Skip to content

Commit 68e5bd1

Browse files
committed
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.
1 parent 722510e commit 68e5bd1

2 files changed

Lines changed: 85 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: 84 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,47 @@ 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+
const NEXT_SCRIPT_IMPORT = /import\s+(\w+)\s*(?:,\s*\{[^}]*\}\s*)?from\s*['"]next\/script['"]/
59+
const TYPE_ATTRIBUTE =
60+
/\btype\s*=\s*(?:'([^']*)'|"([^"]*)"|\{\s*(?:'([^']*)'|"([^"]*)"|`([^`$]*)`)\s*\})/
61+
62+
/** The attribute text of the JSX opening tag that starts at `start`, skipping `>` inside braces. */
63+
function openingTagAttributes(source: string, start: number): string {
64+
let depth = 0
65+
for (let index = start; index < source.length; index++) {
66+
const char = source[index]
67+
if (char === '{') depth++
68+
else if (char === '}') depth--
69+
else if (char === '>' && depth === 0) return source.slice(start, index)
70+
}
71+
return source.slice(start)
72+
}
73+
74+
/** Line numbers of `next/script` elements in `source` whose `type` is not JavaScript. */
75+
function findDataNextScripts(source: string): number[] {
76+
const localName = NEXT_SCRIPT_IMPORT.exec(source)?.[1]
77+
if (!localName) return []
78+
const lines: number[] = []
79+
for (const match of source.matchAll(new RegExp(`<${localName}\\b`, 'g'))) {
80+
const attributes = openingTagAttributes(source, match.index + match[0].length)
81+
const typeMatch = TYPE_ATTRIBUTE.exec(attributes)
82+
if (!typeMatch) continue
83+
const type = typeMatch.slice(1).find((value) => value !== undefined) ?? ''
84+
if (EXECUTABLE_SCRIPT_TYPES.has(type.trim().toLowerCase())) continue
85+
lines.push(source.slice(0, match.index).split('\n').length)
86+
}
87+
return lines
88+
}
89+
4190
const listed = spawnSync('git', ['ls-files', '-z', '--', ...SOURCE_EXTENSIONS], {
4291
cwd: ROOT,
4392
encoding: 'buffer',
@@ -54,22 +103,41 @@ const files = listed.stdout
54103
.split('\0')
55104
.filter((entry) => entry.length > 0)
56105

57-
const offenders: string[] = []
106+
const nulOffenders: string[] = []
107+
const dataScriptOffenders: string[] = []
58108
for (const file of files) {
59109
const source = Bun.file(path.join(ROOT, file))
60110
if (!(await source.exists())) continue
61111
const bytes = await source.bytes()
62-
if (bytes.includes(0)) offenders.push(file)
112+
if (bytes.includes(0)) nulOffenders.push(file)
113+
if (file.startsWith('apps/') && /\.[jt]sx$/.test(file)) {
114+
const text = new TextDecoder().decode(bytes)
115+
if (!text.includes('next/script')) continue
116+
for (const line of findDataNextScripts(text)) dataScriptOffenders.push(`${file}:${line}`)
117+
}
63118
}
64119

65-
if (offenders.length > 0) {
120+
if (nulOffenders.length > 0) {
66121
console.error(
67-
`Source-text audit failed: ${offenders.length} tracked source file(s) contain a raw NUL byte,\n` +
122+
`Source-text audit failed: ${nulOffenders.length} tracked source file(s) contain a raw NUL byte,\n` +
68123
'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."
124+
nulOffenders.map((file) => ` ${file}`).join('\n') +
125+
"\n\n Write the character as the escape '\\u0000' instead — the runtime string is identical.\n"
71126
)
72-
process.exit(1)
73127
}
74128

75-
console.log(`Source-text audit passed (${files.length} files, no raw NUL bytes).`)
129+
if (dataScriptOffenders.length > 0) {
130+
console.error(
131+
`Source-text audit failed: ${dataScriptOffenders.length} \`next/script\` element(s) carry a non-JavaScript \`type\`,\n` +
132+
'which next/script never writes into the server HTML, so crawlers never see the data.\n\n' +
133+
dataScriptOffenders.map((location) => ` ${location}`).join('\n') +
134+
"\n\n Render a native <script type='application/ld+json' dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }} />\n" +
135+
' instead, as apps/docs/components/structured-data.tsx does.\n'
136+
)
137+
}
138+
139+
if (nulOffenders.length > 0 || dataScriptOffenders.length > 0) process.exit(1)
140+
141+
console.log(
142+
`Source-text audit passed (${files.length} files, no raw NUL bytes, no data-typed next/script).`
143+
)

0 commit comments

Comments
 (0)