- Ship an AI guide in the package (ai/: usage rules, component catalog, design rules, composition patterns) via package.json files. - Add examples A-D that import only public exports: default header, viewer header, feature header, and a consumer-side search extension that reuses SWHeaderInput and the public SW tokens. - Add SW-only checks: docs/API consistency, tarball consumer project (React 19 + TS + Vite typecheck/build) and a browser check (27/27). - No runtime source or public API change; README gains a pointer section. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
54 lines
4.3 KiB
JavaScript
54 lines
4.3 KiB
JavaScript
// Checks that the shipped AI guide matches the built public API (2026-10-19).
|
|
// Usage: node packages/sw/tests/ai-docs.check.mjs (build first: npm run build -w @hm-design/sw)
|
|
// 1. Every SW identifier named in ai/*.md is a real export, unless the doc lists it as not public.
|
|
// 2. Every import from "@hm-design/sw" in ai/examples is a real export; no deep package import.
|
|
// 3. Every relative link in ai/*.md and README.md resolves inside the package (tarball-safe).
|
|
import { readdir, readFile } from "node:fs/promises"
|
|
import { existsSync } from "node:fs"
|
|
import path from "node:path"
|
|
import { fileURLToPath } from "node:url"
|
|
|
|
const root = fileURLToPath(new URL("../", import.meta.url))
|
|
const declarations = await readFile(path.join(root, "dist/index.d.ts"), "utf8")
|
|
const nested = [...declarations.matchAll(/from\s+["'](\.[^"']+)["']/g)].map(match => match[1])
|
|
let exported = declarations
|
|
for (const file of nested) exported += await readFile(path.join(root, "dist", file.replace(/\.js$/, ".d.ts")), "utf8")
|
|
const names = new Set()
|
|
for (const block of exported.matchAll(/export\s*(?:type\s*)?\{([^}]*)\}/g)) for (const part of block[1].split(",")) { const name = part.trim().replace(/^type\s+/, "").split(/\s+as\s+/).pop(); if (name) names.add(name) }
|
|
for (const match of exported.matchAll(/export\s+(?:declare\s+)?(?:const|function|interface|type)\s+(\w+)/g)) names.add(match[1])
|
|
// Only names re-exported by the entry are public: collect the entry's re-export lists.
|
|
const publicNames = new Set()
|
|
for (const block of declarations.matchAll(/export\s*(?:type\s*)?\{([^}]*)\}/g)) for (const part of block[1].split(",")) { const name = part.trim().replace(/^type\s+/, "").split(/\s+as\s+/).pop(); if (name) publicNames.add(name) }
|
|
for (const match of declarations.matchAll(/export\s+\*\s+from\s+["']([^"']+)["']/g)) {
|
|
const text = await readFile(path.join(root, "dist", match[1].replace(/\.js$/, ".d.ts")), "utf8")
|
|
for (const block of text.matchAll(/export\s*(?:type\s*)?\{([^}]*)\}/g)) for (const part of block[1].split(",")) { const name = part.trim().replace(/^type\s+/, "").split(/\s+as\s+/).pop(); if (name) publicNames.add(name) }
|
|
}
|
|
void names
|
|
|
|
const results = []
|
|
const check = (name, ok, data) => { results.push(ok); console.log(ok ? "PASS" : "FAIL", name, data === undefined ? "" : JSON.stringify(data)) }
|
|
const docs = (await readdir(path.join(root, "ai"))).filter(name => name.endsWith(".md"))
|
|
for (const doc of docs) {
|
|
const text = await readFile(path.join(root, "ai", doc), "utf8")
|
|
const notPublic = new Set([...(text.split(/^## .*공개하지 않은 것.*$/m)[1]?.split(/^## /m)[0] ?? "").matchAll(/`(\w+)`/g)].map(match => match[1]))
|
|
const mentioned = [...new Set([...text.matchAll(/`((?:SW|parseSW|swHeader|useSW)\w+)/g)].map(match => match[1]))]
|
|
const unknown = mentioned.filter(name => !publicNames.has(name) && !notPublic.has(name) && !/^SWSearchControl/.test(name))
|
|
check(`${doc}: named SW identifiers are public exports`, unknown.length === 0, { mentioned: mentioned.length, unknown })
|
|
const leaked = [...notPublic].filter(name => publicNames.has(name))
|
|
check(`${doc}: "not public" list contains no export`, leaked.length === 0, leaked)
|
|
}
|
|
for (const file of (await readdir(path.join(root, "ai/examples"))).filter(name => /\.tsx?$/.test(name))) {
|
|
const text = await readFile(path.join(root, "ai/examples", file), "utf8")
|
|
const imports = [...text.matchAll(/import\s*\{([^}]*)\}\s*from\s*"@hm-design\/sw"/g)].flatMap(match => match[1].split(",").map(part => part.trim().replace(/^type\s+/, "")).filter(Boolean))
|
|
const deep = /from\s*"@hm-design\/sw\/(?!styles\.css)/.test(text)
|
|
check(`examples/${file}: imports only public exports`, !deep && imports.every(name => publicNames.has(name)), { imports, missing: imports.filter(name => !publicNames.has(name)), deep })
|
|
}
|
|
for (const doc of [...docs.map(name => path.join("ai", name)), "README.md"]) {
|
|
const text = await readFile(path.join(root, doc), "utf8")
|
|
const links = [...text.matchAll(/\]\((\.\/[^)#\s]+)/g)].map(match => match[1])
|
|
const broken = links.filter(link => !existsSync(path.resolve(root, path.dirname(doc), link)))
|
|
check(`${doc}: package-relative links resolve`, broken.length === 0, { links: links.length, broken })
|
|
}
|
|
console.log(`${results.filter(Boolean).length}/${results.length} PASS`)
|
|
process.exitCode = results.every(Boolean) ? 0 : 1
|