Skip to content

fix(plugin-markdown): keep code-span content out of extractToc's HTML strip (objectui#7658) - #7668

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-7658-extracttoc-inline-code-stripping
Sep 4, 2026
Merged

fix(plugin-markdown): keep code-span content out of extractToc's HTML strip (objectui#7658)#7668
os-sam merged 1 commit into
mainfrom
claude/issue-7658-extracttoc-inline-code-stripping

Conversation

@os-sam

@os-sam os-sam commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7658

A note on spelling in this body. Every angle-bracket placeholder below is written as a bare uppercase word (TYPE, NAME, COMPONENT, BASE). GitHub's body sanitizer deletes tag-shaped fragments on save, and neither backticks nor fenced blocks protect them (AGENTS.md, "GitHub mutates body BYTES") — a table written to show angle brackets being eaten would itself have them eaten, and would render as if nothing were wrong.

The defect

stripInline() in packages/plugin-markdown/src/toc.ts applied its rules in sequence: the inline-code rule unwrapped a code span, and the raw-HTML rule that ran next, over that same text deleted tag-shaped text the code span had contained. In the rendered DOM that text is literal — rehype-slug slugs the heading's flattened value and a code span's content is a text value — so the id extractToc produced named an anchor that does not exist. A TOC entry that renders, is clickable, and silently does nothing.

Measured against the real render pipeline, before the fix:

heading (placeholders as words) extractToc id id rehype-slug puts on the heading
objectui generate TYPE NAME (alias g) objectui-generate---alias-g objectui-generate-type-name-alias-g
objectui add COMPONENT objectui-add objectui-add-component
Serving metadata over HTTP (?api=BASE) serving-metadata-over-http-api serving-metadata-over-http-apibase

Seven live headings in this repo's own docs: content/docs/utilities/cli.mdx (3), content/docs/utilities/runner.mdx (1), packages/cli/README.md (3).

The same sequencing had two more consequences the card did not name, both found by the same measurement and both repaired here: the emphasis rules ate the underscores out of a code span (a_b_c slugged as abc), and the link rule rewrote a code span that documents link syntax.

The fix

Code spans are lifted into an opaque slot before any other inline rule and restored verbatim at the end, so nothing reaches inside one. This is the shape the card listed as "tokenising code spans before any other inline rule", and the same approach PR #7657 takes for the gate.

Two properties the slot has to hold at once, and both are pinned:

  • Inert to the emphasis and raw-HTML rules — it carries no *, _ or angle bracket, so those rules cannot see into a code span any more.
  • Still ordinary text to the image and link rules — so a link whose label is a code span keeps collapsing to its label, exactly as the renderer does. Splitting the string on code spans instead of masking would have broken that, since the link rule has to match across the span.

The raw-HTML rule is unchanged and still strips genuine markup. That was verified rather than assumed: remark-rehype runs without allowDangerousHtml, so it drops raw html nodes and keeps the text they wrapped — a heading with a bold tag around a word renders as a-bold-tag, which removing the tags reproduces exactly. This settles the second question the card left open ("a heading containing genuine raw HTML flattens to its raw text under mdast, not to nothing"): for this pipeline it flattens to the wrapped text, and the existing rule already matches it.

The private-use sentinels are written as escape spellings in the source, so the file carries no raw non-ASCII byte; any sentinel present in the input is dropped first, so no input can forge a slot.

Truth source

scripts/github-slug.mjs from PR #7657 is not on this base (checked at 8e501cb9, the merge-base) so nothing here depends on it, and neither it nor scripts/check-doc-links.mjs is touched. As the brief directed, this package uses the real github-slugger it already depends on.

The pin does not re-derive the slug rules at all — re-deriving them would only prove two derivations agree. packages/plugin-markdown/src/toc-anchor-parity.test.tsx renders each heading through MarkdownImpl (the real remark/rehype chain this plugin ships) and compares extractToc's id to the id attribute rehype-slug actually emitted. Reading the rendered id is what proves the anchor exists.

Evidence

