Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/sunny-ears-repair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@pretextbook/pretext-html": minor
"@pretextbook/format": minor
"@pretextbook/import": minor
"@pretextbook/schema": minor
---

Improved import features
11 changes: 2 additions & 9 deletions packages/format/src/lib/__snapshots__/plaintext.ptx
Original file line number Diff line number Diff line change
@@ -1,9 +1,2 @@
This is just text without any elements.

There are some
strange.
Whitespace-only text nodes here.

line breaks.

But it should all be a single line.
This is just text without any elements. There are some strange. Whitespace-only
text nodes here. line breaks. But it should all be a single line.
52 changes: 52 additions & 0 deletions packages/format/src/lib/format.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,55 @@ describe("verbatim content preservation", () => {
expect(result).toBe(input);
});
});

describe("unwrapped fragment formatting", () => {
// A selection formatted on its own (e.g. paste-and-convert) is often prose
// that will be re-inserted into an existing <p>, so loose top-level text and
// inline elements like <m> should reflow together exactly as they would
// inside a <p>, rather than each inline element being expanded like a block.
it("reflows loose text and <m> together instead of breaking <m> onto its own lines", () => {
const input = `We know that <m>x^2 + y^2 = z^2</m> is the Pythagorean theorem.`;
const result = formatPretext(input);
expect(result).not.toMatch(/<m>\n/);
expect(result).toBe(
"We know that <m>x^2 + y^2 = z^2</m> is the Pythagorean theorem.",
);
});

it("wraps a long run of loose text and <m> at printWidth, same as inside a <p>", () => {
const input = `We know that <m>x^2 + y^2 = z^2</m> is the Pythagorean theorem, and also <m>a^2</m> is a square.`;
const result = formatPretext(input);
const wrappedInsideP = formatPretext(`<p>${input}</p>`)
.split("\n")
.slice(1, -1)
.map((line) => line.replace(/^ {2}/, ""))
.join("\n");
expect(result).toBe(wrappedInsideP);
});

it("keeps a short inline fragment on one line", () => {
const input = `See <m>x^2</m> above.`;
const result = formatPretext(input);
expect(result).toBe("See <m>x^2</m> above.");
});

it("still formats multiple top-level block elements independently", () => {
const input = `<p>First.</p><p>Second.</p>`;
const result = formatPretext(input);
expect(result).toBe("<p>\n First.\n</p>\n\n<p>\n Second.\n</p>");
});

it("still normalizes whitespace in a bare top-level <title>", () => {
const input = `<title>This\n is my title</title>`;
const result = formatPretext(input);
expect(result).toBe("<title>This is my title</title>");
});

it("alternates inline runs and a top-level block child", () => {
const input = `Before text.<md><mrow>x</mrow></md>After text.`;
const result = formatPretext(input);
expect(result).toBe(
"Before text.\n<md>\n <mrow>x</mrow>\n</md>\nAfter text.",
);
});
});
104 changes: 79 additions & 25 deletions packages/format/src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,20 @@ export function serializeXast(tree: Root, options?: FormatOptions): string {
) {
tree = { ...tree, children: tree.children[0].children };
}
for (const child of tree.children) {
appendNode(child, lines, 0, ctx);
}
// Route top-level content through the same mixed-content logic as a <p> body
// rather than dispatching each sibling independently. This matters for
// fragments that aren't wrapped in a block element — e.g. a paste-and-convert
// selection like `text <m>x^2</m> more text` — where loose text and inline
// elements (<m>, <c>, etc.) are logically one flowing run, not standalone
// top-level nodes. Without this, each inline element fell through to
// appendBlock and got expanded onto its own lines like a block environment.
// Uses isTopLevelBlock (not the narrower isBlockChild used inside a real <p>)
// so genuine structural elements like a bare top-level <title> still get
// their normal dispatch instead of being flattened as an inline token.
const children = tree.children.filter(
(c) => !(c.type === "text" && c.value.trim() === ""),
);
appendMixedContent(children, lines, 0, ctx, isTopLevelBlock);
const result = applyBlankLines(lines, ctx);
while (result.length > 0 && result[result.length - 1] === "") result.pop();
return result.join("\n");
Expand Down Expand Up @@ -309,29 +320,31 @@ function appendPar(
out.push(`${ind}</${node.name}>`);
}

// ─── Mixed paragraph (has block children like <md>, <ul>) ────────────────────
// ─── Mixed content (inline runs alternating with block children) ────────────

