@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.
Files changed (136) hide show
  1. package/README.md +192 -0
  2. package/package.json +54 -0
  3. package/src/blocks/board.js +210 -0
  4. package/src/blocks/callout.js +63 -0
  5. package/src/blocks/code.js +28 -0
  6. package/src/blocks/deck.js +76 -0
  7. package/src/blocks/diagram.js +265 -0
  8. package/src/blocks/element.js +51 -0
  9. package/src/blocks/graph.js +264 -0
  10. package/src/blocks/grid.js +156 -0
  11. package/src/blocks/index.js +106 -0
  12. package/src/blocks/list.js +50 -0
  13. package/src/blocks/placement.js +119 -0
  14. package/src/blocks/prose.js +28 -0
  15. package/src/blocks/quote.js +25 -0
  16. package/src/blocks/raw.js +47 -0
  17. package/src/blocks/registry.js +158 -0
  18. package/src/blocks/schema-parts.js +19 -0
  19. package/src/blocks/section.js +53 -0
  20. package/src/blocks/slide.js +104 -0
  21. package/src/blocks/stat.js +83 -0
  22. package/src/blocks/table.js +50 -0
  23. package/src/blocks/timeline.js +80 -0
  24. package/src/cli/commands/close.js +51 -0
  25. package/src/cli/commands/comments.js +73 -0
  26. package/src/cli/commands/debug.js +34 -0
  27. package/src/cli/commands/example.js +22 -0
  28. package/src/cli/commands/export.js +10 -0
  29. package/src/cli/commands/init.js +53 -0
  30. package/src/cli/commands/lint.js +93 -0
  31. package/src/cli/commands/list.js +54 -0
  32. package/src/cli/commands/open.js +31 -0
  33. package/src/cli/commands/promote.js +38 -0
  34. package/src/cli/commands/render.js +31 -0
  35. package/src/cli/commands/replay.js +49 -0
  36. package/src/cli/commands/schema.js +10 -0
  37. package/src/cli/commands/serve.js +178 -0
  38. package/src/cli/commands/setup.js +64 -0
  39. package/src/cli/commands/snapshot.js +29 -0
  40. package/src/cli/commands/templates.js +75 -0
  41. package/src/cli/deliver.js +54 -0
  42. package/src/cli/emit.js +29 -0
  43. package/src/cli/format.js +153 -0
  44. package/src/cli/index.js +365 -0
  45. package/src/cli/registry.js +18 -0
  46. package/src/core/blocks.js +147 -0
  47. package/src/core/diagram.js +282 -0
  48. package/src/core/errors.js +156 -0
  49. package/src/core/example.js +109 -0
  50. package/src/core/ir.js +62 -0
  51. package/src/core/lint-gates.js +427 -0
  52. package/src/core/lint.js +281 -0
  53. package/src/core/scan.js +84 -0
  54. package/src/core/schema.js +88 -0
  55. package/src/core/spec-check.js +33 -0
  56. package/src/core/validate.js +44 -0
  57. package/src/core/version.js +18 -0
  58. package/src/core/vocabulary.js +140 -0
  59. package/src/delivery/atomic.js +71 -0
  60. package/src/delivery/comments.js +180 -0
  61. package/src/delivery/home.js +70 -0
  62. package/src/delivery/open.js +31 -0
  63. package/src/delivery/project.js +141 -0
  64. package/src/delivery/read.js +109 -0
  65. package/src/delivery/run.js +95 -0
  66. package/src/delivery/scaffold-blueprints.js +728 -0
  67. package/src/delivery/store.js +219 -0
  68. package/src/delivery/template-extensions.js +183 -0
  69. package/src/delivery/template-format.js +112 -0
  70. package/src/delivery/template-package.js +376 -0
  71. package/src/delivery/template-promote.js +240 -0
  72. package/src/delivery/template-scaffold.js +181 -0
  73. package/src/delivery/templates.js +192 -0
  74. package/src/delivery/toml.js +195 -0
  75. package/src/delivery/write.js +35 -0
  76. package/src/export/browser.js +130 -0
  77. package/src/export/index.js +96 -0
  78. package/src/export/pdf.js +25 -0
  79. package/src/export/png.js +40 -0
  80. package/src/export/pptx.js +48 -0
  81. package/src/export/slides.js +33 -0
  82. package/src/export/snapshot.js +33 -0
  83. package/src/layouts/article.js +103 -0
  84. package/src/layouts/canvas.js +144 -0
  85. package/src/layouts/card.js +128 -0
  86. package/src/layouts/deck.js +88 -0
  87. package/src/layouts/index.js +90 -0
  88. package/src/layouts/one-page.js +161 -0
  89. package/src/layouts/registry.js +251 -0
  90. package/src/layouts/resume.js +172 -0
  91. package/src/layouts/template-index.js +78 -0
  92. package/src/parser/artifact.js +38 -0
  93. package/src/parser/container.js +103 -0
  94. package/src/parser/index.js +223 -0
  95. package/src/parser/tokens.js +265 -0
  96. package/src/render/board-filter.client.js +80 -0
  97. package/src/render/compile.js +29 -0
  98. package/src/render/context.js +98 -0
  99. package/src/render/element.js +32 -0
  100. package/src/render/fonts.js +129 -0
  101. package/src/render/graph-hover.client.js +148 -0
  102. package/src/render/html.js +52 -0
  103. package/src/render/index.js +241 -0
  104. package/src/render/measure.js +60 -0
  105. package/src/render/placement.js +136 -0
  106. package/src/render/playback.client.js +74 -0
  107. package/src/render/scale-to-fit.client.js +136 -0
  108. package/src/render/scale.js +41 -0
  109. package/src/render/skeleton.js +131 -0
  110. package/src/render/ssr.js +24 -0
  111. package/src/render/styles.js +56 -0
  112. package/src/render/templates.js +191 -0
  113. package/src/serve/daemon.js +117 -0
  114. package/src/serve/overlay.js +213 -0
  115. package/src/serve/protocol.js +36 -0
  116. package/src/serve/server.js +264 -0
  117. package/templates/kami/cards/components.js +40 -0
  118. package/templates/kami/cards/index.js +25 -0
  119. package/templates/kami/cards/manifest.js +67 -0
  120. package/templates/kami/cards/styles.css +389 -0
  121. package/templates/kami/long-form/components.js +102 -0
  122. package/templates/kami/long-form/index.js +25 -0
  123. package/templates/kami/long-form/manifest.js +87 -0
  124. package/templates/kami/long-form/styles.css +481 -0
  125. package/templates/kami/one-page/components.js +48 -0
  126. package/templates/kami/one-page/index.js +27 -0
  127. package/templates/kami/one-page/manifest.js +65 -0
  128. package/templates/kami/one-page/styles.css +375 -0
  129. package/templates/kami/resume/components.js +51 -0
  130. package/templates/kami/resume/index.js +27 -0
  131. package/templates/kami/resume/manifest.js +65 -0
  132. package/templates/kami/resume/styles.css +424 -0
  133. package/templates/kami/slides/components.js +41 -0
  134. package/templates/kami/slides/index.js +26 -0
  135. package/templates/kami/slides/manifest.js +64 -0
  136. package/templates/kami/slides/styles.css +406 -0
