///
/**
* The code under every page's `## How` compiles against the package (UIG-14:
* "Every How compiles — checked, not assumed"; Katerina's ruling R18, 25
* September). It was compiled once, by hand, on 19 September, and never again.
*
* Each ```tsx block under `## How` becomes a module of its own:
*
* 1. Its own `import` lines, verbatim; `'@estiva-app/ui'` is this package's
* `src/index.ts`, so a wrong name in an import fails.
* 2. Every part it did not import, imported (a page shows a part's companions
* without their import line).
* 3. The rest as the body of a component: all JSX, or statements then JSX, or
* statements alone, whichever parses.
* 4. What the page leaves to the reader is a placeholder: a lower-case name or
* an ALL_CAPS one is `any`, a React hook comes from React, an `Icon…` from
* Tabler. A capitalised name the package does not have is an error — that is
* what a wrong part name looks like — except the reader's own stand-ins
* below, which the pages name on purpose.
*
* Then it is type-checked with the package's own settings. A part name, a prop
* name and a prop's literal value are checked; a prop given a placeholder is not
* (a placeholder is `any`).
*/
import { readdirSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join } from 'node:path'
import ts from 'typescript'
import { describe, expect, it } from 'vitest'
const REPO = process.cwd()
const norm = (p: string) => p.replace(/\\/g, '/').toLowerCase()
/** The reader's own components a page draws around a part (Katerina's ruling R18: an allow-list). */
const STAND_INS = ['App', 'Conversation', 'ItemDetail', 'ItemRow', 'ListRow', 'Row', 'YourList']
/** Browser globals a page may mean for real; any other lower-case lib.dom name is a placeholder (`name`, `open`). */
const REAL_GLOBALS = new Set(['window', 'document', 'console', 'fetch', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'requestAnimationFrame', 'navigator', 'localStorage', 'sessionStorage', 'globalThis', 'queueMicrotask', 'structuredClone'])
interface Block {
page: string
/** The .mdx line of the block's first line of code. */
start: number
lines: string[]
}
function howBlocks(page: string, text: string): Block[] {
const lines = text.split(/\r?\n/)
const blocks: Block[] = []
let inHow = false
let fence: string | null = null
let start = 0
let buf: string[] = []
lines.forEach((line, i) => {
if (fence !== null) {
if (/^\s*```\s*$/.test(line)) {
if (inHow && fence === 'tsx') blocks.push({ page, start, lines: buf })
fence = null
buf = []
} else buf.push(line)
return
}
const open = /^\s*```(\S*)/.exec(line)
if (open) {
fence = open[1] || '(none)'
start = i + 2
return
}
if (/^## /.test(line)) inHow = /^## How\b/.test(line)
})
return blocks
}
function indexValueExports(): string[] {
const sf = ts.createSourceFile('index.ts', readFileSync(join(REPO, 'src/index.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
const names: string[] = []
for (const s of sf.statements) {
if (ts.isExportDeclaration(s) && !s.isTypeOnly && s.exportClause && ts.isNamedExports(s.exportClause)) for (const e of s.exportClause.elements) if (!e.isTypeOnly) names.push(e.name.text)
}
return names
}
const require = createRequire(join(REPO, 'package.json'))
const REACT = new Set(Object.keys(require('react') as object))
const TABLER = new Set(Object.keys(require('@tabler/icons-react') as object))
const INDEX = indexValueExports()
function splitImports(lines: string[]) {
const imports: { text: string; line: number }[] = []
const body = lines.slice()
for (let i = 0; i < lines.length; i++) {
if (!/^import\b/.test(lines[i])) continue
let j = i
while (j < lines.length && !/from\s+['"][^'"]+['"]\s*;?\s*$/.test(lines[j]) && !/^import\s+['"]/.test(lines[j])) j++
imports.push({ text: lines.slice(i, j + 1).join('\n'), line: i })
for (let k = i; k <= j; k++) body[k] = ''
i = j
}
return { imports, body }
}
function importedNames(imports: { text: string }[]): Set {
const names = new Set()
const sf = ts.createSourceFile('i.ts', imports.map((x) => x.text).join('\n'), ts.ScriptTarget.Latest, true)
for (const s of sf.statements) {
const c = ts.isImportDeclaration(s) ? s.importClause : undefined
if (!c) continue
if (c.name) names.add(c.name.text)
if (c.namedBindings && ts.isNamedImports(c.namedBindings)) for (const e of c.namedBindings.elements) names.add(e.name.text)
if (c.namedBindings && ts.isNamespaceImport(c.namedBindings)) names.add(c.namedBindings.name.text)
}
return names
}
const jsxComments = (lines: string[]) => lines.map((l) => l.replace(/^\/\/(.*)$/, '{/*$1*/}'))
const parses = (text: string) => (ts.transpileModule(text, { fileName: 'x.tsx', reportDiagnostics: true, compilerOptions: { jsx: ts.JsxEmit.ReactJSX } }).diagnostics ?? []).length === 0
/** All JSX; statements then JSX; statements alone. The first that parses wins. */
function wrappings(body: string[]) {
const out: { pre: string[]; jsx: string[] | null }[] = [{ pre: [], jsx: body }]
body.forEach((l, k) => {
if (k > 0 && /^[<{]/.test(l) && body.slice(0, k).some((x) => x.trim() && !/^\s*\/\//.test(x))) out.push({ pre: body.slice(0, k), jsx: body.slice(k) })
})
out.push({ pre: body, jsx: null })
return out
}
interface Extra {
react: Set
tabler: Set
declared: Set
}
function build(block: Block, extra: Extra): { text: string; map: (number | null)[] } {
const { imports, body } = splitImports(block.lines)
const have = importedNames(imports)
const lines: string[] = []
const map: (number | null)[] = []
const push = (text: string, mdx: number | null) => {
for (const t of text.split('\n')) {
lines.push(t)
map.push(mdx)
}
}
for (const imp of imports) imp.text.split('\n').forEach((t, k) => push(t, block.start + imp.line + k))
const auto = INDEX.filter((n) => !have.has(n))
if (auto.length) push(`import { ${auto.join(', ')} } from '@estiva-app/ui'`, null)
const react = [...extra.react].filter((n) => !have.has(n))
if (react.length) push(`import { ${react.join(', ')} } from 'react'`, null)
const tabler = [...extra.tabler].filter((n) => !have.has(n))
if (tabler.length) push(`import { ${tabler.join(', ')} } from '@tabler/icons-react'`, null)
for (const d of extra.declared) push(`declare const ${d}: any`, null)
for (const s of STAND_INS) if (!have.has(s)) push(`declare const ${s}: (props: any) => React.JSX.Element`, null)
for (const w of wrappings(body)) {
const L = lines.slice()
const M = map.slice()
const add = (t: string, mdx: number | null) => {
L.push(t)
M.push(mdx)
}
add('export function __How() {', null)
w.pre.forEach((t, k) => add(t, block.start + k))
if (w.jsx) {
add('return (<>', null)
jsxComments(w.jsx).forEach((t, k) => add(t, block.start + w.pre.length + k))
add('>)', null)
}
add('}', null)
const text = L.join('\n')
if (parses(text)) return { text, map: M }
}
return { text: `${lines.join('\n')}\nexport function __How() {\n${body.join('\n')}\n}`, map: [...map, null, ...body.map((_, k) => block.start + k), null] }
}
function compilerOptions(): ts.CompilerOptions {
const raw = ts.parseConfigFileTextToJson('tsconfig.json', readFileSync(join(REPO, 'tsconfig.json'), 'utf8')).config
const parsed = ts.convertCompilerOptionsFromJson(raw.compilerOptions, REPO)
// A placeholder is `any`: `people.map((p) => …)` would fail noImplicitAny on `p`,
// the placeholder's fault, not the page's. A callback prop's parameter keeps the part's type.
return { ...parsed.options, noEmit: true, noImplicitAny: false, baseUrl: REPO, paths: { '@estiva-app/ui': ['./src/index.ts'], '@estiva-app/ui/editor': ['./src/editor.ts'] }, types: [] }
}
function makeHost(options: ts.CompilerOptions, virtual: Map): ts.CompilerHost {
const host = ts.createCompilerHost(options, true)
const read = host.readFile.bind(host)
const exists = host.fileExists.bind(host)
host.readFile = (f) => virtual.get(norm(f)) ?? read(f)
host.fileExists = (f) => virtual.has(norm(f)) || exists(f)
host.getSourceFile = (f, lang) => {
const t = host.readFile(f)
return t === undefined ? undefined : ts.createSourceFile(f, t, lang, true)
}
host.getCurrentDirectory = () => REPO
return host
}
interface Result {
page: string
start: number
errors: { line: number | null; code: number; text: string }[]
}
/** Compile every block, adding placeholders until nothing new is found. */
function compileAll(blocks: Block[]): Result[] {
const options = compilerOptions()
const state: Extra[] = blocks.map(() => ({ react: new Set(), tabler: new Set(), declared: new Set() }))
const fileOf = blocks.map((b) => `${REPO}/src/__how__${b.page.replace(/\W+/g, '_')}_${b.start}.tsx`)
let program: ts.Program | undefined
let built: ReturnType[] = []
for (let round = 0; round < 8; round++) {
built = blocks.map((b, i) => build(b, state[i]))
const virtual = new Map(fileOf.map((f, i) => [norm(f), built[i].text]))
program = ts.createProgram(fileOf, options, makeHost(options, virtual), program)
const checker = program.getTypeChecker()
let added = 0
fileOf.forEach((f, i) => {
const s = state[i]
const sf = program!.getSourceFile(f)!
for (const d of ts.getPreEmitDiagnostics(program!, sf)) {
if (d.code !== 2304 && d.code !== 2552) continue
const x = /Cannot find name '([^']+)'/.exec(ts.flattenDiagnosticMessageText(d.messageText, ' '))?.[1]
if (!x || s.react.has(x) || s.tabler.has(x) || s.declared.has(x)) continue
if (REACT.has(x)) s.react.add(x)
else if (/^Icon[A-Z]/.test(x) && TABLER.has(x)) s.tabler.add(x)
else if (/^[a-z_$]/.test(x) || /^[A-Z][A-Z0-9_]+$/.test(x)) s.declared.add(x)
else continue
added++
}
// A placeholder that is also a browser global (`name`, `open`) never reports
// "Cannot find name": it silently takes the global's type. Shadow those too.
const walk = (node: ts.Node) => {
if (ts.isIdentifier(node) && built[i].map[sf.getLineAndCharacterOfPosition(node.getStart(sf)).line] != null) {
const p = node.parent
const member = (ts.isPropertyAccessExpression(p) && p.name === node) || (ts.isPropertyAssignment(p) && p.name === node) || ts.isJsxAttribute(p)
const x = node.text
if (!member && /^[a-z]/.test(x) && !REAL_GLOBALS.has(x) && !s.declared.has(x)) {
const sym = checker.getSymbolAtLocation(node)
const decls = sym?.declarations ?? []
if (sym && decls.length && decls.every((d) => program!.isSourceFileDefaultLibrary(d.getSourceFile())) && sym.flags & (ts.SymbolFlags.Variable | ts.SymbolFlags.Function)) {
s.declared.add(x)
added++
}
}
}
ts.forEachChild(node, walk)
}
walk(sf)
})
if (!added) break
}
return blocks.map((b, i) => {
const sf = program!.getSourceFile(fileOf[i])!
const errors = ts.getPreEmitDiagnostics(program!, sf).filter((d) => d.file === sf).map((d) => ({
line: built[i].map[sf.getLineAndCharacterOfPosition(d.start ?? 0).line] ?? null,
code: d.code,
text: ts.flattenDiagnosticMessageText(d.messageText, ' '),
}))
return { page: b.page, start: b.start, errors }
})
}
const pages = readdirSync(join(REPO, 'src')).filter((f) => f.endsWith('.mdx')).sort()
const blocks = pages.flatMap((f) => howBlocks(`src/${f}`, readFileSync(join(REPO, 'src', f), 'utf8')))
describe('the code under How', () => {
it('compiles on every page, and goes red on a wrong part, prop, value or import', { timeout: 120_000 }, () => {
expect(blocks.length).toBeGreaterThan(50)
const button = blocks.find((b) => b.page === 'src/Button.mdx')!
const broken = (name: string, change: (line: string) => string) => ({ ...button, page: `src/BROKEN-${name}.mdx`, lines: button.lines.map(change) })
const probes = [
broken('part', (l) => l.replace('