Skip to content

Commit e44e975

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat: exempt routine documentation from design review
1 parent c8d1bf8 commit e44e975

16 files changed

Lines changed: 487 additions & 31 deletions

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

design-diff.config.json

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@
8888
"list": "apps/docs/lib/openapi-specs.ts",
8989
"export": "OPENAPI_SPEC_FILES",
9090
"root": "apps/docs",
91-
"renderer": "apps/docs/lib/openapi.ts"
91+
"renderer": "apps/docs/lib/openapi.ts",
92+
"contentOnly": true
9293
}
9394
],
9495
"environmentAdapters": [
@@ -100,5 +101,62 @@
100101
"implementationModule": "apps/sim/lib/core/config/env-capabilities.ts",
101102
"implementationExport": "wireFallback"
102103
}
103-
]
104+
],
105+
"documentationContent": {
106+
"roots": ["apps/docs/content/", "apps/sim/content/"],
107+
"frontmatterFields": [
108+
"title",
109+
"description",
110+
"author",
111+
"date",
112+
"publishedAt",
113+
"updatedAt",
114+
"readingTime",
115+
"tags",
116+
"category",
117+
"keywords"
118+
],
119+
"components": [
120+
{
121+
"module": "fumadocs-ui/components/callout",
122+
"names": ["Callout"],
123+
"contentProps": ["type", "title"]
124+
},
125+
{
126+
"module": "fumadocs-ui/components/card",
127+
"names": ["Card", "Cards"],
128+
"contentProps": ["title", "description", "href"]
129+
},
130+
{
131+
"module": "fumadocs-ui/components/steps",
132+
"names": ["Step", "Steps"],
133+
"contentProps": ["title"]
134+
},
135+
{
136+
"module": "fumadocs-ui/components/tabs",
137+
"names": ["Tab", "Tabs"],
138+
"contentProps": ["items", "value"]
139+
},
140+
{
141+
"module": "@/components/ui/faq",
142+
"names": ["FAQ"],
143+
"contentProps": ["items"]
144+
},
145+
{
146+
"module": "@/components/ui/block-info-card",
147+
"names": ["BlockInfoCard"],
148+
"contentProps": ["type"]
149+
},
150+
{
151+
"module": "@/components/ui/command-table",
152+
"names": ["CommandTable"],
153+
"contentProps": []
154+
},
155+
{
156+
"module": "@/components/ui/what-you-will-learn",
157+
"names": ["WhatYouWillLearn"],
158+
"contentProps": ["items"]
159+
}
160+
]
161+
}
104162
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@
188188
"unified": "11.0.5",
189189
"unist-util-visit": "5.1.0",
190190
"vitest": "^4.1.0",
191+
"yaml": "2.9.0",
191192
"zod": "4.3.6"
192193
},
193194
"lint-staged": {

scripts/design-diff/README.md

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ deterministic payload. Finding IDs are stable for the same input pair and engine
2929
## Binary decisions and grouped report (schema 3)
3030

3131
The public decisions are **`flag`** and **`exempt`**. Uncertainty produces `flag`, with
32-
the obstacle recorded in `limitations`. Schema and policy version `3.0.0`, engine version `0.3.0`, retain binary decisions
32+
the obstacle recorded in `limitations`. Schema version `3.0.0`, policy version `4.0.0`, engine version `0.4.0`, retain binary decisions
3333
and add bounded values and explicit report truncation metadata.
3434

3535
Each finding groups evidence by **changed source file**. Several changed definitions in
@@ -112,6 +112,7 @@ scripts/design-diff/
112112
environment.ts Literal createEnv schema field comparison
113113
resolve.ts Bounded expression and import resolution
114114
inputs.ts Configured file-loaded documentation inputs
115+
document-content.ts Routine documentation authoring exemptions
115116
infrastructure.ts Rendering lockfile dependency closure
116117
report.ts Value previews and bounded JSON serialization
117118
semantic.ts Full semantic hashes without repeated tree expansion
@@ -150,12 +151,35 @@ TSX/CSS files that an application build or Tailwind source scan could consume.
150151
| Shape/effects | Radius, border, shadow, opacity, filters | Flag |
151152
| Layout | Wrapping, flex/grid sizing, stretching | Flag |
152153
| Visibility | Hidden state, overflow, clipping, layering | Flag |
153-
| Content | Visible copy, JSX/HTML/MDX structure, images, SVG, fonts | Flag |
154+
| Content | Product copy, new product controls, custom MDX UI, images, SVG, fonts | Flag |
155+
| Routine documentation | Prose, headings, tables, code samples, known content components, API-reference data | No finding |
154156
| Motion | Keyframes, transitions, animation props | Flag |
155157
| Infrastructure | Renderer dependencies, lockfile, CSS processors, module mappings | Flag |
156158
| Movement | Coordinates, translation, margins, gaps, alignment | Flag unless the static proof succeeds |
157159
| Nonvisual/equivalent | Comments, erased types, supported formatting and constant extraction | No finding |
158160

161+
Policy 4 exempts routine authoring in the `documentationContent.roots` directories. The parser
162+
projects out prose, Markdown tables, code samples and recognized frontmatter content fields.
163+
Configured Fumadocs/repository components are recognized by their import module and named export,
164+
including local import aliases. Only their declared content props with literal data are exempt;
165+
standard Callout types and tab labels are authoring options. Unknown widgets, appearance overrides,
166+
spreads, unresolved expressions, embedded images/media and unknown frontmatter fields remain in
167+
scope. YAML 2.9.0 uses its data-only core schema with custom tags disabled and aliases bounded;
168+
malformed or unsupported syntax produces a flag. Prose insertion does not renumber retained
169+
presentation definitions. A custom wrapper's opaque contents may still produce conservative flags.
170+
171+
The exemption concerns document instances. Shared documentation components, templates, styles,
172+
rendering infrastructure and screenshot assets are still analyzed. Product UI is unchanged by this
173+
policy: adding a search field/modal/panel flags even when it reuses EMCN; local appearance overrides,
174+
shared tokens and existing movement checks are retained. Product status/error wording and HTML
175+
policy/pricing pages remain in scope. Published Markdown article prose uses the same authoring
176+
exemption; embedded covers and other media remain visual assets.
177+
178+
The original 180-case manifest retains its policy-3 labels. New qualification rates must compare the
179+
same immutable source commits while reporting document-content exclusions separately; policy-3
180+
visual labels are not automatically policy-4 positives. Previous cloud smoke/evaluation runs do not
181+
validate this policy version.
182+
159183
Babel parses JS/TS/JSX; PostCSS parses CSS; parse5 parses HTML; remark parses
160184
Markdown/MDX/frontmatter/GFM. CSS selector, conditional and declaration order are retained.
161185
JSX whitespace follows React's line handling. Class composition and JSX spread/attribute
@@ -202,8 +226,10 @@ unsupported dynamic schemas use ordinary conservative resolution. Whole-environm
202226
settings therefore do not contaminate the signup page solely through this known adapter.
203227

204228
The configured Fumadocs `OPENAPI_SPEC_FILES` list is parsed from Git in each revision. Listed
205-
JSON inputs are compared semantically, retaining array order and attributing changes to both
206-
the spec and configured renderer. Malformed/missing configured inputs flag with a limitation.
229+
JSON inputs are validated as data. The configured `contentOnly` convention exempts valid API-reference
230+
descriptions, schemas, enum lists and specification-list changes. Malformed/missing configured inputs
231+
still flag with a limitation. JSON consumed elsewhere by product UI remains subject to normal tracing.
232+
Renderer and list implementation changes remain subject to normal source/infrastructure analysis.
207233

208234
The graph reuses up to 32,768 import/export snapshots keyed by source blob, resolving their
209235
paths again for each revision. Each consumer resolver retains at most 32 parsed modules,
@@ -248,8 +274,9 @@ This engine is conservative, not a runtime equivalence prover:
248274
- Dynamic module/asset paths, inherited/conditional export maps outside the supported forms,
249275
generated source and arbitrary imperative renderers cannot be fully followed. Directly
250276
detected DOM/canvas operations and configured native rendering use uncertainty fallbacks.
251-
- MDX expressions and embedded HTML scripts are flagged, without running MDX components or
252-
scripts. Plain HTML whitespace is preserved because CSS can make it meaningful.
277+
- Unresolved/custom MDX expressions and embedded HTML scripts are flagged, without running MDX
278+
components or scripts. Literal content in documented authoring components is exempt. Plain HTML
279+
whitespace outside the Markdown authoring exemption is preserved because CSS can make it meaningful.
253280
- Lockfiles compare recognized rendering dependencies and their resolved transitive closure.
254281
Unrelated tooling/backend updates are clean. Unknown rendering configs/plugins still flag.
255282
Inactive variants, unused assets and an apparently inert removed class can also be flagged.

scripts/design-diff/analyze.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ import type { Change, Config, Definition, Report } from '#design-diff/types'
2424
export function emptyReport(): Report {
2525
return {
2626
schemaVersion: '3.0.0',
27-
engineVersion: '0.3.0',
28-
policyVersion: '3.0.0',
27+
engineVersion: '0.4.0',
28+
policyVersion: '4.0.0',
2929
commits: null,
3030
status: 'failed',
3131
flagged: null,

scripts/design-diff/benchmark.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,8 @@ export async function benchmark(args = process.argv.slice(2)): Promise<void> {
205205
result.reportSha256 = hash(bytes)
206206
if (
207207
report.schemaVersion !== '3.0.0' ||
208-
report.engineVersion !== '0.3.0' ||
209-
report.policyVersion !== '3.0.0'
208+
report.engineVersion !== '0.4.0' ||
209+
report.policyVersion !== '4.0.0'
210210
)
211211
throw new Error('Report version mismatch')
212212
if (JSON.stringify(report.commits) !== JSON.stringify(commits))
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import * as t from '@babel/types'
2+
import { parseDocument } from 'yaml'
3+
import { canonicalJson, parseSyntax } from '#design-diff/ast'
4+
import type { Config, Data } from '#design-diff/types'
5+
6+
type Node = Record<string, Data>
7+
8+
const prose = new Set([
9+
'text',
10+
'inlineCode',
11+
'code',
12+
'break',
13+
'thematicBreak',
14+
'definition',
15+
'footnoteReference',
16+
])
17+
const containers = new Set([
18+
'root',
19+
'paragraph',
20+
'heading',
21+
'blockquote',
22+
'list',
23+
'listItem',
24+
'emphasis',
25+
'strong',
26+
'delete',
27+
'link',
28+
'linkReference',
29+
'table',
30+
'tableRow',
31+
'tableCell',
32+
'footnoteDefinition',
33+
])
34+
const proseTags = new Set([
35+
'p',
36+
'span',
37+
'a',
38+
'strong',
39+
'em',
40+
'b',
41+
'i',
42+
'code',
43+
'pre',
44+
'br',
45+
'hr',
46+
'h1',
47+
'h2',
48+
'h3',
49+
'h4',
50+
'h5',
51+
'h6',
52+
'ul',
53+
'ol',
54+
'li',
55+
'blockquote',
56+
'table',
57+
'thead',
58+
'tbody',
59+
'tr',
60+
'th',
61+
'td',
62+
])
63+
64+
function object(value: Data | undefined): value is Node {
65+
return !!value && typeof value === 'object' && !Array.isArray(value)
66+
}
67+
68+
/** Only literal document data qualifies; calls, spreads and JSX never execute or disappear. */
69+
function literal(node: t.Node | null | undefined): boolean {
70+
if (!node) return false
71+
if (
72+
t.isStringLiteral(node) ||
73+
t.isNumericLiteral(node) ||
74+
t.isBooleanLiteral(node) ||
75+
t.isNullLiteral(node)
76+
)
77+
return true
78+
if (t.isArrayExpression(node)) return node.elements.every(literal)
79+
if (t.isObjectExpression(node))
80+
return node.properties.every(
81+
(property) => t.isObjectProperty(property) && !property.computed && literal(property.value)
82+
)
83+
if (t.isTemplateLiteral(node)) return node.expressions.every(literal)
84+
if (t.isUnaryExpression(node) && ['-', '+', '!'].includes(node.operator))
85+
return literal(node.argument)
86+
if (t.isTSAsExpression(node) || t.isTSSatisfiesExpression(node)) return literal(node.expression)
87+
return false
88+
}
89+
90+
function literalExpression(value: string): boolean {
91+
try {
92+
const parsed = parseSyntax(`const value = (${value})`, 'document.tsx').program.body[0]
93+
return t.isVariableDeclaration(parsed) && literal(parsed.declarations[0]?.init)
94+
} catch {
95+
return false
96+
}
97+
}
98+
99+
/** Project authoring content away while retaining custom presentation and unknown mechanisms. */
100+
export function documentPresentation(
101+
nodes: Node[],
102+
policy: NonNullable<Config['documentationContent']>
103+
): Node[] {
104+
const bindings = new Map<string, { module: string; name: string; props: string[] }>()
105+
const approvedImports = new Set<Node>()
106+
for (const node of nodes) {
107+
if (node.type !== 'mdxjsEsm' || typeof node.value !== 'string') continue
108+
const statements = parseSyntax(node.value, 'document.tsx').program.body
109+
let approved = true
110+
for (const statement of statements) {
111+
if (!t.isImportDeclaration(statement) || !statement.specifiers.length) {
112+
approved = false
113+
continue
114+
}
115+
for (const specifier of statement.specifiers) {
116+
const name = t.isImportSpecifier(specifier)
117+
? t.isIdentifier(specifier.imported)
118+
? specifier.imported.name
119+
: specifier.imported.value
120+
: 'default'
121+
const rule = policy.components.find(
122+
(rule) => rule.module === statement.source.value && rule.names.includes(name)
123+
)
124+
if (!rule || !t.isImportSpecifier(specifier)) {
125+
approved = false
126+
continue
127+
}
128+
bindings.set(specifier.local.name, { module: rule.module, name, props: rule.contentProps })
129+
}
130+
}
131+
if (approved) approvedImports.add(node)
132+
}
133+
134+
const project = (node: Node): Node[] => {
135+
const type = String(node.type)
136+
if (prose.has(type)) return []
137+
if (approvedImports.has(node)) return []
138+
if (type === 'yaml' && typeof node.value === 'string') {
139+
const document = parseDocument(node.value, {
140+
schema: 'core',
141+
customTags: [],
142+
uniqueKeys: true,
143+
})
144+
if (document.errors.length || document.warnings.length)
145+
throw new Error('Unsupported documentation frontmatter')
146+
const value = document.toJS({ maxAliasCount: 0 }) as Data
147+
if (!object(value)) throw new Error('Documentation frontmatter must be an object')
148+
const retained = Object.fromEntries(
149+
Object.entries(value).filter(([key]) => !policy.frontmatterFields.includes(key))
150+
)
151+
return Object.keys(retained).length ? [{ ...node, value: canonicalJson(retained) }] : []
152+
}
153+
if (/^mdx.*Expression$/.test(type) && typeof node.value === 'string') {
154+
if (/^\s*\/\*[\s\S]*\*\/\s*$/.test(node.value) || literalExpression(node.value)) return []
155+
return [node]
156+
}
157+
const children = Array.isArray(node.children)
158+
? node.children.flatMap((child) => (object(child) ? project(child) : []))
159+
: []
160+
if (containers.has(type)) return children.length ? [{ ...node, children }] : []
161+
if (/^mdxJsx(?:Flow|Text)Element$/.test(type)) {
162+
const name = typeof node.name === 'string' ? node.name : ''
163+
const binding = bindings.get(name)
164+
const intrinsic = proseTags.has(name)
165+
if (!binding && !intrinsic && name) return [node]
166+
const allowed = binding?.props ?? ['title', 'href', 'id', 'aria-label']
167+
const attributes = Array.isArray(node.attributes)
168+
? node.attributes.filter((attribute) => {
169+
if (
170+
!object(attribute) ||
171+
attribute.type !== 'mdxJsxAttribute' ||
172+
typeof attribute.name !== 'string'
173+
)
174+
return true
175+
if (!allowed.includes(attribute.name)) return true
176+
if (typeof attribute.value === 'string' || attribute.value === null) return false
177+
return (
178+
!object(attribute.value) ||
179+
typeof attribute.value.value !== 'string' ||
180+
!literalExpression(attribute.value.value)
181+
)
182+
})
183+
: []
184+
if (!attributes.length && !children.length) return []
185+
return [
186+
{
187+
...node,
188+
name: binding ? `${binding.module}#${binding.name}` : node.name,
189+
attributes,
190+
children,
191+
},
192+
]
193+
}
194+
return [node]
195+
}
196+
return nodes.flatMap(project)
197+
}

0 commit comments

Comments
 (0)