@@ -0,0 +1,264 @@
1
+ import { createServer } from 'node:http'
2
+ import { watch } from 'node:fs'
3
+ import { basename, dirname, resolve } from 'node:path'
4
+ import { CODES, EXIT, KsbError, ROOT_PATH } from '../core/errors.js'
5
+ import { collectBlockIds } from '../core/blocks.js'
6
+ import { appendComment, listComments } from '../delivery/comments.js'
7
+ import { COMMENTS_PATH, ERROR_EVENT, EVENTS_PATH, READY_EVENT, RELOAD_EVENT } from './protocol.js'
8
+ import { injectDevRuntime } from './overlay.js'
9
+
10
+ /**
11
+ * The dev preview server (CONTRACT E1/E2, SPEC §10.2 watch 模式).
12
+ *
13
+ * Deliberately node's own `http` module and nothing else. P3 settled the scope:
14
+ * v0.1 `serve` is file-watch plus a reload push, not Vite HMR — the need is
15
+ * "stop pressing refresh", and a bundler integration would drag a second build
16
+ * pipeline into an SDK whose entire promise is one deterministic renderer. The
17
+ * page the browser gets is the *same bytes* `render` writes, plus an injected
18
+ * dev runtime; there is no second rendering path to disagree with the first.
19
+ *
20
+ * Nothing here writes outside the caller-provided artifact path (which the CLI
21
+ * places inside the central store) and that artifact's comment sidecar.
22
+ */
23
+
24
+ /** Loopback only. A preview of unpublished work is not a thing to expose. */
25
+ export const SERVE_HOST = '127.0.0.1'
26
+
27
+ /**
28
+ * Coalescing window for filesystem events. Editors save by writing, truncating
29
+ * and renaming, so one human save arrives as a burst; re-rendering per event
30
+ * would push several reloads for one keystroke.
31
+ */
32
+ const WATCH_DEBOUNCE_MS = 60
33
+
34
+ const JSON_HEADERS = Object.freeze({
35
+ 'content-type': 'application/json; charset=utf-8',
36
+ 'cache-control': 'no-store',
37
+ })
38
+
39
+ const failure = (error) => ({
40
+ ok: false,
41
+ errors: [
42
+ error instanceof KsbError
43
+ ? error.toFinding()
44
+ : { path: ROOT_PATH, code: CODES.PARSE_FAILED, message: error?.message ?? String(error) },
45
+ ],
46
+ })
47
+
48
+ const sendJson = (res, status, payload) => {
49
+ res.writeHead(status, JSON_HEADERS)
50
+ res.end(JSON.stringify(payload))
51
+ }
52
+
53
+ /** Body reader with a hard cap: a preview server must not be a memory sink. */
54
+ const MAX_BODY_BYTES = 64 * 1024
55
+
56
+ const readBody = (req) =>
57
+ new Promise((resolvePromise, rejectPromise) => {
58
+ const chunks = []
59
+ let size = 0
60
+ req.on('data', (chunk) => {
61
+ size += chunk.length
62
+ if (size > MAX_BODY_BYTES) {
63
+ rejectPromise(
64
+ new KsbError({
65
+ code: CODES.INPUT_EMPTY,
66
+ message: `request body exceeds ${MAX_BODY_BYTES} bytes`,
67
+ exitCode: EXIT.VALIDATION,
68
+ }),
69
+ )
70
+ req.destroy()
71
+ return
72
+ }
73
+ chunks.push(chunk)
74
+ })
75
+ req.on('error', rejectPromise)
76
+ req.on('end', () => resolvePromise(Buffer.concat(chunks).toString('utf8')))
77
+ })
78
+
79
+ /**
80
+ * Start the preview server.
81
+ *
82
+ * @param {{compile: () => Promise<{html: string, ir: object}>, initial?: object,
83
+ * sourcePath: string, artifactPath: string, port?: number, host?: string}} input
84
+ * `compile` is injected rather than imported so the server owns no opinion
85
+ * about parsing — and so tests can drive the reload path without a renderer.
86
+ * `initial` lets the caller hand over a render it already performed, so the
87
+ * bytes it archived and the bytes it serves are the same object rather than
88
+ * two renders that merely ought to agree.
89
+ * `persist` receives every later successful render *before* it goes live, so
90
+ * the caller can keep the canonical copy in step (CONTRACT E1's living
91
+ * document). Injected rather than imported: this layer must not know that a
92
+ * central store exists.
93
+ * @returns {Promise<{url: string, port: number, close: () => Promise<void>}>}
94
+ */
95
+ export async function startServer({
96
+ compile,
97
+ initial,
98
+ persist,
99
+ sourcePath,
100
+ artifactPath,
101
+ port = 0,
102
+ host = SERVE_HOST,
103
+ }) {
104
+ const source = resolve(sourcePath)
105
+ let current = initial ?? (await compile())
106
+
107
+ /** Open SSE responses. Held so shutdown can end them instead of hanging. */
108
+ const clients = new Set()
109
+
110
+ const broadcast = (event, data) => {
111
+ const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
112
+ for (const client of clients) {
113
+ try {
114
+ client.write(frame)
115
+ } catch {
116
+ clients.delete(client)
117
+ }
118
+ }
119
+ }
120
+
121
+ const server = createServer((req, res) => {
122
+ handle(req, res).catch((error) => {
123
+ if (!res.headersSent) sendJson(res, 500, failure(error))
124
+ else res.end()
125
+ })
126
+ })
127
+
128
+ async function handle(req, res) {
129
+ const url = new URL(req.url ?? '/', `http://${host}`)
130
+
131
+ if (url.pathname === EVENTS_PATH) {
132
+ res.writeHead(200, {
133
+ 'content-type': 'text/event-stream; charset=utf-8',
134
+ 'cache-control': 'no-store',
135
+ connection: 'keep-alive',
136
+ })
137
+ res.write(`event: ${READY_EVENT}\ndata: ${JSON.stringify({ ok: true })}\n\n`)
138
+ clients.add(res)
139
+ req.on('close', () => clients.delete(res))
140
+ return
141
+ }
142
+
143
+ if (url.pathname === COMMENTS_PATH && req.method === 'GET') {
144
+ sendJson(res, 200, listComments(artifactPath))
145
+ return
146
+ }
147
+
148
+ if (url.pathname === COMMENTS_PATH && req.method === 'POST') {
149
+ const raw = await readBody(req)
150
+ let body
151
+ try {
152
+ body = JSON.parse(raw)
153
+ } catch (cause) {
154
+ sendJson(
155
+ res,
156
+ 400,
157
+ failure(
158
+ new KsbError({
159
+ code: CODES.PARSE_FAILED,
160
+ message: `comment body is not valid JSON: ${cause.message}`,
161
+ exitCode: EXIT.VALIDATION,
162
+ }),
163
+ ),
164
+ )
165
+ return
166
+ }
167
+ try {
168
+ // Validated against the *live* IR: the human commented on the page in
169
+ // front of them, so that is the tree whose ids are legal.
170
+ const entry = appendComment({
171
+ artifactPath,
172
+ blockId: body?.blockId,
173
+ text: body?.text,
174
+ blockIds: collectBlockIds(current.ir?.doc),
175
+ })
176
+ sendJson(res, 201, entry)
177
+ } catch (error) {
178
+ sendJson(res, 400, failure(error))
179
+ }
180
+ return
181
+ }
182
+
183
+ if (url.pathname === '/' && (req.method === 'GET' || req.method === 'HEAD')) {
184
+ const html = injectDevRuntime(current.html)
185
+ res.writeHead(200, {
186
+ 'content-type': 'text/html; charset=utf-8',
187
+ 'cache-control': 'no-store',
188
+ })
189
+ res.end(req.method === 'HEAD' ? undefined : html)
190
+ return
191
+ }
192
+
193
+ sendJson(res, 404, failure(new Error(`no route for ${req.method} ${url.pathname}`)))
194
+ }
195
+
196
+ let timer = null
197
+ let watcher = null
198
+
199
+ const rerender = async () => {
200
+ try {
201
+ const next = await compile()
202
+ // Order is the invariant, not an optimisation: persist, *then* go live.
203
+ // Comments are validated against whatever is live, so a page that led the
204
+ // canonical by even one render could accept an anchor the canonical
205
+ // cannot resolve — the drift seal F1 caught. If persisting fails, the old
206
+ // render stays live and the failure is announced; the two never disagree.
207
+ if (typeof persist === 'function') await persist(next)
208
+ current = next
209
+ broadcast(RELOAD_EVENT, { ok: true, at: new Date().toISOString() })
210
+ } catch (error) {
211
+ // A source the author has half-typed is not an emergency: keep serving the
212
+ // last good render and say so, rather than dropping the preview or dying.
213
+ broadcast(ERROR_EVENT, failure(error))
214
+ }
215
+ }
216
+
217
+ const onFsEvent = (_eventType, filename) => {
218
+ if (filename !== null && filename !== undefined && basename(filename) !== basename(source)) return
219
+ if (timer !== null) clearTimeout(timer)
220
+ timer = setTimeout(() => {
221
+ timer = null
222
+ void rerender()
223
+ }, WATCH_DEBOUNCE_MS)
224
+ }
225
+
226
+ // The *directory* is watched, not the file: a save that replaces the inode
227
+ // (write-temp-then-rename, which is what most editors do) leaves a file
228
+ // watcher bound to a path nobody writes to again — reload would work once and
229
+ // then quietly stop.
230
+ try {
231
+ watcher = watch(dirname(source), { persistent: true }, onFsEvent)
232
+ } catch (cause) {
233
+ throw new KsbError({
234
+ code: CODES.SERVE_START_FAILED,
235
+ message: `could not watch ${dirname(source)}: ${cause.message}`,
236
+ exitCode: EXIT.VALIDATION,
237
+ })
238
+ }
239
+
240
+ await new Promise((resolvePromise, rejectPromise) => {
241
+ server.once('error', (cause) =>
242
+ rejectPromise(
243
+ new KsbError({
244
+ code: CODES.SERVE_START_FAILED,
245
+ message: `could not listen on ${host}:${port}: ${cause.message}`,
246
+ exitCode: EXIT.VALIDATION,
247
+ }),
248
+ ),
249
+ )
250
+ server.listen(port, host, resolvePromise)
251
+ })
252
+
253
+ const actualPort = server.address().port
254
+
255
+ const close = async () => {
256
+ if (timer !== null) clearTimeout(timer)
257
+ if (watcher !== null) watcher.close()
258
+ for (const client of clients) client.end()
259
+ clients.clear()
260
+ await new Promise((done) => server.close(done))
261
+ }
262
+
263
+ return { url: `http://${host}:${actualPort}/`, port: actualPort, close }
264
+ }
@@ -0,0 +1,40 @@
1
+ import { el } from '../../../src/blocks/element.js'
2
+ import { manifest } from './manifest.js'
3
+
4
+ /**
5
+ * Chrome of the Kami card template.
6
+ *
7
+ * The shape of the page — the board, the lead-in region, the card grid — is the
8
+ * `card` 文體's, and every template on that layout gets the same one (CONTRACT
9
+ * C1). What is left here is exactly this template's own business: the masthead,
10
+ * the colophon, and every word of them.
11
+ *
12
+ * Nothing here is a component: each slot returns the same neutral element tree a
13
+ * block module returns, so this file is data with a little arithmetic in it and
14
+ * imports no rendering library at all (CONTRACT A1).
15
+ *
16
+ * There is deliberately **no chrome computed from the document** here, unlike
17
+ * `kami/long-form`'s table of contents. On a board the cards *are* the index:
18
+ * a list of links above a grid of the same headings would say everything twice.
19
+ */
20
+
21
+ const TEMPLATE_LABEL = `${manifest.namespace}/${manifest.name}`
22
+
23
+ const masthead = (meta) =>
24
+ el('header', { class: 'masthead' }, [
25
+ meta.kicker ? el('p', { class: 'kicker' }, [meta.kicker]) : null,
26
+ el('h1', { class: 'doc-title' }, [meta.title]),
27
+ meta.author || meta.date
28
+ ? el('p', { class: 'byline' }, [[meta.author, meta.date].filter(Boolean).join(' · ')])
29
+ : null,
30
+ ])
31
+
32
+ export const chrome = Object.freeze({
33
+ /** Above the board: who wrote this and what it is called. */
34
+ 'cards-lead': (meta) => [masthead(meta)],
35
+
36
+ /** Below it: the engine and skin that drew the page, in this template's words. */
37
+ 'cards-foot': () => [
38
+ el('footer', { class: 'colophon' }, [`kamishibai · ${TEMPLATE_LABEL}@${manifest.version}`]),
39
+ ],
40
+ })
@@ -0,0 +1,25 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { manifest, templateKey } from './manifest.js'
4
+ import { chrome } from './components.js'
5
+
6
+ const STYLES_PATH = fileURLToPath(new URL('./styles.css', import.meta.url))
7
+
8
+ /**
9
+ * Template package as consumed by the render layer.
10
+ *
11
+ * No `root`: the shape of the page belongs to the `card` layout this manifest
12
+ * names, and this package supplies only skin (`styles`), config (`manifest`)
13
+ * and chrome (CONTRACT C1).
14
+ */
15
+ export default Object.freeze({
16
+ manifest,
17
+ key: templateKey,
18
+ chrome,
19
+ language: manifest.language,
20
+ /** Fonts subset into the artifact (SPEC §7.2 出廠字體堆疊). */
21
+ fonts: Object.freeze([
22
+ Object.freeze({ package: '@fontsource/noto-serif-tc', weights: Object.freeze(['400', '700']) }),
23
+ ]),
24
+ styles: () => readFileSync(STYLES_PATH, 'utf8'),
25
+ })
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Template package descriptor (SPEC §5.2 `[template]` section) — the built-in
3
+ * generic skin of the `card` 文體.
4
+ */
5
+ export const manifest = Object.freeze({
6
+ namespace: 'kami',
7
+ name: 'cards',
8
+ version: '0.1.0',
9
+ description: 'Kami 卡片模板 — 卡面鋪排的看板版面',
10
+ language: 'zh-TW',
11
+ /**
12
+ * The 文體 this skin is a skin *of* (CONTRACT C1). The frame, the lead-in
13
+ * region, the card grid and the two chrome slots all come from there; this
14
+ * package only decides what they look like.
15
+ */
16
+ layout: 'card',
17
+ /** No required root form: this template renders whatever sits under `doc`. */
18
+ root: null,
19
+ blocks: Object.freeze([
20
+ 'doc',
21
+ 'section',
22
+ 'prose',
23
+ 'list',
24
+ 'quote',
25
+ 'callout',
26
+ 'code',
27
+ 'table',
28
+ 'raw',
29
+ 'diagram',
30
+ // F6a 四型資料 block(11 號票 P1「資料」)。五張 Kami 皮一起宣告:
31
+ // 詞彙表是准入規則,一張皮少宣告一種,同一份文件在它底下就會被
32
+ // KSB_TEMPLATE_BLOCK_UNSUPPORTED 擋掉——而那不是守門,是缺角。
33
+ 'stat',
34
+ 'timeline',
35
+ 'board',
36
+ 'graph',
37
+ ]),
38
+ /**
39
+ * 轉子/<型別>/config — the callout labels are *wording*, and wording is this
40
+ * template's answer rather than the SDK's (CONTRACT A7). Identical to
41
+ * `kami/long-form`'s on purpose: the two Kami skins are one family, and a card
42
+ * board that said 「注意」 while the long-form page said `NOTE` would read as
43
+ * two products.
44
+ */
45
+ blockConfig: Object.freeze({
46
+ callout: Object.freeze({ labels: Object.freeze({ note: 'NOTE', warn: 'WARNING' }) }),
47
+ }),
48
+ /**
49
+ * The applied state of the `card` layout's geometry, in CSS pixels.
50
+ *
51
+ * `maxWidth` 1200 is the width of the board; `measure` 740 is the width of a
52
+ * line of text (CONTRACT C3). They are two numbers, and on this 文體 they are
53
+ * as far apart as they ever get: the lead-in paragraph respects the measure,
54
+ * while the board beside it is a 24-track grid three cards across. Collapsing
55
+ * them — the pre-F2a habit — would either squeeze the board into a column or
56
+ * stretch every line of prose across 1200px.
57
+ *
58
+ * Both values are this *template's*, not the layout's: the layout only says
59
+ * which knobs exist and what they fall back to. `styles.css` holds neither
60
+ * number; it declares where they go and the render layer writes them in
61
+ * (`src/render/styles.js`).
62
+ */
63
+ maxWidth: 1200,
64
+ measure: 740,
65
+ })
66
+
67
+ export const templateKey = `${manifest.namespace}/${manifest.name}`