diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 876f980b..586fd51f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,13 @@ name: CI on: + # The per-feature PRs are a stack whose bases are other `ds/*` branches; with the + # trigger list limited to the integration branches, none of them could ever run CI. push: - branches: [dot-skill-test, dot-skill, main] + branches: [dot-skill-test, dot-skill, main, 'ds/**'] pull_request: - branches: [dot-skill-test, dot-skill, main] + branches: [dot-skill-test, dot-skill, main, 'ds/**'] + workflow_dispatch: jobs: test: diff --git a/assets/distilly-template.html b/assets/distilly-template.html new file mode 100644 index 00000000..3e3f28d9 --- /dev/null +++ b/assets/distilly-template.html @@ -0,0 +1,1088 @@ + + + + + + + + + +Distilly · 个人画像 + + + + + +
+ + +
+
+
+ + +
+ + + + + + + + diff --git a/assets/template.source.html b/assets/template.source.html new file mode 100644 index 00000000..8e968ef3 --- /dev/null +++ b/assets/template.source.html @@ -0,0 +1,246 @@ + + + + + + + + +Distilly · 个人画像 + + + + + +
+ + +
+
+
+ + +
+ + + + + + + + diff --git a/docs/evidence/pr-03-render.md b/docs/evidence/pr-03-render.md new file mode 100644 index 00000000..9a42a79c --- /dev/null +++ b/docs/evidence/pr-03-render.md @@ -0,0 +1,194 @@ +# PR-03 渲染层证据(`ds/03-render`) + +纯文字证据。截图/PNG **不入库**,见 §6;本文件只记录数值、命令与结果。 + +## 1. 变更摘要 + +单文件 HTML 渲染层:模板是生成物、八段页面、双主题、默认私有、`view check` 结构化诊断、 +`view render` 确定性产物 + 回执、Playwright visual-check 八项。 + +| commit | 内容 | +|---|---| +| `feat(view): add the page template fragments and viewer runtime` | `assets/template.source.html`、`viewer/{sections,theme,export,focus}.js` | +| `feat(view): generate the single-file template from fragments` | `scripts/generate-template.mjs` + 生成物 `assets/distilly-template.html` | +| `feat(view): validate view.json against the v2 contract` | `src/views/schema.mjs`(八段/kind/锚点/confidence/12 字原文 + 诊断形状 + 宽容归一化) | +| `feat(view): render a view.json into a single-file HTML page` | `src/views/render.mjs`(payload 注入、回执、确定性、离线校验) | +| `feat(cli): register view check and view render` | `bin/distilly.mjs` 增加 `view` 子命令(最小注册,见 §8 缺口 1) | +| `chore(view): add the playwright visual check script` | `scripts/visual-check.mjs` | +| `test(view): cover schema diagnostics, rendering and template drift` | `tests/views.test.mjs`、`tests/template.test.mjs` | +| `docs(view): document the render layer contract` | `docs/v2/RENDER.md` | +| `docs(evidence): record the pr-03 render evidence` | 本文件 | + +未触碰 `src/parse/**`、`src/knowledge/**`、`src/skill/**`、`src/install/**`、`SKILL.md`、`prompts/**`; +未修改 `docs/v2/CONTRACT.md`。 + +## 2. 测试命令与结果 + +``` +$ node --test tests/views.test.mjs tests/template.test.mjs +# tests 44 +# pass 44 +# fail 0 +# duration_ms 710.41 +``` + +- `tests/views.test.mjs`(35 项):schema 正/反例、诊断形状逐字段断言、12 字原文边界(11 字不算、12 字算)、 + `--shareable` 放行、锚点格式/回指、宽容形状归一化(含幂等)、确定性(两次渲染字节相同)、 + 私有产物不含引文、`--shareable` 产物含引文且回执记录 `inlined_sources[]`、 + CLI `view check/render --person --json` 与退出码。 +- `tests/template.test.mjs`(9 项):模板 = 碎片构建结果(字节相同)、`--check` 干净退出 0、 + **碎片改了但模板没重生成 → 非零退出**(在临时目录里改 `viewer/sections.js` 后断言 exit 1, + 重新生成后回到 0)、marker 缺失/重复报错、`` 碎片被拒、产物单文件 + CSP + 无 `http(s)://`、 + 两次渲染字节相同。 + +## 3. 产物大小与 sha256 + +| 文件 | 字节 | sha256 | +|---|---|---| +| `assets/distilly-template.html`(生成物) | 41673 | `cb88a172694f31eb970d8f323ffefc1a13319d8aa17c84705ead959d1c738798` | +| `assets/template.source.html` | 10955 | `483189a41f196c7434aa359881a5c34a265db9d25fea3744aa788a776225f1db` | +| `viewer/sections.js` | 15585 | `80967985aee069058589ac5dff5ce4b5aada3b9af40cf6b5039753700017aaa7` | +| `viewer/theme.js` | 4073 | `3f9e73ee72a3dfbf4ca22e5b14551458b1b7251750e6740ab2db04a99bfc474a` | +| `viewer/export.js` | 6323 | `5eab79b4ceb5f3dabb5a4841c63c1f736bc3a64afdc0528b39fdb6900bccb06f` | +| `viewer/focus.js` | 4592 | `5a17e146cb6a1044ae94d9928cb5bb7198bcd1e2c672dd5d33e656e1a9456c91` | + +示例人 `zhang-san`(fixture 在 `/tmp/dst-fixture`,不入库;8 个锚点、18 条结论): + +| 产物 | 字节 | sha256 | +|---|---|---| +| `views/zhang-san.html`(默认私有) | 48338 | `2a0ace948a6027dfec12c77e0052c5c0f035fa3742a39fedb7ec47c9e9f46db0` | +| `views/zhang-san.shareable.html`(`--shareable`) | 49244 | `782118192556b49188bfd97da733e389947dc6be0e8bb6b010f9002581b5b9e1` | +| `evidence/renders/receipt.json`(私有运行) | 1505 | `d4aea409510c0535258266b46dd0726890cce5eac6a95109c8e4cdd1ceac911d` | +| `evidence/renders/receipt.json`(shareable 运行) | 3450 | `651cdd69f131d3c6130698211a8934a9456ce8bf7340df3ed770e27901906ba4` | +| 验收语料形状 `lin-gong.html`(无 `meta.slug`/无 `evidence[]`) | 46402 | `cce9cdb8c850da34917b430ec462e8c370f6adb0ce605733f933ae1477bdba4e` | + +确定性:同一输入连续两次 `view render`,HTML 与 receipt 的 sha256 均不变(单元测试与 §4 手工各验一次)。 +私有 HTML 中检索三条引文均**不存在**;`--shareable` HTML 中三条全部存在,且 +`receipt.inlined_sources[]` 长度 8(每条含 `anchor/source/kind/path/quote_sha256/quote_bytes`)。 + +## 4. visual-check 八项(手工跑) + +命令: + +``` +DISTILLY_PLAYWRIGHT_ROOT=<含 node_modules 的目录> \ + node scripts/visual-check.mjs --out /tmp/dst-evidence/pr-03/ +``` + +对三个页面各跑一次,均 **exit 0、8/8 通过**;下面逐条列出私有示例页的结果(`--json` 回执在 `/tmp/vc-private.json`)。 + +| # | 项 | 结果 | 数值 | +|---|---|---|---| +| 1 | console 无 error/warning | ✅ | 0 条 console / 0 个 pageerror / 0 个失败请求 | +| 2 | 八段非空 | ✅ | 8 段 / 26 条 / 附录 8 锚点 | +| 3 | 无横向溢出 | ✅ | 最大溢出 0px @ 1280/768/375px | +| 4 | 双主题对比度 | ✅ | 最小对比度 6.00:1;背景亮度 light 1.0 → dark 0.0118;`light→dark` 手动切换生效 | +| 5 | 锚点可定位到附录 | ✅ | 8/8 个锚点可聚焦(含回指链接),正文引用无悬空 | +| 6 | 零网络请求(离线 + CSP) | ✅ | 0 个外部请求 / 静态外链 0 个 / CSP 存在 | +| 7 | `@media print` 不裁切 | ✅ | 8 段可见 / 正文 1450 字符(与屏幕一致)/ 溢出 0px | +| 8 | 出 PNG | ✅ | 4 张 → `/tmp/dst-evidence/pr-03` | + +另两个页面(同一套断言,逐条全绿): + +- `legacy`(`lin-gong`,验收脚本的 `view.template.json` 形状 + 无 `evidence[]`):8 段 / 14 条 / 7 锚点, + 最小对比度 6.00:1,0 外部请求。 +- `shareable`(`zhang-san`):8 段 / 26 条 / 8 锚点,附录内联 8 条引文,最小对比度 6.00:1。 + +**负向对照**(证明断言真的会失败,不是恒真;输出在 `/tmp/dst-tamper-out/`): + +| 篡改 | 退出码 | 失败项 | +|---|---|---| +| 删除 CSP meta | 1 | 零网络请求(离线 + CSP) | +| 注入 `https://` 外链 CSS | 1 | console 无 error/warning、零网络请求 | +| payload 删除时间线段 | 1 | 八段非空、锚点可定位到附录、print 不裁切 | +| 亮色 `muted` 改成近背景色 | 1 | 双主题对比度 | +| 暗色 `muted` 改成近背景色 | 1 | 双主题对比度 | +| 手动暗色覆盖失效(`color-scheme` 打回 light) | 1 | 双主题对比度 | +| 正文引用一个没有附录行的锚点 | 1 | 锚点可定位到附录 | +| 原始页面(对照组) | 0 | — | + +其中「亮/暗 muted 低对比度」与「手动覆盖失效」两组对照是发现真实缺陷后补的: +最初 `:root { color-scheme: light }` 把 `light-dark()` 钉死在亮色,导致深色页面根本没变色, +而对比度断言仍然全绿;现在 theme 断言额外比较**实际背景亮度**(light 1.0 / dark 0.0118), +并断言手动切换必须覆盖系统偏好。 + +## 5. 其他手工验证 + +``` +$ node scripts/generate-template.mjs --check +template is up to date: sha256 cb88a172… (41673 bytes, 4 fragments) # exit 0 + +$ node bin/distilly.mjs view check zhang-san --root /tmp/dst-fixture +view check ok: 7/8 segments, 18 items, 8/8 anchors cited, 0 warning(s) + +$ node bin/distilly.mjs view check --person lin-gong --root /tmp/dst-legacy --json # 验收调用形式 +{"command":"view check","person":"lin-gong","ok":true,...,"anchors":{"total":7,"cited":7}} +``` + +- 指纹/大小一致性:`receipt.outputs[0].sha256` 与实际 HTML 文件 sha256 相同(`tests/views.test.mjs` 断言)。 +- 产物检索 `http://` / `https://` / ``); + fragments.push({ token, path: relative, sha256: sha256(body), bytes: Buffer.byteLength(body, "utf8") }); + } + + const dataMarkers = output.split(DATA_TOKEN).length - 1; + if (dataMarkers !== 1) fail(`marker ${DATA_TOKEN} must appear exactly once in template.source.html (found ${dataMarkers})`); + + // The view payload marker stays in the committed template: src/views/render.mjs is the + // only writer that replaces it. Without a payload the page shows its empty state. + const leftover = (output.replace(DATA_TOKEN, "")).match(/@@DISTILLY:[A-Z_]+@@/g); + if (leftover) fail(`unsubstituted markers remain: ${[...new Set(leftover)].join(", ")}`); + + const header = + "\n`; + output = output.replace("\n", `\n${header}`); + + validateOutput(output); + return { output, fragments }; +} + +function validateOutput(output) { + if (!output.includes(CSP)) fail("the generated template lost the frozen CSP meta tag"); + if (!output.includes('')) fail("the generated template lost "); + if ((output.match(/ element"); + const dataTag = '`)) { + fail(`the view data marker ${DATA_TOKEN} must be the whole payload placeholder`); + } + for (const pattern of ["http://", "https://", "src=\"//", "@import", "url(http"]) { + if (output.includes(pattern)) fail(`the generated template must stay offline but contains ${pattern}`); + } +} + +function parseArgs(argv) { + const options = { check: false, json: false, quiet: false }; + for (const arg of argv) { + if (arg === "--check") options.check = true; + else if (arg === "--json") options.json = true; + else if (arg === "--quiet") options.quiet = true; + else if (arg === "--help" || arg === "-h") options.help = true; + else fail(`unknown option: ${arg}`); + } + return options; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(`Usage: node scripts/generate-template.mjs [--check] [--json] [--quiet] + + (no flags) write assets/distilly-template.html from template.source.html + viewer/*.js + --check compare the committed template with a fresh build; exit 1 on drift + --json print a receipt instead of prose`); + return 0; + } + + const { output, fragments } = buildTemplate(); + const bytes = Buffer.byteLength(output, "utf8"); + const digest = sha256(output); + const committed = existsSync(TEMPLATE_PATH) ? readFileSync(TEMPLATE_PATH, "utf8") : null; + + if (options.check) { + const drift = committed !== output; + const receipt = { + command: "template --check", + ok: !drift, + template: { path: "assets/distilly-template.html", sha256: digest, bytes, committed_sha256: committed === null ? null : sha256(committed) }, + fragments, + warnings: [], + supported_fixes: drift + ? ["run: node scripts/generate-template.mjs", "commit assets/distilly-template.html with the fragment change"] + : [], + }; + if (options.json) console.log(JSON.stringify(receipt, null, 2)); + else if (drift) { + console.error("template drift: assets/distilly-template.html is not the build of the current fragments."); + console.error(` built sha256 ${digest} (${bytes} bytes)`); + console.error(` on disk sha256 ${committed === null ? "missing" : sha256(committed)}`); + console.error(" fix: node scripts/generate-template.mjs && git add assets/distilly-template.html"); + } else if (!options.quiet) { + console.log(`template is up to date: sha256 ${digest} (${bytes} bytes, ${fragments.length} fragments)`); + } + return drift ? 1 : 0; + } + + if (committed === output) { + if (!options.quiet) console.log(`template unchanged: sha256 ${digest} (${bytes} bytes)`); + return 0; + } + + writeFileSync(TEMPLATE_PATH, output, "utf8"); + if (options.json) { + console.log( + JSON.stringify( + { + command: "template", + ok: true, + outputs: [{ path: "assets/distilly-template.html", sha256: digest, bytes }], + fragments, + warnings: [], + }, + null, + 2, + ), + ); + } else if (!options.quiet) { + console.log(`wrote assets/distilly-template.html: sha256 ${digest} (${bytes} bytes)`); + for (const entry of fragments) console.log(` ${entry.token} <- ${entry.path} (${entry.bytes} bytes)`); + } + return 0; +} + +if (process.argv[1] && isMain()) { + try { + process.exitCode = main(); + } catch (error) { + console.error(`Error: ${error.message}`); + process.exitCode = 1; + } +} + +/** True when this file is the process entry point (/tmp vs /private/tmp safe). */ +function isMain() { + try { + return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)); + } catch (error) { + return false; + } +} diff --git a/src/commands/view.mjs b/src/commands/view.mjs new file mode 100644 index 00000000..ef47f6bf --- /dev/null +++ b/src/commands/view.mjs @@ -0,0 +1,202 @@ +/** + * `distilly view check|render` — the single-file HTML layer (from ds/03-render). + * + * Registered into the ds/01 dispatcher instead of being handled inline in + * `bin/distilly.mjs`: the modules doing the work (`src/views/schema.mjs`, + * `src/views/render.mjs`) are unchanged, only the entry point moved. + * + * Receipts follow CONTRACT §3, and validation failures are *returned* (with + * diagnostics) rather than thrown, so `--json` always yields one object. + */ + +import { relative, resolve } from "node:path"; + +import { register } from "./index.mjs"; +import { CliError, createReceipt } from "../cli/receipt.mjs"; +import { checkView, expectedSlug, formatDiagnostic, normalizeView } from "../views/schema.mjs"; +import { ViewError, findViewDocuments, loadViewDocument, renderView } from "../views/render.mjs"; + +const OPTIONS = { + person: { type: "string", value: "slug" }, + slug: { type: "string", value: "slug" }, + file: { type: "string", value: "path" }, + root: { type: "string", value: "dir" }, + out: { type: "string", value: "path" }, + shareable: { type: "boolean" }, + // 声明「我知道这里有缺口」:只把「本节稀薄」从错误降为警告 + "allow-missing": { type: "boolean" }, +}; + +function parseViewArgs(argv) { + const options = { slug: null, file: null, root: process.cwd(), out: null, shareable: false, allowMissing: false }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--shareable") options.shareable = true; + else if (arg === "--allow-missing") options.allowMissing = true; + else if (arg === "--person" || arg === "--slug" || arg === "--file" || arg === "--root" || arg === "--out") { + const value = argv[index + 1]; + if (!value) throw new Error(`${arg} requires a value`); + options[arg === "--person" ? "slug" : arg.slice(2)] = value; + index += 1; + } else if (arg.startsWith("--")) throw new Error(`unknown option: ${arg}`); + else if (arg.endsWith(".view.json") && !options.file && !options.slug) options.file = arg; + else if (!options.slug) options.slug = arg; + else throw new Error(`unexpected argument: ${arg}`); + } + return options; +} + +function resolveViewPath(options) { + if (options.file) return resolve(options.file); + if (!options.slug) throw new Error("view check|render needs a or --file "); + const candidates = findViewDocuments(options.slug, resolve(options.root)); + if (candidates.length === 0) { + throw new Error( + `no views/${options.slug}.view.json under ${resolve(options.root)}; ` + + "expected skills///views/.view.json — pass --file to point at one", + ); + } + if (candidates.length > 1) { + throw new Error(`ambiguous slug "${options.slug}": ${candidates.join(", ")} — pass --file to choose one`); + } + return candidates[0]; +} + +function helpFor(sub) { + const zh = [ + "用法:", + ` distilly view ${sub} [--person |--file ] [--root ] [--json]`, + sub === "render" ? " distilly view render [--out ] [--shareable]" : "", + "", + "说明:", + " 默认私有模式只输出结论与锚点编号;--shareable 才内联原文并写进回执。", + ].filter(Boolean).join("\n"); + const en = [ + "Usage:", + ` distilly view ${sub} [--person |--file ] [--root ] [--json]`, + sub === "render" ? " distilly view render [--out ] [--shareable]" : "", + "", + "Notes:", + " Private mode (default) prints conclusions plus anchor ids only; --shareable inlines quotes and records them in the receipt.", + ].filter(Boolean).join("\n"); + return { zh, en }; +} + +const checkCommand = { + summary: "校验 view spec / validate a view spec", + usage: "distilly view check [--person |--file ] [--shareable] [--json]", + options: OPTIONS, + ...helpFor("check"), + run({ argv, json, reporter, ctx }) { + const options = parseViewArgs(argv); + const viewPath = resolveViewPath(options); + const document = loadViewDocument(viewPath); + const report = checkView(document.view, { viewPath: document.path, shareable: options.shareable, allowMissing: options.allowMissing }); + const normalized = normalizeView(document.view).view; + const receipt = { + ...createReceipt("view check", { + person: normalized?.meta?.slug ?? expectedSlug(document.path) ?? null, + ok: report.ok, + }), + shareable: options.shareable, + inputs: [{ path: relative(process.cwd(), document.path), sha256: document.sha256, bytes: document.bytes }], + anchors: { total: report.summary.anchors, cited: report.summary.cited }, + summary: report.summary, + diagnostics: report.diagnostics, + warnings: report.warnings.map((entry) => entry.message), + }; + if (!json) { + for (const entry of report.diagnostics) reporter.line(formatDiagnostic(entry)); + reporter.line( + `view check ${report.ok ? "ok" : `${report.errors.length} error(s)`}: ` + + `${report.summary.sections}/8 segments, ${report.summary.items} items, ` + + `${report.summary.cited}/${report.summary.anchors} anchors cited, ${report.warnings.length} warning(s)`, + ); + } + return { receipt, exitCode: report.ok ? 0 : 1 }; + }, +}; + +const renderCommand = { + summary: "渲染单文件 HTML / render the single-file HTML", + usage: "distilly view render [--person |--file ] [--out ] [--shareable] [--json]", + options: OPTIONS, + ...helpFor("render"), + run({ argv, json, reporter }) { + const options = parseViewArgs(argv); + const viewPath = resolveViewPath(options); + try { + const result = renderView({ + allowMissing: options.allowMissing, + viewPath, + outPath: options.out ? resolve(options.out) : undefined, + shareable: options.shareable, + root: resolve(options.root), + }); + if (!json) { + const output = result.receipt.outputs[0]; + reporter.line(`view render ok: ${output.path} (${output.bytes} bytes, sha256 ${output.sha256})`); + reporter.line(` receipt: ${relative(process.cwd(), result.receiptPath)}`); + reporter.line( + ` mode: ${options.shareable ? "shareable (quotes inlined)" : "private (conclusions + anchor ids only)"}, ` + + `segments: ${result.receipt.segments.length}, anchors: ${result.receipt.anchors.cited}/${result.receipt.anchors.total} cited`, + ); + for (const entry of result.report.warnings) { + reporter.line(` warning: ${formatDiagnostic(entry).replace(/\n\s*/g, " ")}`); + } + } + return { receipt: result.receipt, exitCode: 0 }; + } catch (error) { + if (!(error instanceof ViewError)) throw error; + const receipt = { + ...createReceipt("view render", { ok: false }), + diagnostics: error.diagnostics, + supported_fixes: error.supportedFixes, + }; + if (!json) { + reporter.error(`Error: ${error.message}`); + for (const entry of error.diagnostics) reporter.error(formatDiagnostic(entry)); + for (const fix of error.supportedFixes) reporter.error(` fix: ${fix}`); + } + return { receipt, exitCode: 1 }; + } + }, +}; + +const viewHelp = { + zh: [ + "用法 / Usage:", + " distilly view check --person [--base-dir ] [--json]", + " distilly view render --person [--base-dir ] [--shareable] [--json]", + "", + "`view` 没有独立行为:它是一组子命令的入口。`check` 只读校验视图与锚点,", + "`render` 生成单文件离线 HTML;SKILL.md 里写的 `distilly view check` 属于前者。", + ].join("\n"), + en: [ + "Distilly view — inspect and render a view.", + "", + "`view` has no behaviour of its own; it is the entry point for its subcommands.", + "`check` validates the view and its anchors without writing anything, `render`", + "produces the single-file offline HTML. `distilly view check` in SKILL.md is the", + "former.", + ].join("\n"), +}; + +register("view", { + summary: "View 子命令入口 / View subcommand entry", + usage: "distilly view [options]", + ...viewHelp, + run({ argv, reporter }) { + if (argv.length === 0) { + reporter.line(viewHelp.zh); + return { receipt: createReceipt("view", { warnings: [] }) }; + } + throw new CliError(`unknown view subcommand: ${argv[0]}`, { + code: "usage", + remedy: "choose one of: check, render.", + }); + }, +}); + +register("view check", checkCommand); +register("view render", renderCommand); diff --git a/src/views/render.mjs b/src/views/render.mjs new file mode 100644 index 00000000..444d519c --- /dev/null +++ b/src/views/render.mjs @@ -0,0 +1,266 @@ +/** + * distilly view render — template + views/.view.json -> views/.html + * plus evidence/renders/receipt.json. + * + * Guarantees: + * - single file, offline, no external request (the template's CSP forbids them); + * - deterministic: the same inputs produce the same bytes, twice in a row; + * - private by default: without --shareable no source wording reaches the HTML; + * - the renderer invents nothing: every byte of content comes from view.json. + * + * Zero runtime dependencies. + */ +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { anchorCounts, checkView, expectedSlug } from "./schema.mjs"; + +export const packageRoot = fileURLToPath(new URL("../../", import.meta.url)); +export const TEMPLATE_PATH = join(packageRoot, "assets", "distilly-template.html"); +export const VIEW_DATA_MARKER = "@@DISTILLY:VIEW_DATA@@"; + +const RECEIPT_RELATIVE = join("evidence", "renders", "receipt.json"); + +export class ViewError extends Error { + constructor(message, diagnostics = [], supportedFixes = []) { + super(message); + this.name = "ViewError"; + this.diagnostics = diagnostics; + this.supportedFixes = supportedFixes; + } +} + +export function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +export function sha256File(path) { + return sha256(readFileSync(path)); +} + +function isPlainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Recursively sort object keys so the same document always serialises identically. */ +function canonical(value) { + if (Array.isArray(value)) return value.map(canonical); + if (isPlainObject(value)) { + const out = {}; + for (const key of Object.keys(value).sort()) out[key] = canonical(value[key]); + return out; + } + return value; +} + +/** Deterministic JSON (sorted keys, 2-space indent, trailing newline). */ +export function canonicalJson(value, indent = 2) { + return `${JSON.stringify(canonical(value), null, indent)}\n`; +} + +/** JSON safe to inline inside \n`, "utf8"); + const run = runGenerator(root); + assert.equal(run.status, 1); + assert.match(run.stderr, /<\/script/); +}); + +test("the template is a single offline file with the frozen CSP", () => { + const html = readCommitted(); + assert.ok(html.includes(`content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'"`)); + assert.ok(!html.includes("http://") && !html.includes("https://"), "no absolute URL may appear in the template"); + assert.equal(/]+src=/i.test(html), false); + assert.equal(/]+href=/i.test(html), false); + assert.equal(/]+src=/i.test(html), false); + assert.equal((html.match(//g) || []).length, 1); + assert.ok(html.includes('')); + assert.ok(html.includes(" { + const html = readCommitted(); + assert.equal(html.split(VIEW_DATA_MARKER).length - 1, 1); + assert.equal(html.replace(VIEW_DATA_MARKER, "").includes("@@DISTILLY:"), false, "no other marker may survive"); + for (const relative of Object.values(FRAGMENTS)) { + const body = readFileSync(join(repoRoot, relative), "utf8"); + assert.ok(html.includes(body.trimEnd()), `${relative} is not embedded verbatim`); + } + assert.ok(html.includes('"), true); +}); + +test("private render never inlines source wording; --shareable does and records it", () => { + const root = tempRoot(); + const viewPath = writeFixture(root, validView()); + + const privateRun = renderView({ viewPath }); + for (const quote of Object.values(QUOTES)) { + assert.equal(privateRun.html.includes(quote.slice(0, 12)), false, "private output must not carry source wording"); + assert.equal(privateRun.html.includes(quote), false); + } + assert.deepEqual(privateRun.receipt.inlined_sources, []); + + const shareablePath = writeFixture(tempRoot(), validView()); + const shared = renderView({ viewPath: shareablePath, shareable: true }); + assert.ok(shared.html.includes(QUOTES.k0012), "shareable output must inline the quote"); + assert.equal(shared.receipt.shareable, true); + assert.equal(shared.receipt.inlined_sources.length, 8); + assert.deepEqual(shared.receipt.inlined_sources[0].anchor, "k0012"); + assert.match(shared.receipt.inlined_sources[0].quote_sha256, /^[0-9a-f]{64}$/); + assert.ok(shared.receipt.inlined_sources[0].quote_bytes > 0); + assert.notEqual(shared.receipt.outputs[0].sha256, privateRun.receipt.outputs[0].sha256); +}); + +test("renderView refuses an invalid document and writes nothing", () => { + const root = tempRoot(); + const broken = validView(); + broken.sections[0].items[0].anchors = []; + const viewPath = writeFixture(root, broken); + const outPath = join(root, "skills", "colleague", "zhang-san", "views", "zhang-san.html"); + + assert.throws( + () => renderView({ viewPath }), + (error) => { + assert.ok(error instanceof ViewError); + assert.ok(error.diagnostics.some((entry) => entry.code === "VIEW_ANCHOR_MISSING")); + assert.ok(error.supportedFixes.length > 0); + return true; + }, + ); + assert.equal(existsSync(outPath), false); + assert.equal(existsSync(join(root, "skills", "colleague", "zhang-san", "evidence", "renders", "receipt.json")), false); +}); + +test("loadViewDocument reports broken JSON with a fix", () => { + const root = tempRoot(); + const dir = join(root, "views"); + mkdirSync(dir, { recursive: true }); + const path = join(dir, "broken.view.json"); + writeFileSync(path, "{ not json", "utf8"); + assert.throws(() => loadViewDocument(path), /not valid JSON/); + assert.throws(() => loadViewDocument(join(dir, "missing.view.json")), /not found/); +}); + +test("canonicalJson sorts keys so receipts are byte-stable", () => { + const value = { b: 1, a: { d: 2, c: [3, { f: 4, e: 5 }] } }; + assert.equal(canonicalJson(value), canonicalJson({ a: { c: [3, { e: 5, f: 4 }], d: 2 }, b: 1 })); + assert.equal(canonicalJson(value), '{\n "a": {\n "c": [\n 3,\n {\n "e": 5,\n "f": 4\n }\n ],\n "d": 2\n },\n "b": 1\n}\n'); +}); + +test("REQUIRED_SECTIONS names the eight page segments in order", () => { + assert.deepEqual( + REQUIRED_SECTIONS.map((entry) => entry.id), + ["portrait", "communication", "values", "workstyle", "relationship", "boundaries", "timeline", "evidence"], + ); + assert.deepEqual(KINDS_OF_AUTHORED(), ["claims", "claims", "claims", "claims", "claims", "warnings", "timeline"]); +}); + +function KINDS_OF_AUTHORED() { + return REQUIRED_SECTIONS.filter((entry) => !entry.derived).map((entry) => entry.kind); +} + +test("anchor pattern accepts ledger ids and rejects prose", () => { + for (const value of ["k0012", "k0012:t3", "msg0001", "k0040:t2"]) assert.ok(ANCHOR_PATTERN.test(value), value); + for (const value of ["12", "K0012", "[k0012]", "k12", "k0012:t"]) assert.equal(ANCHOR_PATTERN.test(value), false, value); +}); + +/* ---------------------------------------------------------------- CLI leaf */ + +function runCli(args) { + return spawnSync(process.execPath, [BIN, ...args], { encoding: "utf8", cwd: repoRoot }); +} + +test("cli: view check --json emits a contract receipt", () => { + const root = tempRoot(); + writeFixture(root, validView()); + const run = runCli(["view", "check", "zhang-san", "--root", root, "--json"]); + assert.equal(run.status, 0, run.stderr); + const receipt = JSON.parse(run.stdout); + assert.equal(receipt.command, "view check"); + assert.equal(receipt.ok, true); + assert.equal(receipt.anchors.total, 8); + assert.equal(receipt.anchors.cited, 8); + assert.equal(receipt.diagnostics.length, 0); + assert.equal(receipt.inputs.length, 1); + assert.match(receipt.inputs[0].sha256, /^[0-9a-f]{64}$/); +}); + +test("cli: view check exits 1 and prints fixes for a broken document", () => { + const root = tempRoot(); + const broken = validView(); + broken.sections = broken.sections.filter((section) => section.id !== "boundaries"); + broken.sections[0].items[0].confidence = "sure"; + writeFixture(root, broken); + + const human = runCli(["view", "check", "zhang-san", "--root", root]); + assert.equal(human.status, 1); + assert.match(human.stdout, /VIEW_SECTION_MISSING/); + assert.match(human.stdout, /VIEW_CONFIDENCE_INVALID/); + assert.match(human.stdout, /fix:/); + + const json = runCli(["view", "check", "zhang-san", "--root", root, "--json"]); + assert.equal(json.status, 1); + const receipt = JSON.parse(json.stdout); + assert.equal(receipt.ok, false); + assert.ok(receipt.diagnostics.length >= 2); +}); + +test("cli: view render writes the page twice with identical bytes", () => { + const root = tempRoot(); + const viewPath = writeFixture(root, validView()); + const first = runCli(["view", "render", "zhang-san", "--root", root, "--json"]); + assert.equal(first.status, 0, first.stderr); + const htmlPath = join(root, "skills", "colleague", "zhang-san", "views", "zhang-san.html"); + const receiptPath = join(root, "skills", "colleague", "zhang-san", "evidence", "renders", "receipt.json"); + const firstHtml = readFileSync(htmlPath, "utf8"); + const firstReceipt = readFileSync(receiptPath, "utf8"); + + const second = runCli(["view", "render", viewPath, "--json"]); + assert.equal(second.status, 0, second.stderr); + assert.equal(readFileSync(htmlPath, "utf8"), firstHtml); + assert.equal(readFileSync(receiptPath, "utf8"), firstReceipt); + + const receipt = JSON.parse(second.stdout); + assert.equal(receipt.ok, true); + assert.equal(receipt.outputs[0].sha256, sha256(firstHtml)); +}); + +test("cli: view render refuses an invalid document with exit 1", () => { + const root = tempRoot(); + const broken = validView(); + broken.evidence = []; + writeFixture(root, broken); + const run = runCli(["view", "render", "zhang-san", "--root", root]); + assert.equal(run.status, 1); + assert.match(run.stderr, /VIEW_EVIDENCE_EMPTY|failed view check/); +}); + +test("cli: view needs a slug or --file", () => { + const run = runCli(["view", "check"]); + assert.equal(run.status, 1); + assert.match(run.stderr, /needs a or --file/); + + const help = runCli(["view", "--help"]); + assert.equal(help.status, 0); + assert.match(help.stdout, /## English/); +}); diff --git a/tests/visual-check-rule.test.mjs b/tests/visual-check-rule.test.mjs new file mode 100644 index 00000000..c3b236dd --- /dev/null +++ b/tests/visual-check-rule.test.mjs @@ -0,0 +1,32 @@ +/** + * The anchor rule `scripts/visual-check.mjs` applies. + * + * An appendix row nobody cites is legitimate — it is the ledger index — so it must + * exist and be focusable, but it cannot have a back-link. Requiring one failed + * every page whose appendix carries the whole anchor table (a multi-source Skill + * does), which the run-4 blind page exposed. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { anchorProblem } from "../scripts/visual-check.mjs"; + +const good = { id: "k0001", ok: true, inAppendix: true, focused: true, visible: true, backLinks: 1 }; + +test("a cited anchor needs a back-link", () => { + assert.equal(anchorProblem(good, true), false); + assert.equal(anchorProblem({ ...good, backLinks: 0 }, true), true, "no back-link on a cited anchor is a problem"); +}); + +test("an uncited appendix row is fine without one", () => { + assert.equal(anchorProblem({ ...good, backLinks: 0 }, false), false); +}); + +test("missing, unfocused or invisible anchors are problems either way", () => { + assert.equal(anchorProblem({ id: "k1", ok: false }, false), true); + assert.equal(anchorProblem({ ...good, inAppendix: false }, false), true); + assert.equal(anchorProblem({ ...good, focused: false }, false), true); + assert.equal(anchorProblem({ ...good, visible: false }, false), true); + assert.equal(anchorProblem(undefined, true), true); +}); diff --git a/viewer/export.js b/viewer/export.js new file mode 100644 index 00000000..f05fe377 --- /dev/null +++ b/viewer/export.js @@ -0,0 +1,186 @@ +/* Distilly viewer fragment: offline export actions (print, Markdown copy, HTML snapshot). + No network is used; a download is built from the current document itself. */ +(function () { + "use strict"; + + var LABELS = { + zh: { + copied: "已复制 Markdown 摘要(" + "字符)", + copyFailed: "复制失败:请手动选择页面内容。", + downloaded: "已生成 HTML 快照(浏览器下载目录)。", + downloadFailed: "下载失败:可用「打印 / 导出 PDF」代替。", + printing: "已调用打印对话框;选择「存储为 PDF」即可保存。", + unavailable: "页面数据缺失,导出内容为空。" + }, + en: { + copied: "Markdown summary copied ({n} characters).", + copyFailed: "Copy failed: please select the page content manually.", + downloaded: "HTML snapshot written to your downloads folder.", + downloadFailed: "Download failed: use Print / Export PDF instead.", + printing: "Print dialog requested; choose \"Save as PDF\".", + unavailable: "No page data: nothing to export." + } + }; + + function labels() { + var lang = document.documentElement.getAttribute("lang") || "zh"; + return lang.toLowerCase().indexOf("en") === 0 ? LABELS.en : LABELS.zh; + } + + function status(message) { + var node = document.getElementById("action-status"); + if (node) node.textContent = message; + } + + function view() { + return window.DistillyView && window.DistillyView.view ? window.DistillyView.view : null; + } + + function asArray(value) { + return Object.prototype.toString.call(value) === "[object Array]" ? value : []; + } + + function anchorText(anchors) { + var list = asArray(anchors); + if (list.length === 0) return ""; + return " `" + list.join("` `") + "`"; + } + + function toMarkdown(source) { + if (!source) return ""; + var meta = source.meta || {}; + var lines = ["# " + (meta.title || meta.slug || "Person View"), ""]; + if (meta.slug) lines.push("- slug: `" + meta.slug + "`"); + if (meta.generated_at) lines.push("- generated_at: " + meta.generated_at); + lines.push("- shareable: " + (source.shareable === true ? "true" : "false")); + lines.push(""); + + asArray(source.sections).forEach(function (section, index) { + lines.push("## " + (index + 1) + ". " + (section.title || section.id)); + if (section.summary) lines.push("", String(section.summary)); + lines.push(""); + asArray(section.items).forEach(function (item) { + var prefix = section.kind === "timeline" ? "- " + (item.at || "—") + " · " : "- "; + var suffix = item.confidence ? " (" + item.confidence + ")" : ""; + lines.push(prefix + item.text + suffix + anchorText(item.anchors)); + }); + lines.push(""); + }); + + var evidence = asArray(source.evidence); + lines.push("## " + (evidence.length > 0 ? "证据附录 / Evidence appendix" : "证据附录"), ""); + evidence.forEach(function (entry) { + var where = []; + if (entry.source) where.push(entry.source); + if (entry.kind) where.push(entry.kind); + if (entry.at) where.push(entry.at); + if (entry.path) where.push(entry.path); + lines.push("- `" + entry.anchor + "` — " + where.join(" · ") + (entry.note ? " — " + entry.note : "")); + if (source.shareable === true && entry.quote) lines.push(" > " + String(entry.quote).replace(/\n/g, " ")); + }); + lines.push(""); + return lines.join("\n"); + } + + function copyText(text) { + if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") { + return navigator.clipboard.writeText(text).then(function () { + return true; + }).catch(function () { + return legacyCopy(text); + }); + } + return Promise.resolve(legacyCopy(text)); + } + + function legacyCopy(text) { + var area = document.createElement("textarea"); + area.value = text; + area.setAttribute("readonly", "readonly"); + area.className = "visually-hidden"; + area.setAttribute("aria-hidden", "true"); + document.body.appendChild(area); + var ok = false; + try { + area.select(); + ok = document.execCommand("copy"); + } catch (error) { + ok = false; + } + document.body.removeChild(area); + return ok; + } + + function download() { + var text = "\n" + document.documentElement.outerHTML; + var name = ((view() && view().meta && view().meta.slug) || "distilly-view") + ".html"; + var link = document.createElement("a"); + link.setAttribute("download", name); + link.className = "visually-hidden"; + link.setAttribute("aria-hidden", "true"); + var url = ""; + try { + var blob = new Blob([text], { type: "text/html;charset=utf-8" }); + url = URL.createObjectURL(blob); + link.href = url; + } catch (error) { + link.href = "data:text/html;charset=utf-8," + encodeURIComponent(text); + } + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + if (url) window.setTimeout(function () { URL.revokeObjectURL(url); }, 0); + return true; + } + + function bind(id, handler) { + var node = document.getElementById(id); + if (!node) return; + node.addEventListener("click", function () { + var text = labels(); + try { + handler(text); + } catch (error) { + status(text.copyFailed); + } + }); + } + + function boot() { + bind("export-print", function (text) { + status(text.printing); + window.print(); + }); + + bind("export-copy", function (text) { + var markdown = toMarkdown(view()); + if (markdown === "") { + status(text.unavailable); + return; + } + Promise.resolve(copyText(markdown)).then(function (ok) { + status(ok ? text.copied.replace("(" + "字符)", "(" + markdown.length + " 字符)").replace("{n}", String(markdown.length)) + : text.copyFailed); + }); + }); + + bind("export-html", function (text) { + var ok = false; + try { + ok = download(); + } catch (error) { + ok = false; + } + status(ok ? text.downloaded : text.downloadFailed); + }); + + window.DistillyExport = { toMarkdown: toMarkdown, version: 1 }; + document.documentElement.setAttribute("data-export-bound", "true"); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot); + } else { + boot(); + } +})(); diff --git a/viewer/focus.js b/viewer/focus.js new file mode 100644 index 00000000..84a06e38 --- /dev/null +++ b/viewer/focus.js @@ -0,0 +1,133 @@ +/* Distilly viewer fragment: keyboard reachability and anchor focus behaviour. + Anchor ids can contain ':' (k0012:t3), so every lookup goes through getElementById. */ +(function () { + "use strict"; + + var HIGHLIGHT = "is-focused"; + var LABELS = { + zh: { jumped: "已定位到 {what}", back: "返回引用处", skipped: "已跳到正文" }, + en: { jumped: "Jumped to {what}", back: "Back to the reference", skipped: "Skipped to content" } + }; + + function labels() { + var lang = document.documentElement.getAttribute("lang") || "zh"; + return lang.toLowerCase().indexOf("en") === 0 ? LABELS.en : LABELS.zh; + } + + function status(message) { + var node = document.getElementById("action-status"); + if (node) node.textContent = message; + } + + function targetFromHash(hash) { + var raw = String(hash || "").replace(/^#/, ""); + if (raw === "") return null; + try { + return document.getElementById(decodeURIComponent(raw)); + } catch (error) { + return document.getElementById(raw); + } + } + + function describe(node) { + if (!node) return ""; + if (node.hasAttribute("data-anchor")) return "证据 " + node.getAttribute("data-anchor"); + var heading = node.querySelector ? node.querySelector(".section__title") : null; + if (heading) return heading.textContent || ""; + return node.id || ""; + } + + function clearHighlights() { + var marked = document.querySelectorAll("." + HIGHLIGHT); + for (var index = 0; index < marked.length; index += 1) { + marked[index].classList.remove(HIGHLIGHT); + } + var active = document.querySelectorAll(".anchor-ref.is-active"); + for (var i = 0; i < active.length; i += 1) active[i].classList.remove("is-active"); + } + + function focusTarget(node, announce) { + if (!node) return false; + clearHighlights(); + if (!node.hasAttribute("tabindex")) node.setAttribute("tabindex", "-1"); + node.classList.add(HIGHLIGHT); + try { + node.focus({ preventScroll: true }); + } catch (error) { + node.focus(); + } + if (typeof node.scrollIntoView === "function") { + node.scrollIntoView({ block: "start", behavior: "auto" }); + } + if (announce) status(labels().jumped.replace("{what}", describe(node))); + return true; + } + + function bindAnchorRefs() { + var refs = document.querySelectorAll(".anchor-ref[data-anchor-ref]"); + for (var index = 0; index < refs.length; index += 1) { + var ref = refs[index]; + ref.addEventListener("focus", function (event) { + event.currentTarget.classList.add("is-active"); + }); + ref.addEventListener("blur", function (event) { + event.currentTarget.classList.remove("is-active"); + }); + ref.addEventListener("click", function (event) { + var anchor = event.currentTarget.getAttribute("data-anchor-ref"); + var node = document.getElementById("anchor-" + anchor); + if (node) { + event.preventDefault(); + focusTarget(node, true); + if (window.history && typeof window.history.replaceState === "function") { + window.history.replaceState(null, "", "#anchor-" + anchor); + } + } + }); + } + + var backs = document.querySelectorAll('.evidence__meta a[href^="#section-"]'); + for (var i = 0; i < backs.length; i += 1) { + backs[i].setAttribute("title", labels().back); + } + } + + function bindSkipLink() { + var link = document.querySelector(".skip-link"); + var main = document.getElementById("main"); + if (!link || !main) return; + link.addEventListener("click", function () { + focusTarget(main, false); + status(labels().skipped); + }); + } + + function bindEscape() { + document.addEventListener("keydown", function (event) { + if (event.key !== "Escape") return; + var active = document.activeElement; + if (!active || active === document.body) return; + if (active.classList && (active.classList.contains(HIGHLIGHT) || active.classList.contains("anchor-ref"))) { + clearHighlights(); + if (typeof active.blur === "function") active.blur(); + } + }); + } + + function boot() { + bindSkipLink(); + bindAnchorRefs(); + bindEscape(); + if (window.location.hash) focusTarget(targetFromHash(window.location.hash), false); + window.addEventListener("hashchange", function () { + focusTarget(targetFromHash(window.location.hash), true); + }); + document.documentElement.setAttribute("data-focus-bound", "true"); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot); + } else { + boot(); + } +})(); diff --git a/viewer/sections.js b/viewer/sections.js new file mode 100644 index 00000000..46dbefbd --- /dev/null +++ b/viewer/sections.js @@ -0,0 +1,398 @@ +/* Distilly viewer fragment: renders the eight page sections from the embedded view.json. + Classic script (no modules, no network). Text only: every value is written with + textContent, so a hostile view.json cannot inject markup. */ +(function () { + "use strict"; + + var DATA_ID = "distilly-view-data"; + var ROOT_SELECTOR = "[data-sections-root]"; + + var LABELS = { + zh: { + confidence: { high: "高置信", medium: "中置信", low: "低置信" }, + severity: { high: "雷区·高", medium: "雷区·中", low: "雷区·低" }, + claims: "条结论", + timeline: "个节点", + warnings: "条边界", + evidence: "条锚点", + anchorLabel: "查看证据 ", + citedBy: "被引用", + where: "出处", + digest: "校验", + quote: "原文", + empty: "本节没有可展示的内容。", + noData: "尚未嵌入 view.json 数据;这是一份可直接打开的模板。", + privacyPrivate: "默认私有:本页只显示结论与锚点编号,不内联任何原始引文。", + privacyShareable: "可分享模式:本页内联了下列来源的原文引文,请自行确认分享范围。", + appendixTitle: "证据附录", + appendixNote: "编号可在 knowledge/index.json 回指;点编号可从结论跳到此处。", + quotedSources: "内联来源", + footer: "渲染器不产生事实:所有结论与锚点均来自 view.json 与知识账本。" + }, + en: { + confidence: { high: "high confidence", medium: "medium confidence", low: "low confidence" }, + severity: { high: "red line · high", medium: "red line · medium", low: "red line · low" }, + claims: "claims", + timeline: "milestones", + warnings: "boundaries", + evidence: "anchors", + anchorLabel: "Show evidence ", + citedBy: "cited by", + where: "source", + digest: "digest", + quote: "quote", + empty: "This section has nothing to show.", + noData: "No view.json payload is embedded; this file is the openable template itself.", + privacyPrivate: "Private by default: conclusions and anchor ids only, no source wording.", + privacyShareable: "Shareable mode: verbatim quotes from the sources below are inlined.", + appendixTitle: "Evidence appendix", + appendixNote: "Anchor ids trace back to knowledge/index.json.", + quotedSources: "inlined sources", + footer: "The renderer invents nothing: every claim and anchor comes from view.json." + } + }; + + var DEFAULT_TITLES = [ + { id: "portrait", zh: "一句话画像", en: "One-line portrait" }, + { id: "communication", zh: "沟通风格", en: "Communication style" }, + { id: "values", zh: "决策与价值观", en: "Decisions and values" }, + { id: "workstyle", zh: "工作方式", en: "Working style" }, + { id: "relationship", zh: "关系与称呼", en: "Relationship and address" }, + { id: "boundaries", zh: "边界与雷区", en: "Boundaries and red lines" }, + { id: "timeline", zh: "时间线演变", en: "Timeline" } + ]; + + function readView() { + var node = document.getElementById(DATA_ID); + if (!node) return null; + var raw = (node.textContent || "").trim(); + if (raw === "" || raw === "null") return null; + try { + return JSON.parse(raw); + } catch (error) { + return null; + } + } + + function labelsFor(view) { + var lang = view && view.meta && typeof view.meta.lang === "string" ? view.meta.lang : "zh"; + return lang.toLowerCase().indexOf("en") === 0 ? LABELS.en : LABELS.zh; + } + + function el(tag, className, text) { + var node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined && text !== null) node.textContent = String(text); + return node; + } + + function asArray(value) { + return Object.prototype.toString.call(value) === "[object Array]" ? value : []; + } + + function anchorRef(anchor, label) { + var link = el("a", "anchor-ref", String(anchor)); + link.setAttribute("href", "#anchor-" + String(anchor)); + link.setAttribute("data-anchor-ref", String(anchor)); + link.setAttribute("aria-label", label + String(anchor)); + return link; + } + + function confidenceBadge(level, labels) { + var known = Object.prototype.hasOwnProperty.call(labels.confidence, level) ? level : "low"; + var badge = el("span", "badge badge--" + known, labels.confidence[known]); + badge.setAttribute("data-confidence", known); + return badge; + } + + function sectionShell(spec, index) { + var section = el("section", "section"); + section.id = "section-" + spec.id; + section.setAttribute("data-section", spec.id); + section.setAttribute("data-section-index", String(index + 1)); + section.setAttribute("aria-labelledby", "heading-" + spec.id); + + var head = el("div", "section__head"); + var number = el("span", "section__index", String(index + 1).padStart(2, "0")); + number.setAttribute("aria-hidden", "true"); + var title = el("h2", "section__title", spec.title); + title.id = "heading-" + spec.id; + head.appendChild(number); + head.appendChild(title); + head.appendChild(el("span", "section__count", spec.count)); + section.appendChild(head); + + if (spec.summary) section.appendChild(el("p", "section__summary", spec.summary)); + return section; + } + + function renderClaims(section, spec, view, labels, citations) { + var list = el("ul", "claims"); + spec.items.forEach(function (item) { + var row = el("li", "claim"); + if (item.emphasis === true) row.className = "claim is-emphasis"; + row.setAttribute("data-confidence", item.confidence); + row.appendChild(el("p", "claim__text", item.text)); + var meta = el("ul", "claim__meta"); + meta.appendChild(confidenceBadge(item.confidence, labels)); + asArray(item.anchors).forEach(function (anchor) { + var li = el("li"); + li.appendChild(anchorRef(anchor, labels.anchorLabel)); + meta.appendChild(li); + if (!citations[anchor]) citations[anchor] = []; + citations[anchor].push({ id: spec.id, title: spec.title }); + }); + row.appendChild(meta); + list.appendChild(row); + }); + section.appendChild(list); + } + + function renderWarnings(section, spec, view, labels, citations) { + var list = el("ul", "warnings"); + spec.items.forEach(function (item) { + var row = el("li", "warning"); + row.setAttribute("data-severity", item.severity || "medium"); + row.appendChild(el("p", "warning__text", item.text)); + var meta = el("ul", "warning__meta"); + var severity = Object.prototype.hasOwnProperty.call(labels.severity, item.severity) + ? item.severity + : "medium"; + var badge = el("span", "badge badge--severity", labels.severity[severity]); + badge.setAttribute("data-severity", severity); + meta.appendChild(badge); + meta.appendChild(confidenceBadge(item.confidence, labels)); + asArray(item.anchors).forEach(function (anchor) { + var li = el("li"); + li.appendChild(anchorRef(anchor, labels.anchorLabel)); + meta.appendChild(li); + if (!citations[anchor]) citations[anchor] = []; + citations[anchor].push({ id: spec.id, title: spec.title }); + }); + row.appendChild(meta); + list.appendChild(row); + }); + section.appendChild(list); + } + + function renderTimeline(section, spec, view, labels, citations) { + var list = el("ol", "timeline"); + spec.items.forEach(function (item) { + var row = el("li", "timeline__item"); + row.setAttribute("data-confidence", item.confidence); + var at = el("span", "timeline__at", item.at || "—"); + row.appendChild(at); + row.appendChild(el("span", "timeline__text", item.text)); + var meta = el("ul", "timeline__meta"); + meta.appendChild(confidenceBadge(item.confidence, labels)); + asArray(item.anchors).forEach(function (anchor) { + var li = el("li"); + li.appendChild(anchorRef(anchor, labels.anchorLabel)); + meta.appendChild(li); + if (!citations[anchor]) citations[anchor] = []; + citations[anchor].push({ id: spec.id, title: spec.title }); + }); + row.appendChild(meta); + list.appendChild(row); + }); + section.appendChild(list); + } + + function renderEvidenceItem(entry, labels, citations, shareable) { + var row = el("li", "evidence"); + row.id = "anchor-" + entry.anchor; + row.setAttribute("data-anchor", entry.anchor); + row.setAttribute("tabindex", "-1"); + + var head = el("div", "evidence__head"); + head.appendChild(el("span", "evidence__anchor", entry.anchor)); + head.appendChild(el("span", "badge badge--kind", entry.kind || "source")); + head.appendChild(el("span", "badge", entry.source || "unknown")); + if (entry.at) head.appendChild(el("span", "evidence__at", entry.at)); + row.appendChild(head); + + if (entry.note) row.appendChild(el("p", "evidence__why", entry.note)); + + if (shareable && entry.quote) { + var quote = el("blockquote", "quote", entry.quote); + quote.setAttribute("data-inlined", "true"); + row.appendChild(quote); + row.appendChild(el("p", "quote__note", labels.quote + " · " + (entry.source || "unknown"))); + } + + var where = []; + if (entry.path) where.push(labels.where + ": " + entry.path); + if (entry.id) where.push("id: " + entry.id); + if (entry.sha256) where.push(labels.digest + ": " + String(entry.sha256).slice(0, 12)); + if (where.length > 0) row.appendChild(el("p", "evidence__where", where.join(" · "))); + + var cited = citations[entry.anchor] || []; + if (cited.length > 0) { + var meta = el("ul", "evidence__meta"); + meta.appendChild(el("li", null, labels.citedBy)); + cited.forEach(function (ref) { + var li = el("li"); + var link = el("a", null, ref.title); + link.setAttribute("href", "#section-" + ref.id); + li.appendChild(link); + meta.appendChild(li); + }); + row.appendChild(meta); + } + return row; + } + + function appendixSpec(view, labels) { + return { + id: "evidence", + title: labels.appendixTitle, + summary: labels.appendixNote, + items: asArray(view.evidence) + }; + } + + function renderInto(root, view) { + var labels = labelsFor(view); + root.textContent = ""; + + if (!view) { + var state = el("section", "section", labels.noData); + state.setAttribute("data-section", "empty"); + root.appendChild(state); + return { sections: 0, anchors: 0 }; + } + + var specs = []; + var citations = {}; + asArray(view.sections).forEach(function (raw) { + var id = String(raw && raw.id ? raw.id : ""); + var fallback = DEFAULT_TITLES.filter(function (entry) { return entry.id === id; })[0]; + var items = asArray(raw && raw.items); + specs.push({ + id: id, + kind: String(raw && raw.kind ? raw.kind : "claims"), + title: String(raw && raw.title ? raw.title : (fallback ? fallback.zh : id)), + summary: raw && raw.summary ? String(raw.summary) : "", + items: items, + count: "" + }); + }); + + var appendix = appendixSpec(view, labels); + appendix.count = String(appendix.items.length) + " " + labels.evidence; + specs.push(appendix); + + specs.forEach(function (spec, index) { + if (!spec.count) { + var unit = spec.kind === "timeline" ? labels.timeline + : spec.kind === "warnings" ? labels.warnings : labels.claims; + spec.count = String(spec.items.length) + " " + unit; + } + }); + + var holder = document.createDocumentFragment(); + specs.forEach(function (spec, index) { + var section = sectionShell(spec, index); + if (spec.id === "evidence") { + section.className = "section section--evidence"; + var list = el("ol", "appendix"); + spec.items.forEach(function (entry) { + list.appendChild(renderEvidenceItem(entry, labels, citations, view.shareable === true)); + }); + section.appendChild(list); + } else if (spec.kind === "timeline") { + renderTimeline(section, spec, view, labels, citations); + } else if (spec.kind === "warnings") { + renderWarnings(section, spec, view, labels, citations); + } else { + renderClaims(section, spec, view, labels, citations); + } + holder.appendChild(section); + }); + + root.appendChild(holder); + var count = root.querySelectorAll("[data-section]").length; + var anchors = root.querySelectorAll(".evidence[data-anchor]").length; + return { sections: count, anchors: anchors, citations: citations }; + } + + function renderToc(view, labels) { + var toc = document.getElementById("toc"); + if (!toc) return; + toc.textContent = ""; + if (!view) return; + var heading = el("h2", null, document.documentElement.lang.indexOf("en") === 0 ? "Contents" : "目录"); + var list = el("ol"); + var specs = asArray(view.sections).concat([appendixSpec(view, labels)]); + specs.forEach(function (spec, index) { + var fallback = DEFAULT_TITLES.filter(function (entry) { return entry.id === spec.id; })[0]; + var li = el("li"); + var link = el("a"); + link.setAttribute("href", "#section-" + spec.id); + link.appendChild(el("span", "toc__index", String(index + 1).padStart(2, "0"))); + link.appendChild(document.createTextNode(spec.title || (fallback ? fallback.zh : spec.id))); + li.appendChild(link); + list.appendChild(li); + }); + toc.appendChild(heading); + toc.appendChild(list); + } + + function renderHeader(view, labels) { + var title = document.getElementById("page-title"); + var subtitle = document.getElementById("page-subtitle"); + var meta = document.getElementById("page-meta"); + var note = document.getElementById("privacy-note"); + var footer = document.getElementById("footer-note"); + if (footer) footer.textContent = labels.footer; + + if (!view) { + if (subtitle) subtitle.textContent = labels.noData; + if (note) note.textContent = labels.privacyPrivate; + return; + } + + var meta_ = view.meta || {}; + var displayTitle = meta_.title || meta_.slug || "Person View"; + if (title) title.textContent = displayTitle; + document.title = displayTitle + " · Distilly"; + if (subtitle) { + subtitle.textContent = meta_.subtitle || (meta_.display_name ? meta_.display_name : meta_.slug || ""); + } + if (meta) { + meta.textContent = ""; + var parts = []; + if (meta_.slug) parts.push("slug: " + meta_.slug); + if (meta_.generated_at) parts.push("generated_at: " + meta_.generated_at); + var appendix = appendixSpec(view, labels); + parts.push("anchors: " + appendix.items.length); + meta.textContent = parts.join(" · "); + } + if (note) note.textContent = view.shareable === true ? labels.privacyShareable : labels.privacyPrivate; + } + + function boot() { + var root = document.querySelector(ROOT_SELECTOR); + var view = readView(); + var labels = labelsFor(view); + renderHeader(view, labels); + var result = root ? renderInto(root, view) : { sections: 0, anchors: 0 }; + renderToc(view, labels); + window.DistillyView = { + version: 1, + view: view, + labels: labels, + shareable: Boolean(view && view.shareable === true), + sections: result.sections || 0, + anchors: result.anchors || 0, + citations: result.citations || {} + }; + document.documentElement.setAttribute("data-view-ready", "true"); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot); + } else { + boot(); + } +})(); diff --git a/viewer/theme.js b/viewer/theme.js new file mode 100644 index 00000000..396c7539 --- /dev/null +++ b/viewer/theme.js @@ -0,0 +1,119 @@ +/* Distilly viewer fragment: dual theme (system preference + manual override). + The palette lives in CSS (light-dark()); this file only flips the data-theme + attribute on the root element. */ +(function () { + "use strict"; + + var STORAGE_KEY = "distilly-view-theme"; + var MODES = ["auto", "light", "dark"]; + var LABELS = { + zh: { toDark: "深色模式", toLight: "浅色模式", toSystem: "跟随系统", status: "主题:" }, + en: { toDark: "Dark mode", toLight: "Light mode", toSystem: "Follow system", status: "Theme: " } + }; + + function labels() { + var lang = document.documentElement.getAttribute("lang") || "zh"; + return lang.toLowerCase().indexOf("en") === 0 ? LABELS.en : LABELS.zh; + } + + function stored() { + try { + var value = window.localStorage.getItem(STORAGE_KEY); + return MODES.indexOf(value) === -1 ? null : value; + } catch (error) { + return null; + } + } + + function remember(value) { + try { + window.localStorage.setItem(STORAGE_KEY, value); + } catch (error) { + /* file:// or a locked-down profile: the switch still works for this page view. */ + } + } + + function requested() { + var match = /[?&]theme=(auto|light|dark)(?:&|$)/.exec(window.location.search || ""); + return match ? match[1] : null; + } + + function systemPrefersDark() { + return Boolean(window.matchMedia) && window.matchMedia("(prefers-color-scheme: dark)").matches; + } + + var mode = requested() || stored() || "auto"; + + function effective() { + return mode === "auto" ? (systemPrefersDark() ? "dark" : "light") : mode; + } + + function resetButton() { + var existing = document.getElementById("theme-system"); + if (mode === "auto") { + if (existing && existing.parentNode) existing.parentNode.removeChild(existing); + return; + } + if (existing) return; + var toggle = document.getElementById("theme-toggle"); + if (!toggle || !toggle.parentNode) return; + var text = labels(); + var button = document.createElement("button"); + button.type = "button"; + button.id = "theme-system"; + button.className = "button button--quiet"; + button.textContent = text.toSystem; + button.addEventListener("click", function () { + apply("auto", true); + }); + toggle.parentNode.insertBefore(button, toggle.nextSibling); + } + + function apply(next, announce) { + mode = MODES.indexOf(next) === -1 ? "auto" : next; + var active = effective(); + document.documentElement.setAttribute("data-theme", mode); + document.documentElement.setAttribute("data-theme-effective", active); + remember(mode); + + var text = labels(); + var toggle = document.getElementById("theme-toggle"); + if (toggle) { + var action = active === "dark" ? text.toLight : text.toDark; + toggle.textContent = action; + toggle.setAttribute("aria-pressed", active === "dark" ? "true" : "false"); + toggle.setAttribute("aria-label", action); + } + resetButton(); + if (announce) { + var status = document.getElementById("action-status"); + if (status) status.textContent = text.status + active + (mode === "auto" ? " (" + text.toSystem + ")" : ""); + } + document.documentElement.setAttribute("data-theme-ready", "true"); + } + + function boot() { + apply(mode, false); + var toggle = document.getElementById("theme-toggle"); + if (toggle) { + toggle.addEventListener("click", function () { + apply(effective() === "dark" ? "light" : "dark", true); + }); + } + if (window.matchMedia) { + var query = window.matchMedia("(prefers-color-scheme: dark)"); + var onChange = function () { + if (mode === "auto") apply("auto", false); + }; + if (typeof query.addEventListener === "function") query.addEventListener("change", onChange); + else if (typeof query.addListener === "function") query.addListener(onChange); + } + document.documentElement.setAttribute("data-theme-bound", "true"); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot); + } else { + boot(); + } +})();