feat(sw): prepare reusable AI-friendly component package
- 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>
This commit is contained in:
1 parent
50bf5bda8e
commit
e27385e0f0
26 files changed
+1349
-1
No files matched your search
@@ -0,0 +1,53 @@
|
||||
// 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
|
||||
@@ -0,0 +1,180 @@
|
||||
// Browser check of the independent consumer built by ai-template-consumer.mjs.
|
||||
// Usage: node packages/sw/tests/ai-template-consumer.browser.mjs <consumer-dir> <evidence-dir>
|
||||
// Serves <consumer-dir>/dist and checks examples A–D (rendering, assets, dropdowns, callbacks, focus, styles).
|
||||
import { createRequire } from "node:module"
|
||||
import { createServer } from "node:http"
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
const dist = path.resolve(process.argv[2], "dist")
|
||||
const out = path.resolve(process.argv[3])
|
||||
const { chromium } = createRequire(import.meta.url)(process.env.SW_PLAYWRIGHT_PATH ?? "C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright")
|
||||
await mkdir(out, { recursive: true })
|
||||
const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css" }
|
||||
const server = createServer(async (request, response) => {
|
||||
const pathname = decodeURIComponent(new URL(request.url, "http://x").pathname)
|
||||
const file = path.join(dist, pathname === "/" ? "/index.html" : pathname)
|
||||
let body
|
||||
try { body = await readFile(file) } catch { response.writeHead(404); response.end(); return }
|
||||
response.writeHead(200, { "content-type": types[path.extname(file)] ?? "application/octet-stream" })
|
||||
response.end(body)
|
||||
}).listen(0, "127.0.0.1")
|
||||
await new Promise(resolve => server.once("listening", resolve))
|
||||
const base = `http://127.0.0.1:${server.address().port}/`
|
||||
|
||||
const browser = await chromium.launch({ executablePath: process.env.SW_CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" })
|
||||
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } })
|
||||
const report = { checks: [], errors: [], warnings: [] }
|
||||
page.on("pageerror", error => report.errors.push(error.message))
|
||||
page.on("console", message => { if (message.type() === "error") report.errors.push(message.text()); if (message.type() === "warning") report.warnings.push(message.text()) })
|
||||
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? "PASS" : "FAIL", data }); console.log(ok ? "PASS" : "FAIL", name, JSON.stringify(data ?? "")) }
|
||||
const events = async () => (await page.locator("#events").textContent()).split("\n").filter(Boolean)
|
||||
const lastEvent = async () => (await events()).at(-1)
|
||||
const section = id => page.locator(`[data-example="${id}"]`)
|
||||
const rect = (locator) => locator.evaluate(node => { const r = node.getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) } })
|
||||
|
||||
await page.goto(base)
|
||||
await page.locator('[data-example="d"] .app-sw-search').waitFor()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
|
||||
// 1. Assets and CSS
|
||||
const assets = await page.evaluate(() => [...document.images].map(image => ({ src: image.currentSrc.slice(0, 30), ok: image.complete && image.naturalWidth > 0 })))
|
||||
check("1 all SVG/PNG assets decode (data URI, no external path)", assets.length > 0 && assets.every(item => item.ok && item.src.startsWith("data:")), { count: assets.length, broken: assets.filter(item => !item.ok) })
|
||||
const tokens = await page.evaluate(() => { const s = getComputedStyle(document.querySelector('[data-example="b"] .hm-sw-theme')); return { height: s.getPropertyValue("--hm-sw-header-height").trim(), font: s.fontFamily } })
|
||||
check("1 styles.css loaded; SWTheme provides tokens", tokens.height === "49px" && tokens.font.includes("Noto Sans KR"), tokens)
|
||||
|
||||
// 2. Example A: default header unchanged (Figma 1920 coordinates)
|
||||
const a = section("a")
|
||||
const geometry = {
|
||||
header: await rect(a.locator(".hm-sw-header")), home: await rect(a.locator(".hm-sw-header__home")),
|
||||
document: await rect(a.locator(".hm-sw-header__document")), object: await rect(a.locator(".hm-sw-header__object-area")),
|
||||
window: await rect(a.locator(".hm-sw-header__window")),
|
||||
}
|
||||
check("2 A default header 1920 geometry = Figma (49 / 0·80 / 65·310 / 377·1301 / 1680·240)",
|
||||
geometry.header.h === 49 && geometry.home.x === 0 && geometry.home.w === 80 && geometry.document.x === 65 && geometry.document.w === 310
|
||||
&& geometry.object.x === 377 && geometry.object.w === 1301 && geometry.window.x === 1680 && geometry.window.w === 240, geometry)
|
||||
await a.locator(".hm-sw-header").screenshot({ path: path.join(out, "A-default-1920.png") })
|
||||
await a.locator(".hm-sw-header__field--layer").click()
|
||||
await a.getByRole("button", { name: "CENTER" }).first().click()
|
||||
check("2 A layer dropdown select callback", (await lastEvent()) === "layer:center", await lastEvent())
|
||||
await a.getByRole("button", { name: "종료" }).click()
|
||||
check("2 A close callback", (await lastEvent()) === "close", await lastEvent())
|
||||
|
||||
// 3. Example B: Logo → File → Layer → Color → K
|
||||
const b = section("b")
|
||||
const order = await b.locator(".hm-sw-theme").evaluate(bar => [...bar.querySelectorAll(".hm-sw-header__home, .hm-sw-header__document, .hm-sw-header__field, .hm-sw-header__action")].map(node => node.getAttribute("aria-label") ?? node.className.split(" ")[0]))
|
||||
check("3 B order Logo → File → Layer → Color → K (DOM)", JSON.stringify(order) === JSON.stringify(["뷰어 홈", "hm-sw-header__document", "레이어", "색상", "팝업 가이드"]), order)
|
||||
const bGeo = { bar: await rect(b.locator(".hm-sw-theme")), home: await rect(b.locator(".hm-sw-header__home")), document: await rect(b.locator(".hm-sw-header__document")), layer: await rect(b.locator(".hm-sw-header__field--layer")), color: await rect(b.locator(".hm-sw-header__field--color")) }
|
||||
check("3 B sizes keep SW rules (bar 49, home 80 overlapping file 15px, fields 234/192 x 38)", bGeo.bar.h === 49 && bGeo.home.w === 80 && bGeo.document.x - bGeo.home.x === 65 && bGeo.layer.w === 234 && bGeo.layer.h === 38 && bGeo.color.w === 192, bGeo)
|
||||
await b.locator(".hm-sw-header__document-button").click()
|
||||
await b.getByRole("menuitem", { name: /New/ }).or(b.getByRole("button", { name: /New/ })).first().click()
|
||||
check("3 B document dropdown New callback", (await lastEvent()) === "document:new", await lastEvent())
|
||||
const layerField = b.locator(".hm-sw-header__field--layer")
|
||||
await layerField.click()
|
||||
const layerPanel = b.locator(".hm-sw-header__layer [id], .hm-sw-header__layer-menu").first()
|
||||
check("3 B layer dropdown opens under the field", await layerField.getAttribute("aria-expanded") === "true", await layerField.getAttribute("aria-expanded"))
|
||||
await page.screenshot({ path: path.join(out, "B-layer-open.png"), clip: { x: 0, y: (await rect(b)).y, width: 1000, height: 360 } })
|
||||
await page.keyboard.press("Escape")
|
||||
check("3 B Escape closes and returns focus to the field", await layerField.getAttribute("aria-expanded") === "false" && await layerField.evaluate(node => node === document.activeElement), null)
|
||||
void layerPanel
|
||||
await b.locator(".hm-sw-header__field--color").click()
|
||||
await b.getByRole("button", { name: "빨강" }).click()
|
||||
check("3 B color chip callback (dropdown stays open)", (await lastEvent()) === "color:red" && await b.locator(".hm-sw-header__field--color").getAttribute("aria-expanded") === "true", await lastEvent())
|
||||
await page.mouse.click(1800, 900)
|
||||
const k = b.getByRole("button", { name: "팝업 가이드" })
|
||||
await k.hover()
|
||||
await page.waitForTimeout(500)
|
||||
const guide = await page.locator(".hm-sw-k-guide__panel").first().isVisible()
|
||||
check("3 B K guide preview on hover (animation frame advances)", guide && Number(await page.locator(".hm-sw-k-guide__panel").first().getAttribute("data-frame")) >= 1, { guide })
|
||||
await page.waitForTimeout(1000)
|
||||
await page.screenshot({ path: path.join(out, "B-k-guide.png"), clip: { x: 1300, y: (await rect(b)).y, width: 620, height: 340 } })
|
||||
await k.click()
|
||||
check("3 B K click callback", (await lastEvent()) === "popupGuide", await lastEvent())
|
||||
await page.mouse.move(1, 1)
|
||||
await b.locator(".hm-sw-theme").screenshot({ path: path.join(out, "B-viewer-1920.png") })
|
||||
|
||||
// 4. Example C: File → Line → Text → Dimension → Print
|
||||
const c = section("c")
|
||||
const cOrder = await c.locator(".hm-sw-theme").evaluate(bar => [...bar.querySelectorAll(".hm-sw-header__document, .hm-sw-header__field, .hm-sw-header__action")].map(node => node.getAttribute("aria-label") ?? "file"))
|
||||
check("4 C order File → 선 → 문자 → 치수 → 인쇄", JSON.stringify(cOrder) === JSON.stringify(["file", "선스타일", "문자스타일", "치수스타일", "인쇄"]), cOrder)
|
||||
const fileEdge = await c.locator(".app-sw-file-edge").evaluate(node => getComputedStyle(node).borderLeftWidth)
|
||||
check("4 C first file well has a left edge (consumer wrapper)", fileEdge === "1px", fileEdge)
|
||||
await c.locator(".hm-sw-header__field--line-style").click()
|
||||
await c.getByRole("button", { name: "CENTER" }).first().click()
|
||||
check("4 C line type callback + field value", (await lastEvent()) === "lineType:center" && (await c.locator(".hm-sw-header__field--line-style").textContent()).includes("CENTER"), await lastEvent())
|
||||
await page.keyboard.press("Escape")
|
||||
await c.locator(".hm-sw-header__field--text-style").click()
|
||||
await c.getByRole("button", { name: "Dotum" }).click()
|
||||
check("4 C text style callback + field value", (await lastEvent()) === "textStyle:dotum" && (await c.locator(".hm-sw-header__field--text-style").textContent()).includes("Dotum"), await lastEvent())
|
||||
await c.locator(".hm-sw-header__field--dimension-style").click()
|
||||
await c.getByRole("button", { name: "ISO-25" }).click()
|
||||
check("4 C dimension style callback", (await lastEvent()) === "dimensionStyle:iso", await lastEvent())
|
||||
await c.getByRole("button", { name: "인쇄" }).click()
|
||||
check("4 C print callback", (await lastEvent()) === "print", await lastEvent())
|
||||
await page.mouse.move(1, 1)
|
||||
await c.locator(".hm-sw-theme").screenshot({ path: path.join(out, "C-feature-1920.png") })
|
||||
|
||||
// 5. Example D: existing layer / color + new SWSearchControl
|
||||
const d = section("d")
|
||||
const style = await d.locator(".hm-sw-theme").evaluate(bar => {
|
||||
const pick = node => { const s = getComputedStyle(node), r = node.getBoundingClientRect(); return { h: Math.round(r.height), top: Math.round(r.top), border: `${s.borderTopWidth} ${s.borderTopColor}`, radius: s.borderTopLeftRadius, bg: s.backgroundImage.slice(0, 40), shadow: s.boxShadow } }
|
||||
return { layer: pick(bar.querySelector(".hm-sw-header__field--layer")), search: pick(bar.querySelector(".app-sw-search")),
|
||||
label: getComputedStyle(bar.querySelector(".app-sw-search__label")).fontSize, fieldLabel: getComputedStyle(bar.querySelector(".hm-sw-header__field-label")).fontSize }
|
||||
})
|
||||
const same = ["h", "top", "border", "radius", "bg", "shadow"].every(key => style.layer[key] === style.search[key])
|
||||
check("5 D new search field matches the SW field surface (height/top/border/radius/background/shadow/label)", same && style.label === style.fieldLabel, style)
|
||||
// Pixels of an element screenshot: the inside must not change on hover, only the 1px edge turns yellow.
|
||||
const samplePixels = async (locator) => {
|
||||
const buffer = await locator.screenshot()
|
||||
return page.evaluate(async b64 => {
|
||||
const image = new Image(); image.src = `data:image/png;base64,${b64}`; await image.decode()
|
||||
const canvas = document.createElement("canvas"); canvas.width = image.width; canvas.height = image.height
|
||||
const context = canvas.getContext("2d"); context.drawImage(image, 0, 0)
|
||||
const at = (x, y) => [...context.getImageData(x, y, 1, 1).data].slice(0, 3)
|
||||
return { inside: at(Math.round(image.width * 0.85), Math.round(image.height / 2)), edge: at(Math.round(image.width / 2), 0) }
|
||||
}, buffer.toString("base64"))
|
||||
}
|
||||
const searchField = d.locator(".app-sw-search")
|
||||
await page.mouse.move(1, 1)
|
||||
const before = await samplePixels(searchField)
|
||||
await searchField.hover()
|
||||
const ring = await searchField.evaluate(node => getComputedStyle(node, "::after").opacity)
|
||||
const after = await samplePixels(searchField)
|
||||
const near = (p, q, tolerance = 12) => p.every((value, index) => Math.abs(value - q[index]) <= tolerance)
|
||||
const yellow = rgb => rgb[0] > 150 && rgb[1] > 100 && rgb[2] < 90
|
||||
const layerRing = async () => { await d.locator(".hm-sw-header__field--layer").hover(); return d.locator(".hm-sw-header__field--layer").evaluate(node => getComputedStyle(node, "::after").opacity) }
|
||||
check("5 D hover ring like existing fields (edge turns yellow, inside unchanged)", ring === "1" && near(before.inside, after.inside) && yellow(after.edge) && !yellow(before.edge) && (await layerRing()) === "1", { ring, before, after })
|
||||
await d.locator(".app-sw-search").hover()
|
||||
await page.screenshot({ path: path.join(out, "D-search-hover.png"), clip: { x: 0, y: (await rect(d)).y, width: 760, height: 60 } })
|
||||
const input = d.getByRole("textbox", { name: "검색어" })
|
||||
await input.click()
|
||||
await input.fill("WALL-01")
|
||||
await page.keyboard.press("Enter")
|
||||
check("5 D Enter commits through SWHeaderInput → search callback", (await lastEvent()) === "search:WALL-01", await lastEvent())
|
||||
await d.getByRole("button", { name: "검색", exact: true }).click()
|
||||
check("5 D search button callback uses committed value", (await lastEvent()) === "search:WALL-01", await lastEvent())
|
||||
await input.click()
|
||||
await input.fill("TMP")
|
||||
await page.keyboard.press("Escape")
|
||||
check("5 D Escape restores the committed value", await input.inputValue() === "WALL-01", await input.inputValue())
|
||||
await page.keyboard.press("Tab")
|
||||
await d.locator(".hm-sw-header__field--color").click()
|
||||
await d.getByRole("button", { name: "파랑" }).click()
|
||||
check("5 D existing color dropdown in the same bar", (await lastEvent()) === "color:blue", await lastEvent())
|
||||
await page.mouse.click(1800, 900)
|
||||
await page.mouse.move(1, 1)
|
||||
await d.locator(".hm-sw-theme").screenshot({ path: path.join(out, "D-search-1920.png") })
|
||||
|
||||
// 6. Keyboard order follows DOM order in B
|
||||
await b.locator(".hm-sw-header__home").focus()
|
||||
const tabs = []
|
||||
for (let index = 0; index < 4; index++) { await page.keyboard.press("Tab"); tabs.push(await page.evaluate(() => document.activeElement?.getAttribute("aria-label") ?? document.activeElement?.textContent?.trim().slice(0, 12))) }
|
||||
check("6 B Tab order = display order", tabs.includes("레이어") && tabs.indexOf("레이어") < tabs.indexOf("색상") && tabs.indexOf("색상") < tabs.indexOf("팝업 가이드"), tabs)
|
||||
|
||||
check("7 no page/console errors", report.errors.length === 0, report.errors)
|
||||
await writeFile(path.join(out, "report.json"), JSON.stringify(report, null, 2))
|
||||
await browser.close()
|
||||
server.close()
|
||||
const failed = report.checks.filter(item => item.status === "FAIL").length
|
||||
console.log(`${report.checks.length - failed}/${report.checks.length} PASS`)
|
||||
process.exitCode = failed ? 1 : 0
|
||||
@@ -0,0 +1,70 @@
|
||||
// Independent consumer check for @hm-design/sw (AI template, 2026-10-19).
|
||||
// Usage: node packages/sw/tests/ai-template-consumer.mjs <empty-dir-outside-the-repo>
|
||||
// Packs the built package (npm pack), creates a React 19 + TypeScript + Vite project in <dir>, installs the
|
||||
// tarball, copies the shipped ai/examples from node_modules (not from the repo) and runs tsc + vite build.
|
||||
// Build the package first: npm run build -w @hm-design/sw
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { cp, mkdir, readdir, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const packageRoot = fileURLToPath(new URL("../", import.meta.url))
|
||||
const out = path.resolve(process.argv[2] ?? "")
|
||||
if (!process.argv[2] || out.startsWith(path.resolve(packageRoot, "../.."))) throw new Error("Pass an empty directory outside the repository")
|
||||
await mkdir(out, { recursive: true })
|
||||
if ((await readdir(out)).length) throw new Error(`${out} is not empty`)
|
||||
|
||||
const npm = (args, cwd) => execFileSync(process.platform === "win32" ? "npm.cmd" : "npm", args, { cwd, stdio: "inherit", shell: process.platform === "win32" })
|
||||
npm(["pack", "--ignore-scripts", "--pack-destination", out], packageRoot)
|
||||
const tarball = (await readdir(out)).find(name => name.endsWith(".tgz"))
|
||||
|
||||
const files = {
|
||||
"package.json": JSON.stringify({
|
||||
name: "sw-ai-template-consumer", private: true, type: "module",
|
||||
scripts: { typecheck: "tsc -p tsconfig.json", build: "vite build" },
|
||||
}, null, 2),
|
||||
"tsconfig.json": JSON.stringify({
|
||||
compilerOptions: {
|
||||
target: "ES2022", lib: ["ES2022", "DOM", "DOM.Iterable"], module: "ESNext", moduleResolution: "Bundler",
|
||||
jsx: "react-jsx", strict: true, noEmit: true, skipLibCheck: false, isolatedModules: true,
|
||||
noUnusedLocals: true, noUnusedParameters: true, types: ["vite/client"],
|
||||
},
|
||||
include: ["src"],
|
||||
}, null, 2),
|
||||
"vite.config.ts": `import { defineConfig } from "vite"\nimport react from "@vitejs/plugin-react"\nexport default defineConfig({ plugins: [react()] })\n`,
|
||||
"index.html": `<!doctype html><html lang="ko"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>SW consumer</title><link rel="icon" href="data:," /><style>body { margin: 0; }</style></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>\n`,
|
||||
"src/main.tsx": `import { StrictMode, useState } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import "@hm-design/sw/styles.css"
|
||||
import { ExampleADefaultHeader } from "./examples/ExampleA-DefaultHeader"
|
||||
import { ExampleBViewerHeader } from "./examples/ExampleB-ViewerHeader"
|
||||
import { ExampleCFeatureHeader } from "./examples/ExampleC-FeatureHeader"
|
||||
import { ExampleDSearchExtension } from "./examples/ExampleD-SearchExtension"
|
||||
|
||||
const examples = { a: ExampleADefaultHeader, b: ExampleBViewerHeader, c: ExampleCFeatureHeader, d: ExampleDSearchExtension }
|
||||
function App() {
|
||||
const [events, setEvents] = useState<string[]>([])
|
||||
const onEvent = (name: string) => setEvents(list => [...list, name])
|
||||
return (
|
||||
<>
|
||||
{Object.entries(examples).map(([id, Example]) => (
|
||||
<section key={id} data-example={id} style={{ marginBottom: 360 }}><Example onEvent={onEvent} /></section>
|
||||
))}
|
||||
<pre id="events">{events.join("\\n")}</pre>
|
||||
</>
|
||||
)
|
||||
}
|
||||
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>)
|
||||
`,
|
||||
}
|
||||
for (const [name, text] of Object.entries(files)) {
|
||||
await mkdir(path.dirname(path.join(out, name)), { recursive: true })
|
||||
await writeFile(path.join(out, name), text)
|
||||
}
|
||||
npm(["install", "--no-audit", "--no-fund", `./${tarball}`, "react@19.3.0", "react-dom@19.3.0"], out)
|
||||
npm(["install", "--no-audit", "--no-fund", "-D", "typescript@7.0.2", "vite@8.3.0", "@vitejs/plugin-react@6.1.1", "@types/react@19.3.0", "@types/react-dom@19.3.0"], out)
|
||||
// The examples are taken from the installed package: what an AI tool in another project would read.
|
||||
await cp(path.join(out, "node_modules/@hm-design/sw/ai/examples"), path.join(out, "src/examples"), { recursive: true })
|
||||
npm(["run", "typecheck"], out)
|
||||
npm(["run", "build"], out)
|
||||
console.log(`Consumer ready: ${out} (dist/ built). Next: node packages/sw/tests/ai-template-consumer.browser.mjs ${out} <evidence-dir>`)
|
||||
Reference in new issue
Block a user