Skip to content

Commit e526a91

Browse files
authored
fix(docs): render the FAQ JSON-LD as a native script tag, and forbid data-typed next/script (#8875)
* fix(docs): render the FAQ JSON-LD as a native script tag, and forbid data-typed next/script The FAQ component rendered its FAQPage JSON-LD through next/script with the default afterInteractive strategy, which returns null on the server and injects the tag from a client effect, so the served HTML never carried it. Render a native <script> the way #6763 fixed the other docs JSON-LD blocks; serializeJsonLd already escapes '<'. check:source-text now also fails on a next/script element in apps/** whose type is not JavaScript, naming the native-script fix. * fix(audits): match only the real type prop, fail closed on expression types, and scan MDX pages * fix(audits): recognize default-as next/script imports and skip MDX code examples * fix(audits): keep JSX template literals intact when skipping MDX inline code
1 parent 20101c5 commit e526a91

2 files changed

Lines changed: 104 additions & 18 deletions

File tree

‎apps/docs/components/ui/faq.tsx‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import { useId, useState } from 'react'
44
import { ChevronRight } from '@sim/emcn/icons'
5-
import Script from 'next/script'
65
import { serializeJsonLd } from '@/lib/json-ld'
76
import { cn } from '@/lib/utils'
87

@@ -91,7 +90,7 @@ export function FAQ({ items, title = 'Common Questions' }: FAQProps) {
9190

9291
return (
9392
<div className='mt-12'>
94-
<Script
93+
<script
9594
id={`faq-json-ld-${structuredDataId}`}
9695
type='application/ld+json'
9796
dangerouslySetInnerHTML={{ __html: serializeJsonLd(faqSchema) }}

‎scripts/check-source-text.ts‎

Lines changed: 103 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
#!/usr/bin/env bun
22
/**
3-
* Asserts that no tracked source file contains a raw `U+0000`.
4-
*
5-
* Git classifies a file as binary the moment its contents hold a NUL byte, so a
6-
* single stray `U+0000` written as a literal turns the whole file into
7-
* `Bin 0 -> 4102 bytes` in every diff — a reviewer sees not one line of it, and
8-
* `git grep`, formatters, and editors treat it as opaque or silently normalize
9-
* the byte away. `apps/sim/lib/api/server/nul-byte-boundary.test.ts` shipped
10-
* exactly that way, and two older files had done the same unnoticed.
3+
* Asserts two properties of tracked source text that no linter covers.
114
*
5+
* No raw `U+0000`. Git classifies a file as binary the moment its contents hold
6+
* a NUL byte, so a single stray `U+0000` written as a literal turns the whole
7+
* file into `Bin 0 -> 4102 bytes` in every diff — a reviewer sees not one line
8+
* of it, and `git grep`, formatters, and editors treat it as opaque or silently
9+
* normalize the byte away. `apps/sim/lib/api/server/nul-byte-boundary.test.ts`
10+
* shipped exactly that way, and two older files had done the same unnoticed.
1211
* The escape `'\u0000'` produces an identical string at runtime, so this costs
1312
* nothing to satisfy. `.gitattributes` forces source files to diff as text as a
1413
* second layer, which makes a violation visible; this audit is what keeps one
1514
* from landing in the first place.
15+
*
16+
* No `next/script` carrying a non-JavaScript `type` (JSON-LD, `application/json`,
17+
* a template) in `apps/**`. `next/script` loads and executes JavaScript: its
18+
* default `afterInteractive` strategy renders nothing on the server and injects
19+
* the tag from an effect, and `beforeInteractive` pushes it onto the
20+
* `self.__next_s` client queue, so the data never appears in the served HTML
21+
* that crawlers read. The docs JSON-LD shipped that way twice. Data belongs in a
22+
* native `<script type='application/ld+json'>` whose payload escapes `<`
23+
* (`serializeJsonLd`), as Next's JSON-LD guide prescribes.
1624
*/
1725
import { spawnSync } from 'node:child_process'
1826
import path from 'node:path'
@@ -38,6 +46,63 @@ const SOURCE_EXTENSIONS = [
3846
'*.sh',
3947
]
4048

49+
/** `type` values `next/script` exists to load; `text/partytown` is its `worker` strategy. */
50+
const EXECUTABLE_SCRIPT_TYPES = new Set([
51+
'',
52+
'module',
53+
'text/javascript',
54+
'application/javascript',
55+
'text/partytown',
56+
])
57+
58+
/** `import Script from 'next/script'` (optionally with named imports) or `import { default as Script }`. */
59+
const NEXT_SCRIPT_IMPORT =
60+
/import\s+(?:(\w+)\s*(?:,\s*\{[^}]*\}\s*)?|\{[^}]*\bdefault\s+as\s+(\w+)[^}]*\}\s*)from\s*['"]next\/script['"]/
61+
62+
/**
63+
* MDX fenced and inline code, which a docs page displays rather than renders. An inline span
64+
* directly inside a JSX expression (`{`…`}`, `=`…, `(`…) is a template literal, not code, so it
65+
* is left intact for the `type` check.
66+
*/
67+
const MDX_CODE = /```[\s\S]*?```|(?<![{=($\w])`[^`\n]*`/g
68+
/** The start of the element's own `type` prop — not a suffix like `data-type`. */
69+
const TYPE_PROP = /(?:^|\s)type\s*=\s*/
70+
/** A statically known `type` value right after {@link TYPE_PROP}. */
71+
const LITERAL_TYPE_VALUE = /^(?:'([^']*)'|"([^"]*)"|\{\s*(?:'([^']*)'|"([^"]*)"|`([^`$]*)`)\s*\})/
72+
73+
/** The attribute text of the JSX opening tag that starts at `start`, skipping `>` inside braces. */
74+
function openingTagAttributes(source: string, start: number): string {
75+
let depth = 0
76+
for (let index = start; index < source.length; index++) {
77+
const char = source[index]
78+
if (char === '{') depth++
79+
else if (char === '}') depth--
80+
else if (char === '>' && depth === 0) return source.slice(start, index)
81+
}
82+
return source.slice(start)
83+
}
84+
85+
/**
86+
* Line numbers of `next/script` elements in `source` whose `type` is not JavaScript. A `type` set
87+
* from an expression fails closed: the audit cannot prove it executable.
88+
*/
89+
function findDataNextScripts(source: string): number[] {
90+
const importMatch = NEXT_SCRIPT_IMPORT.exec(source)
91+
const localName = importMatch?.[1] ?? importMatch?.[2]
92+
if (!localName) return []
93+
const lines: number[] = []
94+
for (const match of source.matchAll(new RegExp(`<${localName}\\b`, 'g'))) {
95+
const attributes = openingTagAttributes(source, match.index + match[0].length)
96+
const typeProp = TYPE_PROP.exec(attributes)
97+
if (!typeProp) continue
98+
const literal = LITERAL_TYPE_VALUE.exec(attributes.slice(typeProp.index + typeProp[0].length))
99+
const type = literal?.slice(1).find((value) => value !== undefined)
100+
if (type !== undefined && EXECUTABLE_SCRIPT_TYPES.has(type.trim().toLowerCase())) continue
101+
lines.push(source.slice(0, match.index).split('\n').length)
102+
}
103+
return lines
104+
}
105+
41106
const listed = spawnSync('git', ['ls-files', '-z', '--', ...SOURCE_EXTENSIONS], {
42107
cwd: ROOT,
43108
encoding: 'buffer',
@@ -54,22 +119,44 @@ const files = listed.stdout
54119
.split('\0')
55120
.filter((entry) => entry.length > 0)
56121

57-
const offenders: string[] = []
122+
const nulOffenders: string[] = []
123+
const dataScriptOffenders: string[] = []
58124
for (const file of files) {
59125
const source = Bun.file(path.join(ROOT, file))
60126
if (!(await source.exists())) continue
61127
const bytes = await source.bytes()
62-
if (bytes.includes(0)) offenders.push(file)
128+
if (bytes.includes(0)) nulOffenders.push(file)
129+
if (file.startsWith('apps/') && /\.(?:[jt]sx|mdx)$/.test(file)) {
130+
const decoded = new TextDecoder().decode(bytes)
131+
const text = file.endsWith('.mdx')
132+
? decoded.replace(MDX_CODE, (code) => code.replace(/[^\n]/g, ' '))
133+
: decoded
134+
if (!text.includes('next/script')) continue
135+
for (const line of findDataNextScripts(text)) dataScriptOffenders.push(`${file}:${line}`)
136+
}
63137
}
64138

65-
if (offenders.length > 0) {
139+
if (nulOffenders.length > 0) {
66140
console.error(
67-
`Source-text audit failed: ${offenders.length} tracked source file(s) contain a raw NUL byte,\n` +
141+
`Source-text audit failed: ${nulOffenders.length} tracked source file(s) contain a raw NUL byte,\n` +
68142
'which makes git treat them as binary and hides their contents from review.\n\n' +
69-
offenders.map((file) => ` ${file}`).join('\n') +
70-
"\n\n Write the character as the escape '\\u0000' instead — the runtime string is identical."
143+
nulOffenders.map((file) => ` ${file}`).join('\n') +
144+
"\n\n Write the character as the escape '\\u0000' instead — the runtime string is identical.\n"
71145
)
72-
process.exit(1)
73146
}
74147

75-
console.log(`Source-text audit passed (${files.length} files, no raw NUL bytes).`)
148+
if (dataScriptOffenders.length > 0) {
149+
console.error(
150+
`Source-text audit failed: ${dataScriptOffenders.length} \`next/script\` element(s) carry a non-JavaScript \`type\`,\n` +
151+
'which next/script never writes into the server HTML, so crawlers never see the data.\n\n' +
152+
dataScriptOffenders.map((location) => ` ${location}`).join('\n') +
153+
"\n\n Render a native <script type='application/ld+json' dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }} />\n" +
154+
' instead, as apps/docs/components/structured-data.tsx does.\n'
155+
)
156+
}
157+
158+
if (nulOffenders.length > 0 || dataScriptOffenders.length > 0) process.exit(1)
159+
160+
console.log(
161+
`Source-text audit passed (${files.length} files, no raw NUL bytes, no data-typed next/script).`
162+
)

0 commit comments

Comments
 (0)