@kamishibai/sdk 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +192 -0
- package/package.json +54 -0
- package/src/blocks/board.js +210 -0
- package/src/blocks/callout.js +63 -0
- package/src/blocks/code.js +28 -0
- package/src/blocks/deck.js +76 -0
- package/src/blocks/diagram.js +265 -0
- package/src/blocks/element.js +51 -0
- package/src/blocks/graph.js +264 -0
- package/src/blocks/grid.js +156 -0
- package/src/blocks/index.js +106 -0
- package/src/blocks/list.js +50 -0
- package/src/blocks/placement.js +119 -0
- package/src/blocks/prose.js +28 -0
- package/src/blocks/quote.js +25 -0
- package/src/blocks/raw.js +47 -0
- package/src/blocks/registry.js +158 -0
- package/src/blocks/schema-parts.js +19 -0
- package/src/blocks/section.js +53 -0
- package/src/blocks/slide.js +104 -0
- package/src/blocks/stat.js +83 -0
- package/src/blocks/table.js +50 -0
- package/src/blocks/timeline.js +80 -0
- package/src/cli/commands/close.js +51 -0
- package/src/cli/commands/comments.js +73 -0
- package/src/cli/commands/debug.js +34 -0
- package/src/cli/commands/example.js +22 -0
- package/src/cli/commands/export.js +10 -0
- package/src/cli/commands/init.js +53 -0
- package/src/cli/commands/lint.js +93 -0
- package/src/cli/commands/list.js +54 -0
- package/src/cli/commands/open.js +31 -0
- package/src/cli/commands/promote.js +38 -0
- package/src/cli/commands/render.js +31 -0
- package/src/cli/commands/replay.js +49 -0
- package/src/cli/commands/schema.js +10 -0
- package/src/cli/commands/serve.js +178 -0
- package/src/cli/commands/setup.js +64 -0
- package/src/cli/commands/snapshot.js +29 -0
- package/src/cli/commands/templates.js +75 -0
- package/src/cli/deliver.js +54 -0
- package/src/cli/emit.js +29 -0
- package/src/cli/format.js +153 -0
- package/src/cli/index.js +365 -0
- package/src/cli/registry.js +18 -0
- package/src/core/blocks.js +147 -0
- package/src/core/diagram.js +282 -0
- package/src/core/errors.js +156 -0
- package/src/core/example.js +109 -0
- package/src/core/ir.js +62 -0
- package/src/core/lint-gates.js +427 -0
- package/src/core/lint.js +281 -0
- package/src/core/scan.js +84 -0
- package/src/core/schema.js +88 -0
- package/src/core/spec-check.js +33 -0
- package/src/core/validate.js +44 -0
- package/src/core/version.js +18 -0
- package/src/core/vocabulary.js +140 -0
- package/src/delivery/atomic.js +71 -0
- package/src/delivery/comments.js +180 -0
- package/src/delivery/home.js +70 -0
- package/src/delivery/open.js +31 -0
- package/src/delivery/project.js +141 -0
- package/src/delivery/read.js +109 -0
- package/src/delivery/run.js +95 -0
- package/src/delivery/scaffold-blueprints.js +728 -0
- package/src/delivery/store.js +219 -0
- package/src/delivery/template-extensions.js +183 -0
- package/src/delivery/template-format.js +112 -0
- package/src/delivery/template-package.js +376 -0
- package/src/delivery/template-promote.js +240 -0
- package/src/delivery/template-scaffold.js +181 -0
- package/src/delivery/templates.js +192 -0
- package/src/delivery/toml.js +195 -0
- package/src/delivery/write.js +35 -0
- package/src/export/browser.js +130 -0
- package/src/export/index.js +96 -0
- package/src/export/pdf.js +25 -0
- package/src/export/png.js +40 -0
- package/src/export/pptx.js +48 -0
- package/src/export/slides.js +33 -0
- package/src/export/snapshot.js +33 -0
- package/src/layouts/article.js +103 -0
- package/src/layouts/canvas.js +144 -0
- package/src/layouts/card.js +128 -0
- package/src/layouts/deck.js +88 -0
- package/src/layouts/index.js +90 -0
- package/src/layouts/one-page.js +161 -0
- package/src/layouts/registry.js +251 -0
- package/src/layouts/resume.js +172 -0
- package/src/layouts/template-index.js +78 -0
- package/src/parser/artifact.js +38 -0
- package/src/parser/container.js +103 -0
- package/src/parser/index.js +223 -0
- package/src/parser/tokens.js +265 -0
- package/src/render/board-filter.client.js +80 -0
- package/src/render/compile.js +29 -0
- package/src/render/context.js +98 -0
- package/src/render/element.js +32 -0
- package/src/render/fonts.js +129 -0
- package/src/render/graph-hover.client.js +148 -0
- package/src/render/html.js +52 -0
- package/src/render/index.js +241 -0
- package/src/render/measure.js +60 -0
- package/src/render/placement.js +136 -0
- package/src/render/playback.client.js +74 -0
- package/src/render/scale-to-fit.client.js +136 -0
- package/src/render/scale.js +41 -0
- package/src/render/skeleton.js +131 -0
- package/src/render/ssr.js +24 -0
- package/src/render/styles.js +56 -0
- package/src/render/templates.js +191 -0
- package/src/serve/daemon.js +117 -0
- package/src/serve/overlay.js +213 -0
- package/src/serve/protocol.js +36 -0
- package/src/serve/server.js +264 -0
- package/templates/kami/cards/components.js +40 -0
- package/templates/kami/cards/index.js +25 -0
- package/templates/kami/cards/manifest.js +67 -0
- package/templates/kami/cards/styles.css +389 -0
- package/templates/kami/long-form/components.js +102 -0
- package/templates/kami/long-form/index.js +25 -0
- package/templates/kami/long-form/manifest.js +87 -0
- package/templates/kami/long-form/styles.css +481 -0
- package/templates/kami/one-page/components.js +48 -0
- package/templates/kami/one-page/index.js +27 -0
- package/templates/kami/one-page/manifest.js +65 -0
- package/templates/kami/one-page/styles.css +375 -0
- package/templates/kami/resume/components.js +51 -0
- package/templates/kami/resume/index.js +27 -0
- package/templates/kami/resume/manifest.js +65 -0
- package/templates/kami/resume/styles.css +424 -0
- package/templates/kami/slides/components.js +41 -0
- package/templates/kami/slides/index.js +26 -0
- package/templates/kami/slides/manifest.js +64 -0
- package/templates/kami/slides/styles.css +406 -0
package/README.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# kamishibai(紙芝居)
|
|
2
|
+
|
|
3
|
+
**Agent Presentation SDK** — Agent 著作結構化內容,SDK 確定性地渲染成可單獨生存的離線單檔產物。
|
|
4
|
+
|
|
5
|
+
> *An agent authors structured content; the SDK deterministically renders it into a self-contained offline artifact.*
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 這是什麼
|
|
10
|
+
|
|
11
|
+
讓 LLM agent 直接手寫 HTML 是昂貴且不可驗收的:它得為每一份文件重新產出一套 CSS、手算 SVG 座標,
|
|
12
|
+
而產物沒有任何機器可檢查的品質閘。
|
|
13
|
+
|
|
14
|
+
kamishibai 把這件事切開:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
agent 寫 Markdown 超集 → SDK 解析成 block tree IR → 確定性渲染成離線單檔 HTML
|
|
18
|
+
↘ lint 驗收(exit 0 才算數)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**Agent 負責內容,SDK 負責版面、遞送與持久化。** 渲染過程沒有模型參與,所以它是確定性的、
|
|
22
|
+
可重播的、可驗收的。
|
|
23
|
+
|
|
24
|
+
### 實測差異
|
|
25
|
+
|
|
26
|
+
同一份繁中技術文件(12 KB)、同一交付目標,兩個同型號 agent 分別走兩條路:
|
|
27
|
+
|
|
28
|
+
| | 走 kamishibai | 手寫 HTML |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| **agent 產出的 token** | **8,557** | **21,465**(2.51×) |
|
|
31
|
+
| 耗時 | 184 s | 371 s(2.02×) |
|
|
32
|
+
| 品質閘 | `lint` exit 0,首發即過 | 無 |
|
|
33
|
+
|
|
34
|
+
手寫路徑的 token 有 **40%** 花在 CSS 設計系統與手算 SVG 座標上——**而那是每份新文件都要重付的**。
|
|
35
|
+
|
|
36
|
+
量測方法與完整數字:[`.claude/research/2026-08-19-perf-token-benchmark.md`](.claude/research/2026-08-19-perf-token-benchmark.md)
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 快速開始
|
|
41
|
+
|
|
42
|
+
需要 Node.js(ESM)。
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
git clone https://github.com/TLOGBen/kamishibai
|
|
46
|
+
cd kamishibai
|
|
47
|
+
npm install
|
|
48
|
+
|
|
49
|
+
node ./src/cli/index.js setup --json # 建中央儲存庫、確認渲染用瀏覽器
|
|
50
|
+
node ./src/cli/index.js example doc > demo.md # 要一份合法範例
|
|
51
|
+
node ./src/cli/index.js render demo.md -o demo.html --json # 渲染
|
|
52
|
+
node ./src/cli/index.js lint demo.html --json # 驗收(exit 0 才算數)
|
|
53
|
+
node ./src/cli/index.js snapshot demo.html -o demo.png --json # 自己看一眼
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
你會得到 `demo.html`(離線單檔、內嵌 IR)、正典副本 `~/.kamishibai/artifacts/<專案>/demo.html`,
|
|
57
|
+
以及一張 PNG。
|
|
58
|
+
|
|
59
|
+
> 用 nvm 管 node 的話,非互動 shell 讀不到 PATH——每一行包 `zsh -lic '...'`。
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## 產物長什麼樣
|
|
64
|
+
|
|
65
|
+
- **單檔、離線、零外部請求** — 字體以 unicode-range 子集化後 base64 內嵌;`lint` 會擋下任何外部引用
|
|
66
|
+
- **內嵌 IR** — 產物裡帶著自己的 block tree(`<script type="application/kamishibai+json">`),
|
|
67
|
+
所以 `replay` 可以只憑產物換模板、升版、換皮
|
|
68
|
+
- **確定性** — 同一份 IR 永遠渲染出同樣的位元組;圖表版面是純整數運算,不量測文字、不用亂數
|
|
69
|
+
|
|
70
|
+
實測:`example doc`(1.7 KB 來源)→ 2.34 MB HTML,熱身後 **18 ms**;
|
|
71
|
+
31 KB 來源 → 3.22 MB HTML,**34 ms**。產物大小主要是內嵌字體的常數項(94–97%)。
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## 著作格式
|
|
76
|
+
|
|
77
|
+
帶 frontmatter 的 Markdown 超集:
|
|
78
|
+
|
|
79
|
+
````markdown
|
|
80
|
+
---
|
|
81
|
+
title: 範例文件
|
|
82
|
+
kicker: KAMISHIBAI EXAMPLE
|
|
83
|
+
template: kami/long-form
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
# 第一章
|
|
87
|
+
|
|
88
|
+
這是 **prose** 區塊,支援行內 Markdown。
|
|
89
|
+
|
|
90
|
+
:::note
|
|
91
|
+
這是 callout。另有 `:::warn`。
|
|
92
|
+
:::
|
|
93
|
+
|
|
94
|
+
```diagram
|
|
95
|
+
{
|
|
96
|
+
"kind": "graph",
|
|
97
|
+
"nodes": [{ "id": "ir", "label": "block tree" }, { "id": "out", "label": "離線產物" }],
|
|
98
|
+
"edges": [{ "from": "ir", "to": "out", "label": "render" }]
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
````
|
|
102
|
+
|
|
103
|
+
`diagram` fence 裡是**結構化 spec,不是座標**——版面由 SDK 算,agent 不必手畫。
|
|
104
|
+
|
|
105
|
+
也可以直接餵 block tree JSON。`kamishibai schema` 會吐出 IR 的 JSON Schema(draft 2020-12),
|
|
106
|
+
`kamishibai example <kind>` 會吐出任一 block 的合法範例。
|
|
107
|
+
|
|
108
|
+
**核心 block 型別**(11 種):`section` `prose` `quote` `callout` `code` `table` `raw` `list`
|
|
109
|
+
`deck` `slide` `diagram`
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## CLI
|
|
114
|
+
|
|
115
|
+
| 指令 | 用途 |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `render <input>` | Markdown 超集/block JSON → 離線單檔 HTML |
|
|
118
|
+
| `lint <artifact>` | 驗收:零外部請求、內嵌 IR 齊備且通過 schema |
|
|
119
|
+
| `replay <artifact>` | 由產物內嵌 IR 重繪(換模板/升版/換皮) |
|
|
120
|
+
| `serve <input>` | 本地預覽,來源檔一變就重繪推播 reload |
|
|
121
|
+
| `close` | 終止本 SDK 起的預覽伺服器 |
|
|
122
|
+
| `export <artifact>` | document → PDF、deck → PPTX |
|
|
123
|
+
| `snapshot <artifact>` | 截成 PNG,讓 agent(與人)看得到視覺效果 |
|
|
124
|
+
| `comments <args...>` | 讀寫產物留言,以 block id 錨定 |
|
|
125
|
+
| `open <name>` / `list` | 由中央產物庫解析並開啟/列出呈現史 |
|
|
126
|
+
| `templates` | 列出中央儲存庫已註冊的模板包 |
|
|
127
|
+
| `example [kind]` / `schema` | 輸出合法範例/IR JSON Schema |
|
|
128
|
+
| `setup` / `debug` | 初始化環境/診斷 |
|
|
129
|
+
|
|
130
|
+
所有指令支援 `--json`,供 agent 直接解析。
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## 出廠模板
|
|
135
|
+
|
|
136
|
+
| key | root | 用途 |
|
|
137
|
+
|---|---|---|
|
|
138
|
+
| `kami/long-form` | document | 長文件、報告 |
|
|
139
|
+
| `kami/slides` | deck | 簡報(鍵盤翻頁、fullscreen、無 JS 時降級為捲動長文) |
|
|
140
|
+
|
|
141
|
+
模板決定 `---` 是不是分頁、決定產物長相;同一份 IR 換模板即換皮。
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## 中央儲存庫
|
|
146
|
+
|
|
147
|
+
`~/.kamishibai/` 是真實落點——每次 `render` 都會存一份正典副本,
|
|
148
|
+
所以「上次那份簡報」永遠找得回來、重繪得出來。
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
~/.kamishibai/
|
|
152
|
+
├── artifacts/<專案>/<slug>.html # 正典產物(與遞送副本位元組相同)
|
|
153
|
+
└── templates/<namespace>/<name>/ # 模板包命名空間
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## 專案狀態
|
|
159
|
+
|
|
160
|
+
**v0.1.0 — 可用,但仍在成形中。** 已封緘的切片:渲染/IR/lint、持久化/replay、
|
|
161
|
+
圖表/export/snapshot、serve/comment loop、模板命名空間。
|
|
162
|
+
|
|
163
|
+
已知未完成(見報告 §5–§6):
|
|
164
|
+
|
|
165
|
+
- 中央儲存庫的模板命名空間目前**只是登記簿,還不是載入點**——第三方模板列得出來、還載不進去
|
|
166
|
+
- **slot/plugin 擴充層尚未實作**(`SPEC.md` §11–§12 已定義機制)
|
|
167
|
+
- diagram v1 只有一種 kind(`graph`),節點語意色/群組框/虛線邊等待 plugin 層
|
|
168
|
+
- 沒有版面座標系;自訂版面目前只能走 `raw-html` 逃生艙
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## 文件
|
|
173
|
+
|
|
174
|
+
| 檔案 | 給誰看 |
|
|
175
|
+
|---|---|
|
|
176
|
+
| [`SPEC.md`](SPEC.md) | 實作用規格書 |
|
|
177
|
+
| [`AGENTS.md`](AGENTS.md) | 在本 repo 內開發 SDK 的 agent |
|
|
178
|
+
| [`.claude/wayfinder/kamishibai-sdk/`](.claude/wayfinder/kamishibai-sdk/) | 決策地圖與 12 張票面(**規格真源**) |
|
|
179
|
+
| [`.claude/research/`](.claude/research/) | 查證與量測報告 |
|
|
180
|
+
|
|
181
|
+
規格衝突時,以票面的 `## Answer` 為準。
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## License
|
|
186
|
+
|
|
187
|
+
**尚未授權(`UNLICENSED`)——保留所有權利。**
|
|
188
|
+
|
|
189
|
+
本專案仍在成形中,v1.0 之前不附授權條款:你可以閱讀原始碼,但未獲授權使用、修改或再散布。
|
|
190
|
+
|
|
191
|
+
`issues/09-public-packaging.md` 已定案**最終採 MIT**,但要等實作收斂、第三方資產的授權盤點完成(見 `.claude/research/tsanger-jinkai02-license.md`)之後才生效。
|
|
192
|
+
屆時會補上 `LICENSE` 與 `THIRD_PARTY_LICENSES`——出廠字體 Noto Serif TC 走 SIL OFL 1.1,須保留自身授權全文,**不得併入 MIT 宣告**。
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kamishibai/sdk",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Agent Presentation SDK — agents author a structured IR; the SDK deterministically renders self-contained offline single-file HTML artifacts (five genres, template packages, replay/resk in, lint gates).",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"directories": {
|
|
7
|
+
"test": "tests"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "vitest run"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/TLOGBen/kamishibai.git"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"agent",
|
|
18
|
+
"presentation",
|
|
19
|
+
"sdk",
|
|
20
|
+
"html",
|
|
21
|
+
"renderer",
|
|
22
|
+
"claude",
|
|
23
|
+
"ir",
|
|
24
|
+
"offline",
|
|
25
|
+
"single-file"
|
|
26
|
+
],
|
|
27
|
+
"author": "",
|
|
28
|
+
"license": "UNLICENSED",
|
|
29
|
+
"type": "module",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/TLOGBen/kamishibai/issues"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/TLOGBen/kamishibai#readme",
|
|
34
|
+
"bin": {
|
|
35
|
+
"kamishibai": "src/cli/index.js"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@fontsource/noto-serif-tc": "^5.3.0",
|
|
39
|
+
"ajv": "^8.20.0",
|
|
40
|
+
"commander": "^15.0.0",
|
|
41
|
+
"gray-matter": "^4.0.3",
|
|
42
|
+
"markdown-it": "^15.0.0",
|
|
43
|
+
"playwright": "^1.62.1",
|
|
44
|
+
"pptxgenjs": "^4.0.1",
|
|
45
|
+
"vue": "^3.5.41"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"vitest": "^4.1.10"
|
|
49
|
+
},
|
|
50
|
+
"files": [
|
|
51
|
+
"src",
|
|
52
|
+
"templates"
|
|
53
|
+
]
|
|
54
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { el } from './element.js'
|
|
2
|
+
import { str } from './schema-parts.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `board` — 卡片看板 (wayfinder issue 11 P1「資料」; 篩選互動 issue 11 P5).
|
|
6
|
+
*
|
|
7
|
+
* Shaped like `diagram`, not like a nest: lanes and cards are two flat arrays,
|
|
8
|
+
* and a card names its lane by id. That is the one field this slice added
|
|
9
|
+
* beyond the narrowest possible schema, so it owes an argument.
|
|
10
|
+
*
|
|
11
|
+
* ## Why `card.lane` is allowed in under the narrow rule
|
|
12
|
+
*
|
|
13
|
+
* 13 號票 裁決 5 keeps the core narrow by sending *semantic decoration* to a
|
|
14
|
+
* plugin kind — 副標, variant, 群組, and the battle-map palette (狀態色, effort)
|
|
15
|
+
* are its named examples. `lane` is none of those: without it there is no board
|
|
16
|
+
* at all, only a pile of cards. It is the same class of field as `edge.from` in
|
|
17
|
+
* a graph — a **referential** field that constitutes the structure, not one
|
|
18
|
+
* that describes it. The alternative shape (lanes each carrying their own card
|
|
19
|
+
* array) needs no reference field, but buries the cards inside an array of
|
|
20
|
+
* objects, where the id-assigning walk cannot reach them: every card would
|
|
21
|
+
* render with no id and therefore no block path (the same wall `timeline`
|
|
22
|
+
* documents). So the reference is the shape that keeps every card addressable.
|
|
23
|
+
*
|
|
24
|
+
* Everything else stays out. A card is `{lane, title}` and nothing more: no
|
|
25
|
+
* status colour, no assignee, no effort, no order key, no tags. The battle-map
|
|
26
|
+
* client that wants those gets them as an `x-*` plugin kind or as template
|
|
27
|
+
* config, which is 13 號票 裁決 5 read literally.
|
|
28
|
+
*
|
|
29
|
+
* ## The filter (issue 11 P5)
|
|
30
|
+
*
|
|
31
|
+
* The buttons are drawn here, server-side, so the lane vocabulary is visible in
|
|
32
|
+
* the artifact whether or not the script runs. Each button is labelled with its
|
|
33
|
+
* *lane's own label* — the SDK writes no user-facing word of its own, which is
|
|
34
|
+
* the rule `callout` had to be rescued from (CONTRACT A7). There is deliberately
|
|
35
|
+
* no 「all」 button for the same reason: pressing an active lane again releases
|
|
36
|
+
* it, so returning to the full board needs no word from anybody.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** A column of the board. `id` is what a card points at, so it may not be empty. */
|
|
40
|
+
const boardLane = Object.freeze({
|
|
41
|
+
type: 'object',
|
|
42
|
+
required: ['id', 'label'],
|
|
43
|
+
properties: { id: { ...str, minLength: 1 }, label: str },
|
|
44
|
+
additionalProperties: false,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/** One card. `lane` is a reference, not a description — see the header. */
|
|
48
|
+
const boardCard = Object.freeze({
|
|
49
|
+
type: 'object',
|
|
50
|
+
required: ['lane', 'title'],
|
|
51
|
+
properties: { lane: { ...str, minLength: 1 }, title: str },
|
|
52
|
+
additionalProperties: false,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const isNonEmptyString = (value) => typeof value === 'string' && value.length > 0
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Every problem with one board block, as human-readable messages.
|
|
59
|
+
*
|
|
60
|
+
* A card pointing at a lane nobody declared is the failure that matters, and no
|
|
61
|
+
* JSON Schema keyword can express it: the card is well-formed, the lane list is
|
|
62
|
+
* well-formed, and the reference between them is the only thing that is wrong.
|
|
63
|
+
* Drawing anyway would put the card nowhere — it would simply not appear, which
|
|
64
|
+
* is exactly the silent data loss `manifest.blocks` and `assertBlockSpecs` both
|
|
65
|
+
* exist to refuse.
|
|
66
|
+
*/
|
|
67
|
+
export function boardProblems(block) {
|
|
68
|
+
const problems = []
|
|
69
|
+
|
|
70
|
+
const lanes = block?.lanes
|
|
71
|
+
if (!Array.isArray(lanes) || lanes.length === 0) {
|
|
72
|
+
problems.push('board.lanes 必須是至少一個欄位的陣列')
|
|
73
|
+
return problems
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const ids = new Set()
|
|
77
|
+
for (const [index, lane] of lanes.entries()) {
|
|
78
|
+
if (!isNonEmptyString(lane?.id)) {
|
|
79
|
+
problems.push(`lanes[${index}].id 必須是非空字串`)
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
if (ids.has(lane.id)) {
|
|
83
|
+
problems.push(`lanes[${index}].id "${lane.id}" 重複;欄位 id 必須唯一`)
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
ids.add(lane.id)
|
|
87
|
+
if (typeof lane.label !== 'string') {
|
|
88
|
+
problems.push(`lanes[${index}].label(id "${lane.id}")必須是字串`)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const cards = block?.cards
|
|
93
|
+
if (!Array.isArray(cards)) {
|
|
94
|
+
problems.push('board.cards 必須是陣列(沒有卡片就給空陣列)')
|
|
95
|
+
return problems
|
|
96
|
+
}
|
|
97
|
+
for (const [index, card] of cards.entries()) {
|
|
98
|
+
if (!isNonEmptyString(card?.lane)) {
|
|
99
|
+
problems.push(`cards[${index}].lane 必須是非空字串`)
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
if (!ids.has(card.lane)) {
|
|
103
|
+
problems.push(
|
|
104
|
+
`cards[${index}].lane 指向不存在的欄位 "${card.lane}";` +
|
|
105
|
+
`已宣告的欄位:${[...ids].join(', ')}`,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return problems
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The cards belonging to one lane, in document order. */
|
|
113
|
+
const cardsOf = (cards, laneId) => cards.filter((card) => card?.lane === laneId)
|
|
114
|
+
|
|
115
|
+
export default Object.freeze({
|
|
116
|
+
type: 'board',
|
|
117
|
+
|
|
118
|
+
schema: Object.freeze({
|
|
119
|
+
required: ['lanes', 'cards'],
|
|
120
|
+
properties: {
|
|
121
|
+
lanes: { type: 'array', minItems: 1, items: boardLane },
|
|
122
|
+
cards: { type: 'array', items: boardCard },
|
|
123
|
+
},
|
|
124
|
+
}),
|
|
125
|
+
|
|
126
|
+
example: Object.freeze({
|
|
127
|
+
block: {
|
|
128
|
+
id: 'b24',
|
|
129
|
+
type: 'board',
|
|
130
|
+
lanes: [
|
|
131
|
+
{ id: 'todo', label: '待辦' },
|
|
132
|
+
{ id: 'doing', label: '進行中' },
|
|
133
|
+
{ id: 'done', label: '已完成' },
|
|
134
|
+
],
|
|
135
|
+
cards: [
|
|
136
|
+
{ lane: 'todo', title: 'figure/video 兩型落地' },
|
|
137
|
+
{ lane: 'doing', title: '四型資料 block 落地' },
|
|
138
|
+
{ lane: 'done', title: '五文體終局' },
|
|
139
|
+
{ lane: 'done', title: '轉子接口與模組註冊表' },
|
|
140
|
+
],
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
|
|
144
|
+
styleHooks: Object.freeze([
|
|
145
|
+
'board',
|
|
146
|
+
'board-filters',
|
|
147
|
+
'board-filter',
|
|
148
|
+
'board-lanes',
|
|
149
|
+
'board-lane',
|
|
150
|
+
'board-lane-label',
|
|
151
|
+
'board-card',
|
|
152
|
+
]),
|
|
153
|
+
|
|
154
|
+
create: ({ lanes, cards }) => Object.freeze({ type: 'board', lanes, cards }),
|
|
155
|
+
|
|
156
|
+
validate: boardProblems,
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* How a failed `validate` is reported. Plain data because this layer imports
|
|
160
|
+
* nothing — the core turns it into a `KsbError` with the block path attached
|
|
161
|
+
* (`test_f1_validate_codes_match_core` pins the spelling against `CODES`).
|
|
162
|
+
*/
|
|
163
|
+
invalid: Object.freeze({
|
|
164
|
+
code: 'KSB_BOARD_INVALID',
|
|
165
|
+
message: (problems) =>
|
|
166
|
+
`board block 無法繪製:${problems.join(';')}。` +
|
|
167
|
+
'指向不存在欄位的卡片不會報錯、只會不出現——那正是看板最看不出來的資料遺失。',
|
|
168
|
+
}),
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The filter's DOM contract, stated here because the client asset reads it:
|
|
172
|
+
* `data-board` scopes one board against every other board on the page, and
|
|
173
|
+
* `data-board-lane` is carried by the button, the lane and every card, so the
|
|
174
|
+
* script never has to know how a lane relates to its cards.
|
|
175
|
+
*/
|
|
176
|
+
render: (block) => {
|
|
177
|
+
const lanes = block.lanes ?? []
|
|
178
|
+
const cards = block.cards ?? []
|
|
179
|
+
return el('section', { class: 'board', 'data-board': block.id }, [
|
|
180
|
+
el(
|
|
181
|
+
'div',
|
|
182
|
+
{ class: 'board-filters' },
|
|
183
|
+
lanes.map((lane) =>
|
|
184
|
+
el(
|
|
185
|
+
'button',
|
|
186
|
+
{
|
|
187
|
+
type: 'button',
|
|
188
|
+
class: 'board-filter',
|
|
189
|
+
'data-board-lane': lane.id,
|
|
190
|
+
'aria-pressed': 'false',
|
|
191
|
+
},
|
|
192
|
+
[lane.label],
|
|
193
|
+
),
|
|
194
|
+
),
|
|
195
|
+
),
|
|
196
|
+
el(
|
|
197
|
+
'div',
|
|
198
|
+
{ class: 'board-lanes' },
|
|
199
|
+
lanes.map((lane) =>
|
|
200
|
+
el('div', { class: 'board-lane', 'data-board-lane': lane.id }, [
|
|
201
|
+
el('div', { class: 'board-lane-label' }, [lane.label]),
|
|
202
|
+
...cardsOf(cards, lane.id).map((card) =>
|
|
203
|
+
el('article', { class: 'board-card', 'data-board-lane': lane.id }, [card.title]),
|
|
204
|
+
),
|
|
205
|
+
]),
|
|
206
|
+
),
|
|
207
|
+
),
|
|
208
|
+
])
|
|
209
|
+
},
|
|
210
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { el } from './element.js'
|
|
2
|
+
import { children } from './schema-parts.js'
|
|
3
|
+
|
|
4
|
+
/** The variants the parser recognises after `:::`. */
|
|
5
|
+
export const CALLOUT_VARIANTS = Object.freeze(['note', 'warn'])
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `callout` — an aside with a label.
|
|
9
|
+
*
|
|
10
|
+
* The label *text* is deliberately absent from this module. It used to live in
|
|
11
|
+
* the shared conversion path as a table of user-visible English words, which
|
|
12
|
+
* meant a template that wanted 「注意」, an icon, or no label at all could only
|
|
13
|
+
* fork the renderer. Wording is decoration, decoration belongs to the template,
|
|
14
|
+
* so the label arrives through the template's own `blockConfig.callout.labels`
|
|
15
|
+
* (CONTRACT A7). A variant the template did not name gets no label — silence,
|
|
16
|
+
* rather than someone else's word: falling back to a *sibling* variant's label
|
|
17
|
+
* would put 「NOTE」 on a warning, which is worse than an unlabelled box and
|
|
18
|
+
* invisible to every test that only renders the variants a template declared.
|
|
19
|
+
*/
|
|
20
|
+
export default Object.freeze({
|
|
21
|
+
type: 'callout',
|
|
22
|
+
|
|
23
|
+
schema: Object.freeze({
|
|
24
|
+
required: ['variant', 'children'],
|
|
25
|
+
properties: { variant: { enum: [...CALLOUT_VARIANTS] }, children },
|
|
26
|
+
}),
|
|
27
|
+
|
|
28
|
+
example: Object.freeze({
|
|
29
|
+
block: {
|
|
30
|
+
id: 'b6',
|
|
31
|
+
type: 'callout',
|
|
32
|
+
variant: 'note',
|
|
33
|
+
children: [{ id: 'b7', type: 'prose', html: '提示內容。' }],
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
|
|
37
|
+
nesting: Object.freeze({ arrays: Object.freeze(['children']) }),
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The `:::note` / `:::warn` fences, declared rather than hard-coded in the
|
|
41
|
+
* parser. `parser/container.js` used to import `CALLOUT_VARIANTS` directly,
|
|
42
|
+
* which made the Markdown superset the one surface a third-party block still
|
|
43
|
+
* could not reach (wayfinder issue 13 的十一檔問題最後一處).
|
|
44
|
+
*/
|
|
45
|
+
syntax: Object.freeze({
|
|
46
|
+
containers: CALLOUT_VARIANTS,
|
|
47
|
+
build: ({ name, children: kids }) => ({ type: 'callout', variant: name, children: kids }),
|
|
48
|
+
}),
|
|
49
|
+
|
|
50
|
+
styleHooks: Object.freeze(['callout', 'callout-note', 'callout-warn', 'callout-label']),
|
|
51
|
+
|
|
52
|
+
create: ({ variant, children: kids }) =>
|
|
53
|
+
Object.freeze({ type: 'callout', variant, children: kids }),
|
|
54
|
+
|
|
55
|
+
render: (block, ctx) => {
|
|
56
|
+
const labels = ctx.config('callout').labels ?? {}
|
|
57
|
+
const label = labels[block.variant] ?? ''
|
|
58
|
+
return el('aside', { class: `callout callout-${block.variant}` }, [
|
|
59
|
+
el('span', { class: 'callout-label' }, [label]),
|
|
60
|
+
...ctx.renderChildren(block.children),
|
|
61
|
+
])
|
|
62
|
+
},
|
|
63
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { el } from './element.js'
|
|
2
|
+
import { str } from './schema-parts.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `code` — a fenced block, rendered verbatim.
|
|
6
|
+
*
|
|
7
|
+
* The `language-*` class is the highlight.js/Prism convention, emitted only
|
|
8
|
+
* when the fence declared a language: an empty `language-` class would claim a
|
|
9
|
+
* language that is not there.
|
|
10
|
+
*/
|
|
11
|
+
export default Object.freeze({
|
|
12
|
+
type: 'code',
|
|
13
|
+
|
|
14
|
+
schema: Object.freeze({ required: ['lang', 'text'], properties: { lang: str, text: str } }),
|
|
15
|
+
|
|
16
|
+
example: Object.freeze({
|
|
17
|
+
block: { id: 'b8', type: 'code', lang: 'js', text: 'export const x = 1' },
|
|
18
|
+
}),
|
|
19
|
+
|
|
20
|
+
styleHooks: Object.freeze(['code', 'language-*']),
|
|
21
|
+
|
|
22
|
+
create: ({ lang, text }) => Object.freeze({ type: 'code', lang, text }),
|
|
23
|
+
|
|
24
|
+
render: (block) =>
|
|
25
|
+
el('pre', { class: 'code' }, [
|
|
26
|
+
el('code', { class: block.lang ? `language-${block.lang}` : null }, [block.text]),
|
|
27
|
+
]),
|
|
28
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { el } from './element.js'
|
|
2
|
+
import { children } from './schema-parts.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The canonical deck source. `---` is a page break *only* under the slides
|
|
6
|
+
* template, so the frontmatter is load-bearing — copying the body under
|
|
7
|
+
* `kami/long-form` gives a document with the breaks silently dropped.
|
|
8
|
+
*/
|
|
9
|
+
export const EXAMPLE_DECK = `---
|
|
10
|
+
title: 範例簡報
|
|
11
|
+
kicker: KAMISHIBAI EXAMPLE
|
|
12
|
+
author: kamishibai
|
|
13
|
+
template: kami/slides
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# 第一張 開場
|
|
17
|
+
|
|
18
|
+
首張自動帶出 frontmatter 的 title 與 kicker。
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
# 第二張 構件
|
|
23
|
+
|
|
24
|
+
- 清單、callout、程式碼、表格都與長文模板同源
|
|
25
|
+
- \`---\` 前後各留一個空行,否則 Markdown 會把它讀成 setext 標題
|
|
26
|
+
|
|
27
|
+
:::note
|
|
28
|
+
產物內建播放:方向鍵/空白鍵翻頁,f 全螢幕,Esc 離開。
|
|
29
|
+
:::
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
# 第三張 收束
|
|
34
|
+
|
|
35
|
+
\`\`\`js
|
|
36
|
+
export const slides = (deck) => deck.slides.length
|
|
37
|
+
\`\`\`
|
|
38
|
+
`
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `deck` — the root container of a slide artifact.
|
|
42
|
+
*
|
|
43
|
+
* It draws the frame and nothing else. The progress readout and the keyboard
|
|
44
|
+
* hint are wording, so they arrive through the `deck-chrome` slot the template
|
|
45
|
+
* fills; the playback behaviour is skeleton, so it arrives from the render
|
|
46
|
+
* layer (wayfinder issue 13 P3) rather than from either of them.
|
|
47
|
+
*
|
|
48
|
+
* `data-template` sits here because this element *is* the artifact's outermost
|
|
49
|
+
* container under a deck-rooted template — the same place `kami/long-form`
|
|
50
|
+
* puts it on `article.paper`.
|
|
51
|
+
*/
|
|
52
|
+
export default Object.freeze({
|
|
53
|
+
type: 'deck',
|
|
54
|
+
|
|
55
|
+
schema: Object.freeze({ required: ['slides'], properties: { slides: children } }),
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A deck's teachable example is a whole Markdown-superset source, not one
|
|
59
|
+
* block: it is the form that can be piped straight back into `render -`.
|
|
60
|
+
*/
|
|
61
|
+
example: Object.freeze({ source: EXAMPLE_DECK }),
|
|
62
|
+
|
|
63
|
+
nesting: Object.freeze({ arrays: Object.freeze(['slides']) }),
|
|
64
|
+
|
|
65
|
+
styleHooks: Object.freeze(['deck']),
|
|
66
|
+
|
|
67
|
+
create: ({ slides }) => Object.freeze({ type: 'deck', slides }),
|
|
68
|
+
|
|
69
|
+
render: (block, ctx) => {
|
|
70
|
+
const slides = block.slides ?? []
|
|
71
|
+
return el('div', { class: 'deck', 'data-template': ctx.templateKey }, [
|
|
72
|
+
...slides.map((slide, index) => ctx.renderBlock(slide, { index, total: slides.length })),
|
|
73
|
+
...ctx.chrome('deck-chrome', { total: slides.length }),
|
|
74
|
+
])
|
|
75
|
+
},
|
|
76
|
+
})
|