All runs at d3ba962a, through the shared verify lock (slot issue-7658); wall-clock figures from it are shared-box readings, not idle-box.

Red first. The new pin was run before the repair existed: 5 failed / 3 passed — the three live shapes, the duplicate-suffix case, and the code-span-literal case. Predicted 5 failures before running; observed 5. The three rendered ids in the table above came out of that run, confirming the card's measurements against the real renderer.

Green. vitest run packages/plugin-markdown/Test Files 5 passed (5), Tests 39 passed (39). Union at d3ba962a including the console docs-portal consumer: Test Files 6 passed (6), Tests 45 passed (45), VITEST_EXIT=0.

Non-vacuity (ablation). Predicted in writing before the leg: red with exactly 5 failures / 3 passes, then 8/8 after restore. Observed exactly that.

  • Mutation proven on disk before the run: blob moved off the HEAD blob (b0eae89f to 1d49f884), anchored grep counts SLOT_OPEN 2 to 0 and the old one-line code rule 0 to 1. The leg refuses to run if any of those checks disagrees.
  • Ablated: Tests 5 failed | 3 passed (8), ABLATED_VITEST_EXIT=1.
  • Restored via git checkout HEAD -- ABSOLUTE_PATH (never a bare checkout) under an EXIT INT TERM trap holding absolute paths from git rev-parse --show-toplevel; verified by blob equality back to b0eae89f, empty git diff HEAD, and SLOT_OPEN back to 2.
  • No rebuild leg is needed and none is claimed: the pin imports ./toc and ./MarkdownImpl by relative path from source, so no dist sits between the mutation and the assertion.

Type-check / lint. pnpm --filter @object-ui/plugin-markdown run type-checkTYPECHECK_EXIT=0. Package lint — PKG_LINT_EXIT=0 (7 pre-existing warnings, 0 errors). Repo-wide eslint . --no-inline-config --format json: 4259 files linted, and both changed files report 0 errors and 0 warnings; the 93 errors in that run are pre-existing across 77 other files, none of them touched here. No narrowing to declare — the full population was linted.

Gates, exit codes captured by redirect before any pipe, each quoted from the gate's own verdict line: check:control-bytes OK (6254 tracked text files); check:phantom-deps OK; check:self-import OK; check:esm-specifiers OK; check:vi-mock-specifiers OK; check:vi-mock-inherit OK; check:shell-escape-residue OK; check:published-tsconfig-exclude OK; check:side-effects-array OK; check:entry-guard OK; check:doc-fences OK; check:published-dist OK; check:dist-completeness OK.

check:readme-exports first reported PRECONDITION NOT MET — "the population COLLAPSED, this run proves nothing", 28 packages unbuilt. Recorded as NOT MEASURED, not as a pass and not as a red; the workspace was then built (39/39 turbo tasks) and it was re-run: OK, with a lit population — 37 of 40 packages read, 52 keys compared both ways.

Changeset gates: check-changeset-presence OK — "2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)"; check-changeset-no-major OK; check-changeset-overwrite OK. skip-changeset is not applied.

Clause 2 determination: no contract review needed

extractToc's signature, its opts shape and the TocItem interface are all byte-identical to before; nothing is added to or withdrawn from the package's exports, and no schema key is added, removed or re-typed. The change is entirely inside a private module-scoped helper, and what moves is the value of an id that was measurably wrong. Consumers that relied on the broken id would have been relying on an anchor that does not exist. So this is a consumer-side bug fix and can land through the merge queue; needs:contract-review is not applied to either carrier.

The visible text field does change for affected headings — it now keeps the code span's content, which is what the heading actually reads. Its only consumer, apps/console/src/pages/DocPage.tsx, renders it as React text (no dangerouslySetInnerHTML anywhere in that file), so the restored characters are escaped by React and cannot become markup.

Adjacent gaps: measured and filed, not repaired here