function appendMixedPar(
node: Element,
/**
* Serializes a sequence of children that alternates between flowing inline
* content (text + inline elements like <m>) and structural block children
* (display math, lists, etc.). Each inline run is collected and reflowed as a
* unit at `depth`; each block child is recursively serialized at `depth`.
*
* Used both for the body of a mixed <p> and for the top-level content of a
* fragment that isn't wrapped in a block element (e.g. a paste-and-convert
* selection formatted on its own) — in both cases loose text and inline
* elements are logically one flowing run and must reflow together rather than
* each becoming an isolated top-level node.
*/
function appendMixedContent(
children: (RootContent | ElementContent)[],
out: string[],
depth: number,
ctx: Ctx,
isBlock: (child: RootContent | ElementContent) => boolean,
): void {
// A mixed <p> alternates between inline runs (text + inline elements) and
// structural block children (display math, lists, etc.). Each inline run is
// collected and reflowed as a unit; each block child is recursively serialized.
const ind = ctx.ind.repeat(depth);
const childInd = ctx.ind.repeat(depth + 1);
if (isEmptyElement(node)) {
out.push(`${ind}${selfClose(node)}`);
return;
}
out.push(`${ind}${openTag(node)}`);

const children = meaningfulChildren(node);
let i = 0;
while (i < children.length) {
if (isBlockChild(children[i])) {
if (isBlock(children[i])) {
const child = children[i] as Element;
i++;

Expand All @@ -352,16 +365,16 @@ function appendMixedPar(
}

const blockLines: string[] = [];
appendElement(child, blockLines, depth + 1, ctx);
appendElement(child, blockLines, depth, ctx);
if (punctuation && blockLines.length > 0) {
blockLines[blockLines.length - 1] += punctuation;
}
out.push(...blockLines);
} else {
// Collect contiguous inline children (text nodes + inline elements) into one
// run, then reflow the whole run at printWidth.
const run: ElementContent[] = [];
while (i < children.length && !isBlockChild(children[i])) {
const run: (RootContent | ElementContent)[] = [];
while (i < children.length && !isBlock(children[i])) {
run.push(children[i]);
i++;
}
Expand All @@ -370,15 +383,35 @@ function appendMixedPar(
for (const line of reflowTokens(
tokens,
ctx.printWidth,
childInd.length,
ind.length,
ctx.breakSentences,
)) {
out.push(`${childInd}${line}`);
out.push(`${ind}${line}`);
}
}
}
}
}

function appendMixedPar(
node: Element,
out: string[],
depth: number,
ctx: Ctx,
): void {
const ind = ctx.ind.repeat(depth);
if (isEmptyElement(node)) {
out.push(`${ind}${selfClose(node)}`);
return;
}
out.push(`${ind}${openTag(node)}`);
appendMixedContent(
meaningfulChildren(node),
out,
depth + 1,
ctx,
isBlockChild,
);
out.push(`${ind}</${node.name}>`);
}

Expand Down Expand Up @@ -495,7 +528,7 @@ function inlineEl(node: Element): string {

// ─── Token collection and reflow ──────────────────────────────────────────────

function collectTokens(children: ElementContent[]): string[] {
function collectTokens(children: (RootContent | ElementContent)[]): string[] {
// Produces a flat list of reflow tokens: words from text nodes, and serialized
// inline elements treated as opaque single tokens.
const tokens: string[] = [];
Expand Down Expand Up @@ -629,7 +662,7 @@ function meaningfulChildren(node: Element): ElementContent[] {
);
}

function isBlockChild(child: ElementContent): boolean {
function isBlockChild(child: RootContent | ElementContent): boolean {
if (child.type !== "element") return false;
const name = child.name;
// <c> and <pf> are in verbatimTags (inline code) but are always rendered inline,
Expand All @@ -651,6 +684,27 @@ function isBlockChild(child: ElementContent): boolean {
);
}

// Like isBlockChild, but for content at the top level of a fragment rather
// than inside a real <p>. isBlockChild is deliberately narrow: tags like
// <title>, <caption>, or <fn> are never legitimate children of a <p>, so it
// doesn't need to recognize them and safely treats them as opaque inline
// tokens if they ever show up there. But those same tags commonly *are* the
// top-level content of a fragment (e.g. a copied `<title>...</title>`), where
// flattening them into a single raw token would skip their normal whitespace
// reflow (see appendSmartPar) instead of just rendering them inline. So at
// the top level, any tag recognized by name in another category — not just
// blockTags/verbatimTags — is dispatched normally rather than flattened.
function isTopLevelBlock(child: RootContent | ElementContent): boolean {
if (isBlockChild(child)) return true;
if (child.type !== "element") return false;
const name = child.name;
return (
smartParTags.includes(name) ||
lineEndTags.includes(name) ||
parTags.includes(name)
);
}

function hasBlockChildren(node: Element): boolean {
return node.children.some((c) => isBlockChild(c));
}
Expand Down
Loading
Loading