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
115 changes: 115 additions & 0 deletions .changeset/6972-markdown-inert-keys-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
---
'@object-ui/types': minor
'@object-ui/plugin-markdown': minor
---

**Breaking for authored metadata:** `MarkdownSchema.sanitize` and
`MarkdownSchema.components` are RETIRED (objectui#6972, ADR-0049
enforce-or-remove). A `markdown` node that authors either key no longer
validates: the parse fails loudly on that key's path with the explanation in
the message, and both TS members are `?: never` tombstones, so the same document
is refused at compile time. The two keys do not share a disposition — triage
recorded the asymmetry — so each is argued below.

## `sanitize`

**What was measured, on this branch's base.** `sanitize` was declared
`?: boolean` with `@default true` on both published faces — `data-display.ts`
and the Zod mirror — documented, and read by NOTHING. Worse than an ordinary
inert key, it implied a switch that does not exist: sanitization is
**unconditional**. `rehypePlugins` in `plugin-markdown/src/MarkdownImpl.tsx` is
a module-level `const` array whose last link is `[rehypeSanitize, sanitizeSchema]`,
handed to `ReactMarkdown` as-is — no ternary, no `if`, no runtime assembly.
`MarkdownRenderer` forwards exactly `content` and `className`, and
`MarkdownImplProps` accepts only those two. A repo-wide grep for
`schema.sanitize` over `packages/` and `apps/` returns nothing, against a
control of 20 `.tsx` files reading `schema.content` in the same query shape, so
the zero is a reading, not a blind query. An author writing `sanitize: false`
believed they turned XSS filtering off; one writing `sanitize: true` believed
they turned it on. Neither was true.

