Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions apps/docs/components/ui/faq.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

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

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

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

/** `type` values `next/script` exists to load; `text/partytown` is its `worker` strategy. */
const EXECUTABLE_SCRIPT_TYPES = new Set([
'',
'module',
'text/javascript',
'application/javascript',
'text/partytown',
])

/** `import Script from 'next/script'` (optionally with named imports) or `import { default as Script }`. */
const NEXT_SCRIPT_IMPORT =
/import\s+(?:(\w+)\s*(?:,\s*\{[^}]*\}\s*)?|\{[^}]*\bdefault\s+as\s+(\w+)[^}]*\}\s*)from\s*['"]next\/script['"]/

/**
* MDX fenced and inline code, which a docs page displays rather than renders. An inline span
* directly inside a JSX expression (`{`…`}`, `=`…, `(`…) is a template literal, not code, so it
* is left intact for the `type` check.
*/
const MDX_CODE = /```[\s\S]*?```|(?<![{=($\w])`[^`\n]*`/g
/** The start of the element's own `type` prop — not a suffix like `data-type`. */
const TYPE_PROP = /(?:^|\s)type\s*=\s*/
/** A statically known `type` value right after {@link TYPE_PROP}. */
const LITERAL_TYPE_VALUE = /^(?:'([^']*)'|"([^"]*)"|\{\s*(?:'([^']*)'|"([^"]*)"|`([^`$]*)`)\s*\})/

/** The attribute text of the JSX opening tag that starts at `start`, skipping `>` inside braces. */
function openingTagAttributes(source: string, start: number): string {
let depth = 0
for (let index = start; index < source.length; index++) {
const char = source[index]
if (char === '{') depth++
else if (char === '}') depth--
else if (char === '>' && depth === 0) return source.slice(start, index)
}
return source.slice(start)
}

/**
* Line numbers of `next/script` elements in `source` whose `type` is not JavaScript. A `type` set
* from an expression fails closed: the audit cannot prove it executable.
*/
function findDataNextScripts(source: string): number[] {
const importMatch = NEXT_SCRIPT_IMPORT.exec(source)
const localName = importMatch?.[1] ?? importMatch?.[2]
if (!localName) return []
const lines: number[] = []
for (const match of source.matchAll(new RegExp(`<${localName}\\b`, 'g'))) {
const attributes = openingTagAttributes(source, match.index + match[0].length)
const typeProp = TYPE_PROP.exec(attributes)
if (!typeProp) continue
const literal = LITERAL_TYPE_VALUE.exec(attributes.slice(typeProp.index + typeProp[0].length))
const type = literal?.slice(1).find((value) => value !== undefined)
if (type !== undefined && EXECUTABLE_SCRIPT_TYPES.has(type.trim().toLowerCase())) continue
lines.push(source.slice(0, match.index).split('\n').length)
}
return lines
}

const listed = spawnSync('git', ['ls-files', '-z', '--', ...SOURCE_EXTENSIONS], {
cwd: ROOT,
encoding: 'buffer',
Expand All @@ -54,22 +119,44 @@ const files = listed.stdout
.split('\0')
.filter((entry) => entry.length > 0)

const offenders: string[] = []
const nulOffenders: string[] = []
const dataScriptOffenders: string[] = []
for (const file of files) {
const source = Bun.file(path.join(ROOT, file))
if (!(await source.exists())) continue
const bytes = await source.bytes()
if (bytes.includes(0)) offenders.push(file)
if (bytes.includes(0)) nulOffenders.push(file)
if (file.startsWith('apps/') && /\.(?:[jt]sx|mdx)$/.test(file)) {
const decoded = new TextDecoder().decode(bytes)
const text = file.endsWith('.mdx')
? decoded.replace(MDX_CODE, (code) => code.replace(/[^\n]/g, ' '))
Comment thread
waleedlatif1 marked this conversation as resolved.
: decoded
if (!text.includes('next/script')) continue
for (const line of findDataNextScripts(text)) dataScriptOffenders.push(`${file}:${line}`)
Comment thread
waleedlatif1 marked this conversation as resolved.
}
}

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

console.log(`Source-text audit passed (${files.length} files, no raw NUL bytes).`)
if (dataScriptOffenders.length > 0) {
console.error(
`Source-text audit failed: ${dataScriptOffenders.length} \`next/script\` element(s) carry a non-JavaScript \`type\`,\n` +
'which next/script never writes into the server HTML, so crawlers never see the data.\n\n' +
dataScriptOffenders.map((location) => ` ${location}`).join('\n') +
"\n\n Render a native <script type='application/ld+json' dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }} />\n" +
' instead, as apps/docs/components/structured-data.tsx does.\n'
)
}

if (nulOffenders.length > 0 || dataScriptOffenders.length > 0) process.exit(1)

console.log(
`Source-text audit passed (${files.length} files, no raw NUL bytes, no data-typed next/script).`
)
Loading