From 59b91a0919f108b0f7a566204e3f37265618c742 Mon Sep 17 00:00:00 2001 From: Lee Calcote Date: Mon, 14 Sep 2026 22:39:46 +0000 Subject: [PATCH 1/5] fix: eliminate React hydration errors on blog pages Blog posts logged dozens of console errors on every load - repeated "Minified React error #418" (hydration failed, the initial UI does not match the server render) followed by "#423" (React gave up and switched the entire root to client rendering, discarding the SSR output). Running the production bundle against a development React build named the cause: "validateDOMNesting(...):

cannot appear as a descendant of

" and "Expected server HTML to contain a matching

in

". MDX parses the body of a multi-line JSX element as Markdown, so

Some text.

compiles to

Some text.

. That markup is invalid, so the browser closes the outer

before the nested one and the parsed DOM no longer matches the tree React renders on the client. The same happened wherever a heading, list, table,

or code block was written inside a hand-written

, and for every Markdown image, because the MDX image component wrapped each one in a

while sitting inside a paragraph. Fixes, all at the source: - Add a rehype plugin that rewrites the tree the way the HTML parser would: nested paragraphs collapse into their parent (keeping whichever wrapper carries the attributes), and other block-level children are hoisted out of the paragraph as siblings. It also lifts a lone Markdown paragraph out of components that render a single text element, such as Typography. - Wrap MDX images in a display:block instead of a
, which is valid inside the paragraph the image renders in. - Close the paragraph before the list on the Sistent color page. Verified with a lite blog build served locally and loaded in Chromium, in both light and dark color schemes: /blog/engineering/why-claude-code-cant-find-your-tools 53 -> 0 /blog/engineering/claude-code-skills-not-found-... 34 -> 0 /blog, / (controls) 0 -> 0 Invalid block-in-paragraph nesting across the whole built site dropped from 517 occurrences on 118 pages to zero. Signed-off-by: Lee Calcote --- gatsby-config.js | 6 + rehype-fix-paragraph-nesting.js | 233 ++++++++++++++++++ root-wrapper.js | 9 +- .../Projects/Sistent/identity/color/index.js | 31 ++- 4 files changed, 265 insertions(+), 14 deletions(-) create mode 100644 rehype-fix-paragraph-nesting.js diff --git a/gatsby-config.js b/gatsby-config.js index 19c2baad345d46..4a0ae824cecd88 100644 --- a/gatsby-config.js +++ b/gatsby-config.js @@ -5,6 +5,7 @@ const { getExcludedCollections, isFullSiteBuild, } = require("./src/utils/build-collections"); +const rehypeFixParagraphNesting = require("./rehype-fix-paragraph-nesting"); const isDevelopment = process.env.NODE_ENV === "development"; const isProduction = process.env.NODE_ENV === "production"; @@ -513,6 +514,11 @@ module.exports = { options: { extensions: [".mdx", ".md"], gatsbyRemarkPlugins: [], + mdxOptions: { + // Keeps MDX from emitting block-level content inside

, which the + // browser's parser re-shapes and React then fails to hydrate. + rehypePlugins: [rehypeFixParagraphNesting], + }, }, }, { diff --git a/rehype-fix-paragraph-nesting.js b/rehype-fix-paragraph-nesting.js new file mode 100644 index 00000000000000..57e358e90a79da --- /dev/null +++ b/rehype-fix-paragraph-nesting.js @@ -0,0 +1,233 @@ +/* eslint-env node */ + +/** + * rehype plugin: keep block-level content out of

elements. + * + * MDX parses the body of a multi-line JSX element as Markdown, so authoring + * + *

+ * Some text. + *

+ * + * compiles to `

Some text.

`. The same happens whenever a heading, + * list, table,
or code block is written inside a hand-written

. + * + * That markup is invalid HTML. When the browser parses the server-rendered + * page it silently closes the outer

before the block-level child, so the + * parsed DOM no longer matches the tree React renders on the client. React then + * fails hydration ("Minified React error #418") and, having no Suspense + * boundary to recover into, discards the server markup and re-renders the whole + * root on the client ("Minified React error #423"). + * + * This plugin rewrites the tree so the emitted HTML is valid: nested paragraphs + * are collapsed into their parent, and any other block-level child is hoisted + * out of the paragraph the way the HTML parser would place it. The parsed DOM + * then matches React's tree and hydration succeeds. + */ + +// Elements whose start tag implicitly closes an open

(HTML Standard, +// "a p element's end tag can be omitted" plus the in-body insertion rules). +const BLOCK_LEVEL_TAGS = new Set([ + "address", + "article", + "aside", + "blockquote", + "center", + "dd", + "details", + "dialog", + "dir", + "div", + "dl", + "dt", + "fieldset", + "figcaption", + "figure", + "footer", + "form", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hgroup", + "hr", + "li", + "main", + "menu", + "nav", + "ol", + "p", + "pre", + "section", + "summary", + "table", + "ul", +]); + +// Components that render a single text element (

by default). MDX turns +// their multi-line body into a Markdown paragraph, which lands inside whatever +// element the component renders — invalid there for every variant it supports. +const PARAGRAPH_COMPONENTS = new Set(["Typography"]); + +const tagNameOf = (node) => { + if (!node || typeof node !== "object") return null; + if (node.type === "element") return node.tagName; + if (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") { + return node.name; + } + return null; +}; + +const isBlockLevel = (node) => { + const tagName = tagNameOf(node); + return ( + typeof tagName === "string" && BLOCK_LEVEL_TAGS.has(tagName.toLowerCase()) + ); +}; + +const isParagraph = (node) => tagNameOf(node) === "p"; + +const isBlankText = (node) => + node && + node.type === "text" && + typeof node.value === "string" && + node.value.trim() === ""; + +// Whether a paragraph node carries markup worth keeping (a className, an id, …) +// when a redundant wrapper has to be dropped. +const hasAttributes = (node) => { + if (node.type === "element") { + return !!node.properties && Object.keys(node.properties).length > 0; + } + return Array.isArray(node.attributes) && node.attributes.length > 0; +}; + +// `

text

` keeps whichever of the two wrappers carries +// the attributes, so styling written by the author survives. +const collapseSoleNestedParagraph = (paragraph) => { + let current = paragraph; + for (;;) { + const content = current.children.filter((child) => !isBlankText(child)); + if (content.length !== 1 || !isParagraph(content[0])) return current; + + const inner = content[0]; + if (hasAttributes(current) || !hasAttributes(inner)) { + current.children = inner.children; + } else { + current = inner; + } + } +}; + +// `` and friends render one text element, so a lone Markdown +// paragraph in their body is always misplaced. Only a sole paragraph is lifted: +// a body deliberately written as several paragraphs is left as the author wrote +// it, since the component would have to be given a block-level `component` prop +// for that to be valid anyway. +const liftSoleParagraphFromComponent = (node) => { + const name = tagNameOf(node); + if (!name || !PARAGRAPH_COMPONENTS.has(name)) return; + if (!Array.isArray(node.children)) return; + + const content = node.children.filter((child) => !isBlankText(child)); + if ( + content.length !== 1 || + !isParagraph(content[0]) || + hasAttributes(content[0]) + ) + return; + + node.children = content[0].children; +}; + +// A paragraph reached through inline wrappers is invalid too +// (`

text

`), so drop it and keep its content in place. +// Block-level children are left alone: they get hoisted out of the paragraph +// afterwards, and paragraphs are legal once inside them. +const unwrapInlineParagraphs = (node) => { + if (!Array.isArray(node.children)) return; + + const children = []; + for (const child of node.children) { + if (isBlockLevel(child) && !isParagraph(child)) { + children.push(child); + continue; + } + unwrapInlineParagraphs(child); + if (isParagraph(child)) children.push(...child.children); + else children.push(child); + } + node.children = children; +}; + +// Turn one paragraph into the sequence of siblings a browser would produce: +// inline runs stay in a paragraph, block-level children become siblings. +const splitParagraph = (paragraph) => { + const siblings = []; + let inlineRun = []; + let isFirstSegment = true; + + const flushInlineRun = () => { + if (inlineRun.some((child) => !isBlankText(child))) { + if (isFirstSegment) { + paragraph.children = inlineRun; + siblings.push(paragraph); + } else { + const segment = { ...paragraph, children: inlineRun }; + delete segment.position; + siblings.push(segment); + } + isFirstSegment = false; + } + inlineRun = []; + }; + + for (const child of paragraph.children) { + if (isBlockLevel(child)) { + flushInlineRun(); + siblings.push(child); + } else { + inlineRun.push(child); + } + } + flushInlineRun(); + + return siblings; +}; + +// Depth-first, so a paragraph that only becomes invalid after its own children +// are normalised is still handled by its parent on the way back up. +const fixParagraphNesting = (node) => { + if (!node || !Array.isArray(node.children)) return; + + node.children.forEach(fixParagraphNesting); + liftSoleParagraphFromComponent(node); + + const children = []; + for (const child of node.children) { + if (!isParagraph(child) || !Array.isArray(child.children)) { + children.push(child); + continue; + } + + const paragraph = collapseSoleNestedParagraph(child); + unwrapInlineParagraphs(paragraph); + + if (paragraph.children.some(isBlockLevel)) + children.push(...splitParagraph(paragraph)); + else children.push(paragraph); + } + node.children = children; +}; + +const rehypeFixParagraphNesting = () => (tree) => { + fixParagraphNesting(tree); + return tree; +}; + +module.exports = rehypeFixParagraphNesting; +module.exports.BLOCK_LEVEL_TAGS = BLOCK_LEVEL_TAGS; +module.exports.PARAGRAPH_COMPONENTS = PARAGRAPH_COMPONENTS; diff --git a/root-wrapper.js b/root-wrapper.js index 7472bcbe0ea329..77e718e2157b90 100644 --- a/root-wrapper.js +++ b/root-wrapper.js @@ -8,10 +8,13 @@ import { ContextWrapper } from "./context-wrapper"; import { IoIosCopy } from "@react-icons/all-files/io/IoIosCopy"; import { IoIosCheckmark } from "@react-icons/all-files/io/IoIosCheckmark"; -// Custom image component for better CLS scores +// Custom image component for better CLS scores. +// The wrapper is a rather than a
: Markdown images render inside a +//

, and a block-level wrapper there is invalid HTML that the browser's +// parser re-shapes, which breaks hydration. const OptimizedImage = (props) => { return ( -

+ { loading="lazy" alt={props.alt || "Blog content image"} /> -
+ ); }; diff --git a/src/sections/Projects/Sistent/identity/color/index.js b/src/sections/Projects/Sistent/identity/color/index.js index 809983ef233120..6cf8279d01237e 100644 --- a/src/sections/Projects/Sistent/identity/color/index.js +++ b/src/sections/Projects/Sistent/identity/color/index.js @@ -7,7 +7,14 @@ import ContextVisuals1 from "../../../../../assets/images/app/projects/sistent/c import ContextVisuals2 from "../../../../../assets/images/app/projects/sistent/context-visuals-2.webp"; import ContextVisuals3 from "../../../../../assets/images/app/projects/sistent/context-visuals-3.webp"; import ContextVisuals4 from "../../../../../assets/images/app/projects/sistent/context-visuals-4.webp"; -import { useTheme, Tooltip, Snackbar, IconButton, styled, NoSsr } from "@sistent/sistent"; +import { + useTheme, + Tooltip, + Snackbar, + IconButton, + styled, + NoSsr, +} from "@sistent/sistent"; import { SistentLayout } from "../../sistent-layout"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; @@ -301,16 +308,18 @@ const SistentIdentityColor = () => {

There is an alternate relationship between the layer hierarchy in both light and dark themes: -

    -
  • - In the light theme, as layers are stacked towards the topmost - surface, they become progressively darker. -
  • -
  • - In the dark theme, as layers are stacked towards the topmost - surface, they become progressively lighter. -
  • -
+

+
    +
  • + In the light theme, as layers are stacked towards the topmost + surface, they become progressively darker. +
  • +
  • + In the dark theme, as layers are stacked towards the topmost + surface, they become progressively lighter. +
  • +
+

This means, in effect, that while there is a fine blend of surfaces on any given UI theme, there is also strict adherence to accessibility guidelines and recommendations. From 1b4e356e1981ba853e98d978ea51eaf2367717c2 Mon Sep 17 00:00:00 2001 From: Lee Calcote Date: Fri, 18 Sep 2026 21:14:24 +0000 Subject: [PATCH 2/5] fix: emit the table structure the HTML parser implies A post merged into master after the paragraph fix brought the same hydration failure back on one blog page: 35x "Minified React error #418" plus "#423" on /blog/ai/ai-system-1-versus-system-2-tasks. The cause is a different parser rule with the same consequence. The post writes its comparison table as , and the parser always inserts the implied , so every row sits one level deeper in the DOM than React put it. /blog/meshery/deploying-linkerd-with-meshery hit the same thing through . Rather than keep guessing which shapes the parser rewrites, the markup is now checked against the parser itself: every built page is parsed in Chromium and the resulting element sequence compared with the sequence the server HTML was written in. Any divergence is a hydration bug. That found these two table cases, and confirms no other blog page diverges. So the plugin now also applies the parser's table rules - wrapping bare rows in a , bare cells in a , and dropping the whitespace the parser would foster-parent out of a table. Renamed to rehype-fix-dom-nesting.js, since it is no longer only about paragraphs. Verified on the merged head, in both light and dark color schemes: /blog/ai/ai-system-1-versus-system-2-tasks 36 -> 0 /blog/meshery/deploying-linkerd-with-meshery 0 -> 0 5 other blog pages, /blog, / (controls) 0 -> 0 Pages whose markup the parser restructures: 14 -> 11, with none of the remaining 11 on a blog page. They are all hand-written JSX in components rather than MDX content, so the build-time transform cannot reach them. Signed-off-by: Lee Calcote --- gatsby-config.js | 4 +- ...ph-nesting.js => rehype-fix-dom-nesting.js | 104 ++++++++++++++---- 2 files changed, 87 insertions(+), 21 deletions(-) rename rehype-fix-paragraph-nesting.js => rehype-fix-dom-nesting.js (62%) diff --git a/gatsby-config.js b/gatsby-config.js index 4a0ae824cecd88..c05d7d03127bf8 100644 --- a/gatsby-config.js +++ b/gatsby-config.js @@ -5,7 +5,7 @@ const { getExcludedCollections, isFullSiteBuild, } = require("./src/utils/build-collections"); -const rehypeFixParagraphNesting = require("./rehype-fix-paragraph-nesting"); +const rehypeFixDomNesting = require("./rehype-fix-dom-nesting"); const isDevelopment = process.env.NODE_ENV === "development"; const isProduction = process.env.NODE_ENV === "production"; @@ -517,7 +517,7 @@ module.exports = { mdxOptions: { // Keeps MDX from emitting block-level content inside

, which the // browser's parser re-shapes and React then fails to hydrate. - rehypePlugins: [rehypeFixParagraphNesting], + rehypePlugins: [rehypeFixDomNesting], }, }, }, diff --git a/rehype-fix-paragraph-nesting.js b/rehype-fix-dom-nesting.js similarity index 62% rename from rehype-fix-paragraph-nesting.js rename to rehype-fix-dom-nesting.js index 57e358e90a79da..a1e904950eabe0 100644 --- a/rehype-fix-paragraph-nesting.js +++ b/rehype-fix-dom-nesting.js @@ -1,28 +1,36 @@ /* eslint-env node */ /** - * rehype plugin: keep block-level content out of

elements. + * rehype plugin: emit markup the HTML parser will not rearrange. * - * MDX parses the body of a multi-line JSX element as Markdown, so authoring + * React hydration compares the tree it renders against the DOM the browser + * parsed from the server-rendered HTML. Where those disagree, hydration fails + * ("Minified React error #418") and, with no Suspense boundary to recover into, + * React discards the server markup and re-renders the whole root on the client + * ("Minified React error #423"). Markup the parser silently restructures is + * therefore a hydration bug, even when it renders the way the author intended. * - *

- * Some text. - *

+ * Two shapes show up in MDX content: * - * compiles to `

Some text.

`. The same happens whenever a heading, - * list, table,
or code block is written inside a hand-written

. + * 1. Block-level content inside a

. MDX parses the body of a multi-line JSX + * element as Markdown, so authoring * - * That markup is invalid HTML. When the browser parses the server-rendered - * page it silently closes the outer

before the block-level child, so the - * parsed DOM no longer matches the tree React renders on the client. React then - * fails hydration ("Minified React error #418") and, having no Suspense - * boundary to recover into, discards the server markup and re-renders the whole - * root on the client ("Minified React error #423"). + *

+ * Some text. + *

* - * This plugin rewrites the tree so the emitted HTML is valid: nested paragraphs - * are collapsed into their parent, and any other block-level child is hoisted - * out of the paragraph the way the HTML parser would place it. The parsed DOM - * then matches React's tree and hydration succeeds. + * compiles to `

Some text.

`, and the parser closes the outer

+ * before the nested one. The same happens for a heading, list, table,

+ * or code block written inside a hand-written

. + * + * 2. Table rows and cells written without their section. `

, where the parser inserts a
` and + * ` and , so every row lands one level deeper than + * React put it. + * + * This plugin applies the parser's own rules at build time - hoisting block + * children out of paragraphs, and inserting the table sections and rows the + * parser would - so the emitted HTML survives a round trip through it. */ // Elements whose start tag implicitly closes an open

(HTML Standard, @@ -72,6 +80,12 @@ const BLOCK_LEVEL_TAGS = new Set([ // element the component renders — invalid there for every variant it supports. const PARAGRAPH_COMPONENTS = new Set(["Typography"]); +// Table structure the parser fills in when rows or cells are written bare. +const TABLE_SECTION_TAGS = new Set(["thead", "tbody", "tfoot"]); +const TABLE_CELL_TAGS = new Set(["td", "th"]); +// Stray text in any of these is "foster parented" out of the table entirely. +const TABLE_INTERNAL_TAGS = new Set(["table", "thead", "tbody", "tfoot", "tr"]); + const tagNameOf = (node) => { if (!node || typeof node !== "object") return null; if (node.type === "element") return node.tagName; @@ -81,6 +95,8 @@ const tagNameOf = (node) => { return null; }; +const lowerTagNameOf = (node) => (tagNameOf(node) || "").toLowerCase(); + const isBlockLevel = (node) => { const tagName = tagNameOf(node); return ( @@ -198,6 +214,55 @@ const splitParagraph = (paragraph) => { return siblings; }; +// Group each run of children the parser would gather under an implied element. +const wrapRunsOfChildren = (node, belongsInWrapper, wrapperTagName) => { + if (!node.children.some(belongsInWrapper)) return; + + const children = []; + let run = []; + const flushRun = () => { + if (run.length) { + children.push({ + type: "element", + tagName: wrapperTagName, + properties: {}, + children: run, + }); + run = []; + } + }; + + for (const child of node.children) { + if (belongsInWrapper(child)) run.push(child); + else { + flushRun(); + children.push(child); + } + } + flushRun(); + node.children = children; +}; + +// Insert the

and the parser would, and drop the whitespace it +// would hoist out of the table, so the markup round-trips through the parser. +const fixTableStructure = (node) => { + const tagName = lowerTagNameOf(node); + if (!TABLE_INTERNAL_TAGS.has(tagName)) return; + + node.children = node.children.filter((child) => !isBlankText(child)); + + const isCell = (child) => TABLE_CELL_TAGS.has(lowerTagNameOf(child)); + const isRow = (child) => lowerTagNameOf(child) === "tr"; + + if (TABLE_SECTION_TAGS.has(tagName)) { + wrapRunsOfChildren(node, isCell, "tr"); + } else if (tagName === "table") { + // A bare cell implies a row as well as a section, so wrap cells first. + wrapRunsOfChildren(node, isCell, "tr"); + wrapRunsOfChildren(node, isRow, "tbody"); + } +}; + // Depth-first, so a paragraph that only becomes invalid after its own children // are normalised is still handled by its parent on the way back up. const fixParagraphNesting = (node) => { @@ -205,6 +270,7 @@ const fixParagraphNesting = (node) => { node.children.forEach(fixParagraphNesting); liftSoleParagraphFromComponent(node); + fixTableStructure(node); const children = []; for (const child of node.children) { @@ -223,11 +289,11 @@ const fixParagraphNesting = (node) => { node.children = children; }; -const rehypeFixParagraphNesting = () => (tree) => { +const rehypeFixDomNesting = () => (tree) => { fixParagraphNesting(tree); return tree; }; -module.exports = rehypeFixParagraphNesting; +module.exports = rehypeFixDomNesting; module.exports.BLOCK_LEVEL_TAGS = BLOCK_LEVEL_TAGS; module.exports.PARAGRAPH_COMPONENTS = PARAGRAPH_COMPONENTS; From 18ba1afbad6bc931356e20476cca77cff23bffe8 Mon Sep 17 00:00:00 2001 From: Lee Calcote Date: Fri, 18 Sep 2026 21:38:37 +0000 Subject: [PATCH 3/5] fix: close three gaps in the DOM nesting transform Review findings on the plugin, each reproduced against the current code before fixing and covered by the checks below. A block reached through an inline wrapper was left in place. The search for block children only looked at a paragraph's direct children, so `

a

B
c

` kept its invalid nesting. The split is now recursive and mirrors what the parser builds when it reconstructs a formatting element after a block: `

a

`, `
B
`, `

c

`. A paragraph component holding more than one paragraph was skipped, so `` with two Markdown paragraphs still emitted `

`. Each paragraph now becomes its own copy of the component, which keeps the author's paragraph breaks and their styling rather than running the text together. A paragraph carrying its own markup is kept in place of the component wrapper, since the more specific attributes are the ones worth preserving - that case was previously skipped too. Non-whitespace text between a table's rows and cells was left inside the table, where the parser instead "foster parents" it out to just before the table. It is now moved there at build time. Whitespace is still simply dropped, since it renders as nothing once outside the table. No change to the built output of the current content: pages the parser restructures stays at 11, none of them a blog page, and all ten probed pages remain at zero console errors in both color schemes. These were latent gaps rather than live failures - which is how the table case started out too, until a post landed that hit it. Signed-off-by: Lee Calcote --- rehype-fix-dom-nesting.js | 158 +++++++++++++++++++++++++++++--------- 1 file changed, 120 insertions(+), 38 deletions(-) diff --git a/rehype-fix-dom-nesting.js b/rehype-fix-dom-nesting.js index a1e904950eabe0..75fd0f6ca5cc56 100644 --- a/rehype-fix-dom-nesting.js +++ b/rehype-fix-dom-nesting.js @@ -143,20 +143,26 @@ const collapseSoleNestedParagraph = (paragraph) => { // a body deliberately written as several paragraphs is left as the author wrote // it, since the component would have to be given a block-level `component` prop // for that to be valid anyway. -const liftSoleParagraphFromComponent = (node) => { +const isParagraphComponent = (node) => { const name = tagNameOf(node); - if (!name || !PARAGRAPH_COMPONENTS.has(name)) return; - if (!Array.isArray(node.children)) return; - - const content = node.children.filter((child) => !isBlankText(child)); - if ( - content.length !== 1 || - !isParagraph(content[0]) || - hasAttributes(content[0]) - ) - return; + return !!name && PARAGRAPH_COMPONENTS.has(name); +}; - node.children = content[0].children; +// `` and friends render one text element, so a Markdown paragraph +// in their body is always misplaced. Each paragraph becomes its own copy of the +// component, which keeps the author's paragraph breaks and their styling: a +// body written as two paragraphs renders as two, rather than running together. +// A paragraph carrying its own markup is kept instead of the component wrapper, +// since the more specific attributes are the ones worth preserving. +const liftParagraphsFromComponent = (component) => { + const content = component.children.filter((child) => !isBlankText(child)); + if (!content.length || !content.every(isParagraph)) return [component]; + + return content.map((paragraph) => + hasAttributes(paragraph) + ? paragraph + : withChildren(component, paragraph.children), + ); }; // A paragraph reached through inline wrappers is invalid too @@ -179,38 +185,80 @@ const unwrapInlineParagraphs = (node) => { node.children = children; }; -// Turn one paragraph into the sequence of siblings a browser would produce: -// inline runs stay in a paragraph, block-level children become siblings. -const splitParagraph = (paragraph) => { - const siblings = []; - let inlineRun = []; - let isFirstSegment = true; +const withChildren = (node, children) => { + const copy = { ...node, children }; + delete copy.position; + return copy; +}; + +// A block reached through inline wrappers (`

a

B
c

`) +// closes the paragraph just as a direct child would, so it has to be found at +// any inline depth. +const containsBlockLevel = (node) => + Array.isArray(node.children) && + node.children.some( + (child) => isBlockLevel(child) || containsBlockLevel(child), + ); +// Separate a run of children into the inline pieces and block-level pieces the +// parser would end up with, splitting inline wrappers around any block they +// contain: `a
B
c
` becomes `a`, `
B
`, +// `c`, which is what the parser builds when it reconstructs the +// formatting element after the block. +const partitionAroundBlocks = (children) => { + const pieces = []; + let inlineRun = []; const flushInlineRun = () => { - if (inlineRun.some((child) => !isBlankText(child))) { - if (isFirstSegment) { - paragraph.children = inlineRun; - siblings.push(paragraph); - } else { - const segment = { ...paragraph, children: inlineRun }; - delete segment.position; - siblings.push(segment); - } - isFirstSegment = false; + if (inlineRun.length) { + pieces.push({ inline: inlineRun }); + inlineRun = []; } - inlineRun = []; }; - for (const child of paragraph.children) { + for (const child of children) { if (isBlockLevel(child)) { flushInlineRun(); - siblings.push(child); + pieces.push({ block: child }); + } else if (containsBlockLevel(child)) { + for (const piece of partitionAroundBlocks(child.children)) { + if (piece.block) { + flushInlineRun(); + pieces.push(piece); + } else { + inlineRun.push(withChildren(child, piece.inline)); + } + } } else { inlineRun.push(child); } } flushInlineRun(); + return pieces; +}; + +// Turn one paragraph into the sequence of siblings a browser would produce: +// inline runs stay in a paragraph, block-level content becomes a sibling. +const splitParagraph = (paragraph) => { + const siblings = []; + let isFirstSegment = true; + + for (const piece of partitionAroundBlocks(paragraph.children)) { + if (piece.block) { + siblings.push(piece.block); + continue; + } + if (piece.inline.every(isBlankText)) continue; + + if (isFirstSegment) { + paragraph.children = piece.inline; + siblings.push(paragraph); + isFirstSegment = false; + } else { + siblings.push(withChildren(paragraph, piece.inline)); + } + } + return siblings; }; @@ -243,14 +291,11 @@ const wrapRunsOfChildren = (node, belongsInWrapper, wrapperTagName) => { node.children = children; }; -// Insert the
and the parser would, and drop the whitespace it -// would hoist out of the table, so the markup round-trips through the parser. +// Insert the and the parser would, so the markup round-trips. const fixTableStructure = (node) => { const tagName = lowerTagNameOf(node); if (!TABLE_INTERNAL_TAGS.has(tagName)) return; - node.children = node.children.filter((child) => !isBlankText(child)); - const isCell = (child) => TABLE_CELL_TAGS.has(lowerTagNameOf(child)); const isRow = (child) => lowerTagNameOf(child) === "tr"; @@ -263,18 +308,55 @@ const fixTableStructure = (node) => { } }; +// Text between a table's rows and cells belongs to no cell, so the parser +// "foster parents" it: the text is moved out to just before the table. Doing +// the same here keeps the emitted markup and the parsed DOM in step. Whitespace +// is simply dropped, since it renders as nothing once outside the table. +const fosterParentStrayText = (table) => { + const strayText = []; + + const collect = (node) => { + if (!TABLE_INTERNAL_TAGS.has(lowerTagNameOf(node))) return; + node.children = node.children.filter((child) => { + if (child.type !== "text") { + collect(child); + return true; + } + if (!isBlankText(child)) strayText.push(child); + return false; + }); + }; + collect(table); + + return strayText; +}; + // Depth-first, so a paragraph that only becomes invalid after its own children // are normalised is still handled by its parent on the way back up. const fixParagraphNesting = (node) => { if (!node || !Array.isArray(node.children)) return; node.children.forEach(fixParagraphNesting); - liftSoleParagraphFromComponent(node); fixTableStructure(node); const children = []; for (const child of node.children) { - if (!isParagraph(child) || !Array.isArray(child.children)) { + if (!Array.isArray(child.children)) { + children.push(child); + continue; + } + + if (lowerTagNameOf(child) === "table") { + children.push(...fosterParentStrayText(child), child); + continue; + } + + if (isParagraphComponent(child)) { + children.push(...liftParagraphsFromComponent(child)); + continue; + } + + if (!isParagraph(child)) { children.push(child); continue; } @@ -282,7 +364,7 @@ const fixParagraphNesting = (node) => { const paragraph = collapseSoleNestedParagraph(child); unwrapInlineParagraphs(paragraph); - if (paragraph.children.some(isBlockLevel)) + if (containsBlockLevel(paragraph)) children.push(...splitParagraph(paragraph)); else children.push(paragraph); } From f1bb80192c7db426c0f52abb5943df272242c8d1 Mon Sep 17 00:00:00 2001 From: Lee Calcote Date: Fri, 18 Sep 2026 22:05:48 +0000 Subject: [PATCH 4/5] fix: split paragraph components around block content too Follow-up review finding, reproduced first: a component that renders a single text element still emitted invalid markup when its body mixed paragraphs with other block content. `` holding a paragraph, a list and another paragraph came back out of the plugin untouched, because the lift only ran when every child was a paragraph. It now splits the same way a paragraph does: each Markdown paragraph becomes its own copy of the component, and any other block-level content moves out beside it, which is where the parser puts it. Blocks reached through inline wrappers are handled by the same recursive split used for paragraphs. No change to the built output of the current content: pages the parser restructures stays at 11, none of them a blog page, and all ten probed pages remain at zero console errors in both color schemes. Signed-off-by: Lee Calcote --- rehype-fix-dom-nesting.js | 44 +++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/rehype-fix-dom-nesting.js b/rehype-fix-dom-nesting.js index 75fd0f6ca5cc56..cef6b88699c4db 100644 --- a/rehype-fix-dom-nesting.js +++ b/rehype-fix-dom-nesting.js @@ -156,13 +156,45 @@ const isParagraphComponent = (node) => { // since the more specific attributes are the ones worth preserving. const liftParagraphsFromComponent = (component) => { const content = component.children.filter((child) => !isBlankText(child)); - if (!content.length || !content.every(isParagraph)) return [component]; - - return content.map((paragraph) => - hasAttributes(paragraph) - ? paragraph - : withChildren(component, paragraph.children), + const needsWork = content.some( + (child) => + isParagraph(child) || isBlockLevel(child) || containsBlockLevel(child), ); + if (!content.length || !needsWork) return [component]; + + const siblings = []; + let inlineRun = []; + const flushInlineRun = () => { + if (inlineRun.length) { + siblings.push(withChildren(component, inlineRun)); + inlineRun = []; + } + }; + + for (const child of content) { + if (isParagraph(child)) { + flushInlineRun(); + // A paragraph carrying its own markup is kept in place of the component + // wrapper, since the more specific attributes are worth preserving. + siblings.push( + hasAttributes(child) ? child : withChildren(component, child.children), + ); + continue; + } + // Anything else block-level cannot live in a text element at all, so it + // moves out beside the component, exactly as the parser would place it. + for (const piece of partitionAroundBlocks([child])) { + if (piece.block) { + flushInlineRun(); + siblings.push(piece.block); + } else { + inlineRun.push(...piece.inline); + } + } + } + flushInlineRun(); + + return siblings.length ? siblings : [component]; }; // A paragraph reached through inline wrappers is invalid too From 8064f22f495cb0a839883a73952664770fb9c593 Mon Sep 17 00:00:00 2001 From: Lee Calcote Date: Fri, 18 Sep 2026 22:29:10 +0000 Subject: [PATCH 5/5] fix: keep separating whitespace when splitting a paragraph component Review finding on the previous commit, and a regression it introduced: splitting a paragraph component rebuilt its inline runs from a list that had already had whitespace-only nodes filtered out, so a space between two inline children was dropped. `a b
B
` rendered as "ab". Blank nodes are still ignored when deciding whether the component needs splitting at all, but the children are now walked as written, and a run is emitted only when it holds something other than whitespace. This is how the paragraph path already worked, which is why it was unaffected. Site-wide checks unchanged: 11 parser-restructured pages, none of them a blog page, and zero console errors on all ten probed pages in both color schemes. Signed-off-by: Lee Calcote --- rehype-fix-dom-nesting.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/rehype-fix-dom-nesting.js b/rehype-fix-dom-nesting.js index cef6b88699c4db..871df03ee455c3 100644 --- a/rehype-fix-dom-nesting.js +++ b/rehype-fix-dom-nesting.js @@ -155,6 +155,8 @@ const isParagraphComponent = (node) => { // A paragraph carrying its own markup is kept instead of the component wrapper, // since the more specific attributes are the ones worth preserving. const liftParagraphsFromComponent = (component) => { + // Blank text is ignored when deciding what to do, but the children are walked + // as written: whitespace between two inline children is a word separator. const content = component.children.filter((child) => !isBlankText(child)); const needsWork = content.some( (child) => @@ -165,13 +167,13 @@ const liftParagraphsFromComponent = (component) => { const siblings = []; let inlineRun = []; const flushInlineRun = () => { - if (inlineRun.length) { + if (inlineRun.some((child) => !isBlankText(child))) { siblings.push(withChildren(component, inlineRun)); - inlineRun = []; } + inlineRun = []; }; - for (const child of content) { + for (const child of component.children) { if (isParagraph(child)) { flushInlineRun(); // A paragraph carrying its own markup is kept in place of the component
` are how people write tables by hand, but the parser inserts + * the implied