**Why remove and not enforce.** The enforce arm of enforce-or-remove for this
key is a switch that DISABLES XSS sanitization, which is not an acceptable
outcome; for `sanitize` the ruling collapses to remove (triage on
objectui#6972).

**Who is affected — a `sanitize` authored onto a `markdown` node:**

```json
{ "type": "markdown",
"content": "# Hello",
"sanitize": false } // ← was tolerated, changed nothing
```

now fails validation with:

> RETIRED (objectui#6972) — sanitization is unconditional: rehype-sanitize is a
> fixed last link of the markdown renderer's rehype chain, and no value of this
> key ever switched it. There is no authored spelling that disables XSS
> sanitization; delete the key.

## `components`

**What was measured, on this branch's base.** `components` was declared
`?: Record<string, any>` ("custom components for markdown elements") on both
faces and read by NOTHING: the `components` map `MarkdownImpl` hands to
`ReactMarkdown` is its own module-level `mdComponents` (the mermaid / metadata
fence overrides), never merged with anything off the schema, and
`grep -rn "schema.components"` over `packages/` and `apps/` returns nothing
against the same `schema.content` control. The premise the PM declared
falsifiable — *no host path consumes a `components` map* — was re-measured
before this half was written: `MarkdownImplProps` has no such prop, `LazyMarkdown`
receives only `content` and `className`, and no plugin API, app-shell or runner
site passes one.

**Why remove and not wire, and why not a runtime slot.** A map of React
component overrides is not a value a JSON document can author — the same shape
as the handler keys objectui#6124 retired ("JSON has no function value"). The
`runtime-slot` disposition keeps a TypeScript twin callable when a host-supplied
value actually reaches a renderer; nothing reaches this one, so there is no twin
to keep and the TS face refuses it outright. This half is the PM's disposition
under a declared veto window on objectui#6972, not a maintainer ruling; the PR
stays draft for contract review. A real override slot must arrive as a proposal
WITH its enforcing reader, not by reviving this key.

```json
{ "type": "markdown",
"content": "# Hello",
"components": { "h1": "h2" } } // ← was tolerated, changed nothing
```

now fails validation with:

> RETIRED (objectui#6972) — never read: the markdown renderer forwards only
> `content` and `className`, and a map of React component overrides is not a
> JSON-authorable value. Delete the key; the fenced mermaid / metadata block
> overrides are the renderer's own fixed map, not an authoring surface.

## Both keys

**Two published faces.** `@object-ui/plugin-markdown` re-exports `MarkdownSchema`
from `@object-ui/types` (one authority since objectui#6172) rather than
declaring a copy, so the retirement reaches its consumers through the same
declaration — which is why this changeset names the plugin as well: no plugin
source changes, but the type its published face exposes narrows, and its own
test now pins that the refusal arrives there.

**Who is NOT affected.** A document that never wrote either key is untouched
(`absent` stays valid), `content` and `className` are unchanged, and the
renderer's behaviour is byte-identical — it sanitized unconditionally before
and does now, and its fenced-block overrides are the same fixed map. One
in-repo fixture authored either key
(`packages/types/examples/data-display-examples.json#examples.markdown`,
`"sanitize": true`); the key is deleted from it and the fixture is now pinned
to parse green. No fixture, catalog entry, doc snippet, skill or app in this
repository authored `components` on a markdown node.

**Migration:** delete the keys. There is nothing to replace either with — the
behaviour `sanitize` claimed to control is always on, and no authored spelling
overrides markdown elements.

Graded `minor`, not `patch`: this narrows the accepted input set, which is
breaking for any author who wrote the tolerated key. It is not `major` per
this repo's fixed-group convention (objectui's own breaking changes ship as
`minor`; the group's major tracks `@objectstack` — AGENTS.md 版本号策略,
mechanically enforced by `scripts/check-changeset-no-major.mjs`).
15 changes: 13 additions & 2 deletions content/docs/plugins/plugin-markdown.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,19 @@ renderer's own props type takes a non-optional `string`, and the Zod mirror
|----------|------|---------|-------------|
| `content` | string | — (required) | Markdown content to render |
| `className` | string | `''` | Additional Tailwind CSS classes |
| `sanitize` | boolean | `true` | Declared on the schema; the renderer sanitizes unconditionally |
| `components` | Record | — | Declared on the schema; not read by this renderer |

> **Retired (objectui#6972):** `sanitize` and `components` used to be listed
> here. Neither was ever read. `sanitize` (a boolean defaulting to `true`)
> implied a switch that does not exist: sanitization is **unconditional** —
> `rehype-sanitize` is a fixed last link of the renderer's rehype chain, and no
> value of the key ever switched it, so `sanitize: false` changed nothing while
> reading as a security control. `components` (a `Record` of React component
> overrides) is not a value a JSON document can author, and the renderer's own
> fenced-block overrides (mermaid / metadata) are a fixed internal map, not an
> authoring surface. Both keys are now `never` on the TypeScript face and are
> refused **by name** by the Zod mirror, with that explanation in the parse
> message. There is no authored spelling that disables XSS sanitization, and no
> authored spelling that overrides markdown elements; delete the keys.

## Supported Markdown Features

Expand Down
39 changes: 39 additions & 0 deletions packages/plugin-markdown/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { describe, it, expect } from 'vitest';
import { ComponentRegistry } from '@object-ui/core';
import type { ComponentInput } from '@object-ui/types';
import type { MarkdownSchema } from './types';
// Imports all renderers to register them. Module scope, NOT awaited inside a
// `beforeAll` — there the cold transform of the renderer graph is billed to the
// hook, against `hookTimeout`. That is what made the sibling plugin-kanban test
Expand Down Expand Up @@ -97,4 +98,42 @@ describe('Plugin Markdown', () => {
expect(defaults?.content).toContain('#');
});
});

describe('MarkdownSchema retirements reach this package\'s published face (objectui#6972)', () => {
// `./types` re-exports `MarkdownSchema` from `@object-ui/types` — ONE
// authority (objectui#6172), not a local copy — so an ADR-0049 tombstone
// declared there must be a `tsc` error through THIS package's import
// spelling too. That is the "two published faces" half of the retirement,
// pinned where the second face lives. REAL enforcement: `type-check` runs
// `tsc -p tsconfig.test.json`, so an unused directive fails the build —
// if the plugin ever re-declared a local `MarkdownSchema` that carried the
// key, this leg goes red before anything else does.
it('`sanitize` is a `tsc` error through the re-exported authority — sanitization is unconditional', () => {
const node: MarkdownSchema = {
type: 'markdown',
content: '# Hello',
// @ts-expect-error `sanitize` is an ADR-0049 retirement tombstone (objectui#6972): the renderer sanitizes unconditionally, no value switches it
sanitize: false,
};
expect(node.content).toBe('# Hello');
});

it('`components` is a `tsc` error through the re-exported authority — nothing reads an override map', () => {
const node: MarkdownSchema = {
type: 'markdown',
content: '# Hello',
// @ts-expect-error `components` is an ADR-0049 retirement tombstone (objectui#6972): the renderer forwards only content and className
components: { h1: 'h2' },
};
expect(node.content).toBe('# Hello');
});

it('the two values the renderer DOES forward stay writable — the non-vacuity control', () => {
// `content` and `className` are exactly what `MarkdownRenderer` hands to
// `MarkdownImpl`; without this leg the directive above could be satisfied
// by a face that lost the whole interface.
const node: MarkdownSchema = { type: 'markdown', content: '# Hello', className: 'prose-lg' };
expect(node.className).toBe('prose-lg');
});
});
});
3 changes: 1 addition & 2 deletions packages/types/examples/data-display-examples.json
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,7 @@
"markdown": {
"type": "markdown",
"id": "readme",
"content": "# Welcome\n\nThis is a **markdown** component.\n\n- Feature 1\n- Feature 2\n- Feature 3",
"sanitize": true
"content": "# Welcome\n\nThis is a **markdown** component.\n\n- Feature 1\n- Feature 2\n- Feature 3"
},
"html": {
"type": "html",
Expand Down
Loading
Loading