A corpus sweep was run to check whether this change left anything behind — every file in content/docs plus every packages/*/README.md rendered through MarkdownImpl and compared id-by-id with extractToc. 223 files, 2941 rendered headings (a lit population, not a zero reading); 5 files still diverge, in two classes, neither of them this card's defect class. Per the scope fence they are measured and filed rather than repaired here:

  • objectui#7666extractToc lists a heading the renderer never emits, when the heading sits on the line right after a JSX/raw-HTML block with no blank line. 4 live files, all content/docs/fields/*.mdx.
  • objectui#7667 — the emphasis rules eat intraword underscores, so NON_GRID_ROW_CEILING slugs as nongridrow_ceiling while the anchor reads non_grid_row_ceiling. 1 live instance, packages/react/README.md:224. That card also records setext headings being invisible to extractToc, with zero live instances measured.

Both were dedup-searched first over all 383 open issues in this repo (repo-scoped REST list plus local grep, after a repo-scoped REST read returned HTTP 200), with a lit control: the term extractToc returns objectui#7658, so the instrument was demonstrably not dark. Neither card is assigned and neither carries labels; grading is the triage seat's.

Left untouched per the scope fence: scripts/check-doc-links.mjs, scripts/github-slug.mjs, scripts/__tests__/check-doc-links.test.ts (PR #7657's territory), the getDataConfig producers, and packages/types/src/registry.ts / complex.ts.

Generated by Claude Code in session https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3 (a seat-level id, not a per-change one), dispatched by the domain:ui PM seat.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3


Generated by Claude Code

… strip (objectui#7658)

`stripInline()` unwrapped an inline code span and then ran the raw-HTML rule
over the result, so tag-shaped text that was INSIDE backticks was deleted:
`objectui add <component>` slugged to `objectui-add` while `rehype-slug` put
`objectui-add-component` on the rendered heading, and the TOC's `#id` named an
anchor that does not exist. The same sequencing let the emphasis rules eat the
underscores out of `a_b_c` and the link rule rewrite `[x](y)`.

Code spans are now lifted into an opaque slot before any other inline rule and
restored verbatim at the end. The raw-HTML rule is unchanged and still strips
genuine markup, which is what the renderer does (`remark-rehype` runs without
`allowDangerousHtml`, dropping raw html nodes and keeping the text they wrapped
— measured, not assumed). The slot stays ordinary text to the link rule, so a
code-span link label still collapses to its label.

Pinned against the real render pipeline: the new test renders each heading
through `MarkdownImpl` and compares `extractToc`'s id to the `id` attribute
`rehype-slug` actually emitted, over the three heading shapes this repo's own
docs carry (7 live headings across cli.mdx, runner.mdx, packages/cli/README.md).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3180.1 KB 3191.4 KB
Main entry chunk (gzip) 143.2 KB 350 KB
Entry file index-CTVv7GCd.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 510.70KB 116.21KB
core (index.js) 6.70KB 2.68KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 180.00KB 50.20KB
fields (index.js) 242.27KB 61.22KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.98KB 10.98KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 48.05KB 13.34KB
plugin-charts (index.js) 70.92KB 19.75KB
plugin-chatbot (index.js) 196.19KB 46.43KB
plugin-dashboard (index.js) 132.86KB 34.68KB
plugin-designer (index.js) 212.86KB 43.19KB
plugin-detail (index.js) 250.55KB 64.06KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 132.87KB 32.66KB
plugin-gantt (index.js) 167.41KB 41.04KB
plugin-grid (index.js) 209.40KB 56.80KB
plugin-kanban (index.js) 52.71KB 14.55KB
plugin-list (index.js) 113.28KB 27.59KB
plugin-map (index.js) 20.57KB 6.82KB
plugin-markdown (index.js) 13.84KB 4.77KB
plugin-report (index.js) 43.57KB 11.96KB
plugin-timeline (index.js) 30.84KB 8.85KB
plugin-tree (index.js) 9.35KB 3.23KB
plugin-view (index.js) 85.24KB 20.94KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.58KB 2.23KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(plugin-markdown): extractToc strips tag-shaped text INSIDE inline code, so its #id links miss the heading they name

2 participants