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,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 = / i m p o r t \s + ( \w + ) \s * (?: , \s * \{ [ ^ } ] * \} \s * ) ? f r o m \s * [ ' " ] n e x t \/ s c r i p t [ ' " ] /
59+ const TYPE_ATTRIBUTE =
60+ / \b t y p e \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+
4190const 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 [ ] = [ ]
58108for ( 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/' ) && / \. [ j t ] s x $ / . 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