diff --git a/common/changes/@microsoft/tsdoc/fix-issue-493_2026-09-27-18-52.json b/common/changes/@microsoft/tsdoc/fix-issue-493_2026-09-27-18-52.json
new file mode 100644
index 00000000..494bceef
--- /dev/null
+++ b/common/changes/@microsoft/tsdoc/fix-issue-493_2026-09-27-18-52.json
@@ -0,0 +1,11 @@
+{
+ "changes": [
+ {
+ "packageName": "@microsoft/tsdoc",
+ "comment": "Fix an issue where `TSDocEmitter` dropped a line break that appeared before an inline node such as a code span, `{@link}` tag, or HTML element, joining the adjacent words.",
+ "type": "patch"
+ }
+ ],
+ "packageName": "@microsoft/tsdoc",
+ "email": "109114+spencerbeggs@users.noreply.github.com"
+}
\ No newline at end of file
diff --git a/tsdoc/src/emitters/__tests__/TSDocEmitter.test.ts b/tsdoc/src/emitters/__tests__/TSDocEmitter.test.ts
index 055e9315..69fce8ab 100644
--- a/tsdoc/src/emitters/__tests__/TSDocEmitter.test.ts
+++ b/tsdoc/src/emitters/__tests__/TSDocEmitter.test.ts
@@ -150,3 +150,54 @@ test('04 TSDocEmitter.renderDeclarationReference()', () => {
});
expect(htmlTag.emitAsTsdoc()).toMatchInlineSnapshot(`"my-package#(MyClass:class)"`);
});
+
+test('05 Emit soft breaks before inline nodes', () => {
+ const input: string = `
+/**
+ * The policy is
+ * \`fail\`.
+ *
+ * See the
+ * {@link Foo} thing.
+ *
+ * This is an
+ * element.
+ *
+ * The policy is
+ * fail now.
+ *
+ * Then \`x\`
+ * then b.
+ *
+ * See {@link Foo}
+ * \`x\` too.
+ */
+`;
+
+ expect(createSnapshot(input)).toMatchInlineSnapshot(`
+Object {
+ "errors": Array [],
+ "output": "
+/**
+ * The policy is
+ * \`fail\`.
+ *
+ * See the
+ * {@link Foo} thing.
+ *
+ * This is an
+ * element.
+ *
+ * The policy is
+ * fail now.
+ *
+ * Then \`x\`
+ * then b.
+ *
+ * See {@link Foo}
+ * \`x\` too.
+ */
+",
+}
+`);
+});
diff --git a/tsdoc/src/transforms/TrimSpacesTransform.ts b/tsdoc/src/transforms/TrimSpacesTransform.ts
index 5f0dcdea..9571fb38 100644
--- a/tsdoc/src/transforms/TrimSpacesTransform.ts
+++ b/tsdoc/src/transforms/TrimSpacesTransform.ts
@@ -21,11 +21,21 @@ export class TrimSpacesTransform {
// as soon as nonempty content is encountered.
let finishedSkippingLeadingSpaces: boolean = false;
- function pushAccumulatedText(): void {
+ function pushAccumulatedText(followedByInlineNode: boolean): void {
const lines: string[] = accumulatedTextChunks.join('').split('\n');
for (let i: number = 0; i < lines.length; i++) {
const line: string = lines[i];
- if (line.length !== 0) {
+ if (line.length === 0) {
+ // If the text ended with a line break and an inline node (e.g. a code span or {@link}) comes next,
+ // preserve the soft break; otherwise the text and the node would be joined without any separator.
+ if (followedByInlineNode && i !== 0 && i === lines.length - 1) {
+ transformedNodes.push(
+ new DocSoftBreak({
+ configuration: docParagraph.configuration
+ })
+ );
+ }
+ } else {
if (i !== 0) {
transformedNodes.push(
new DocSoftBreak({
@@ -90,7 +100,7 @@ export class TrimSpacesTransform {
if (accumulatedTextChunks.length > 0) {
// TODO: We should probably track the accumulatedNodes somehow, e.g. so we can map them back to the
// original excerpts. But we need a developer scenario before we can design this API.
- pushAccumulatedText();
+ pushAccumulatedText(true);
}
transformedNodes.push(node);
@@ -100,7 +110,7 @@ export class TrimSpacesTransform {
// Push the accumulated text
if (accumulatedTextChunks.length > 0) {
- pushAccumulatedText();
+ pushAccumulatedText(false);
}
const transformedParagraph: DocParagraph = new DocParagraph({