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 */
1725import { spawnSync } from 'node:child_process'
1826import 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+ / i m p o r t \s + (?: ( \w + ) \s * (?: , \s * \{ [ ^ } ] * \} \s * ) ? | \{ [ ^ } ] * \b d e f a u l t \s + a s \s + ( \w + ) [ ^ } ] * \} \s * ) f r o m \s * [ ' " ] n e x t \/ s c r i p t [ ' " ] /
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 ) t y p e \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+
41106const 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 [ ] = [ ]
58124for ( 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/' ) && / \. (?: [ j t ] s x | m d x ) $ / . 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