@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,191 @@
1
+ import cards from '../../templates/kami/cards/index.js'
2
+ import longForm from '../../templates/kami/long-form/index.js'
3
+ import onePage from '../../templates/kami/one-page/index.js'
4
+ import resume from '../../templates/kami/resume/index.js'
5
+ import slides from '../../templates/kami/slides/index.js'
6
+ import { CODES, validationError } from '../core/errors.js'
7
+
8
+ /**
9
+ * Factory template registry (SPEC §6.1 出廠內建模板家族).
10
+ *
11
+ * One entry per 文體, because every layout ships a built-in generic template so
12
+ * `render` can never fail for want of one (`registry.js` の defaultTemplate).
13
+ * `kami/cards` joined in F2c with the `card` 文體 itself, `kami/one-page` in F2d
14
+ * with `one-page`, and `kami/resume` in F2e with `resume` — the fifth and last
15
+ * of the v1 set.
16
+ */
17
+ const REGISTRY = new Map([
18
+ [cards.key, cards],
19
+ [longForm.key, longForm],
20
+ [onePage.key, onePage],
21
+ [resume.key, resume],
22
+ [slides.key, slides],
23
+ ])
24
+
25
+ /**
26
+ * Additional places a template may come from, consulted after the factory
27
+ * registry (CONTRACT F3 D1, P4「解析鏈=出廠 registry 優先 → store」).
28
+ *
29
+ * Until F3 this list did not exist and `resolveTemplate` read the map above and
30
+ * nothing else — which is why a package could be installed, listed by
31
+ * `templates` and counted by `debug`, and still come back
32
+ * `KSB_TEMPLATE_NOT_FOUND` when anyone tried to draw with it (量測報告 §5.2).
33
+ * The namespace was a ledger, not a loading point.
34
+ *
35
+ * A *source* rather than a hard-wired store lookup, because this layer must not
36
+ * learn where `~/.kamishibai` is: it renders, it does not deliver. The store
37
+ * installs itself from the delivery layer (`installTemplateStore`), the same
38
+ * inversion the block and layout registries already use.
39
+ */
40
+ let sources = Object.freeze([])
41
+
42
+ const reject = (message) => {
43
+ throw new TypeError(`invalid template source: ${message}`)
44
+ }
45
+
46
+ /**
47
+ * Register one template source — the public extension point of the chain.
48
+ *
49
+ * Shape-checked at registration for the reason the other two registries state:
50
+ * a source that cannot list or cannot load fails on the rarest path, deep
51
+ * inside a render, where the message would be about something else entirely.
52
+ *
53
+ * @param {{keys: () => string[], load: (key: string) => Promise<object|undefined>}} source
54
+ */
55
+ export function registerTemplateSource(source) {
56
+ if (source === null || typeof source !== 'object') reject('not an object')
57
+ if (typeof source.keys !== 'function') reject('`keys` must be a function')
58
+ if (typeof source.load !== 'function') reject('`load` must be a function')
59
+ sources = Object.freeze([...sources, source])
60
+ return sources.length
61
+ }
62
+
63
+ /** Drop every registered source (test hygiene, and re-installation on a new HOME). */
64
+ export function resetTemplateSources() {
65
+ sources = Object.freeze([])
66
+ return sources.length
67
+ }
68
+
69
+ export function listTemplateKeys() {
70
+ return [...REGISTRY.keys()].sort()
71
+ }
72
+
73
+ /**
74
+ * The namespaces the factory templates occupy — reserved (CONTRACT P4).
75
+ *
76
+ * Queried from the registry rather than frozen into a constant, per the F2a
77
+ * 封緘補丁 (c): a list captured at import time is a list a third party cannot
78
+ * see itself in.
79
+ */
80
+ export function reservedNamespaces() {
81
+ return [...new Set([...REGISTRY.values()].map((t) => t.manifest.namespace))].sort()
82
+ }
83
+
84
+ const namespaceOf = (key) => String(key).split('/')[0]
85
+
86
+ /**
87
+ * Whether a key names a store package squatting on a factory namespace — a
88
+ * package that exists on disk and can never be loaded (CONTRACT P4, 封緘 F2).
89
+ *
90
+ * The factory packages are themselves projected into the store, so being *in*
91
+ * `kami/` is not the test: being in `kami/` **without** being one of the factory
92
+ * templates is. One definition, because both consumers need exactly this
93
+ * predicate and a second copy would be free to disagree — the listing would then
94
+ * mark a package the resolver happily loaded, or worse, the other way round.
95
+ */
96
+ export function isReservedKey(key) {
97
+ const bare = String(key ?? '').split('@')[0]
98
+ if (REGISTRY.has(bare)) return false
99
+ return reservedNamespaces().includes(namespaceOf(bare))
100
+ }
101
+
102
+ /**
103
+ * Every key `-t` could name on this machine **and actually get**: the factory
104
+ * family plus whatever the installed sources hold, minus the squatters.
105
+ *
106
+ * This is the list a `KSB_TEMPLATE_NOT_FOUND` prints, and it has to include the
107
+ * store: an error that recites only the two bundled templates, while the very
108
+ * package the user just installed sits unlisted, is how the store came to look
109
+ * broken in the first place.
110
+ *
111
+ * It equally has to *exclude* the unloadable ones (封緘 F2). This list is read
112
+ * as an offer — "try one of these" — so publishing a key that is refused the
113
+ * moment anyone takes the offer sends the reader in a circle, and the key it
114
+ * sends them to is precisely the one impersonating an official template. The
115
+ * listing (`templates`) still shows it, because that surface answers a different
116
+ * question: what is on disk. Here the question is what can be drawn with.
117
+ */
118
+ export function availableTemplateKeys() {
119
+ const keys = new Set(listTemplateKeys())
120
+ for (const source of sources) {
121
+ for (const key of source.keys()) {
122
+ if (!isReservedKey(key)) keys.add(key)
123
+ }
124
+ }
125
+ return [...keys].sort()
126
+ }
127
+
128
+ /**
129
+ * The factory manifests as plain data, for layers that need to *describe* the
130
+ * built-ins without rendering with them (the store's template namespace, E3).
131
+ * Exposed from the registry rather than re-imported by the caller so a template
132
+ * added here cannot go unregistered on disk.
133
+ */
134
+ export function builtinManifests() {
135
+ return listTemplateKeys().map((key) => REGISTRY.get(key).manifest)
136
+ }
137
+
138
+ /**
139
+ * A store package may not wear a factory namespace (CONTRACT P4).
140
+ *
141
+ * The factory packages are themselves projected into the store, so `kami/` is
142
+ * *expected* to be there — which is exactly what makes the squat dangerous. A
143
+ * `kami/`-named package would read as an official one to every human and every
144
+ * agent that lists the namespace, and the ordering rule alone (factory first)
145
+ * only protects the two keys that already exist: it would say nothing about
146
+ * `kami/report`. So the name is refused rather than merely out-ranked.
147
+ */
148
+ function assertNotReserved(key) {
149
+ // The predicate itself lives in `isReservedKey` and is *called*, not restated
150
+ // (複驗 F-6). The earlier version repeated the namespace test here in a
151
+ // slightly different shape, so the comment's「單一定義」claim was already
152
+ // false: two spellings of one rule, free to drift apart, with the listing and
153
+ // the resolver each believing its own.
154
+ if (!isReservedKey(key)) return
155
+ const namespace = namespaceOf(key)
156
+ throw validationError(
157
+ `模板包 "${key}" 佔用了出廠保留的 namespace \`${namespace}\`;` +
158
+ `保留的 namespace:${reservedNamespaces().join(', ')}。` +
159
+ '庫裡的包不得冒用出廠名——冒用得逞的話,列表上它與出廠包長得一模一樣,' +
160
+ '而讀到的人無從分辨自己畫的是誰的模板。',
161
+ CODES.TEMPLATE_NAMESPACE_RESERVED,
162
+ 'doc.meta.template',
163
+ )
164
+ }
165
+
166
+ /**
167
+ * Resolve `<namespace>/<name>[@version]`; the version part is advisory in S1.
168
+ *
169
+ * Chain: the factory registry first, then every installed source in
170
+ * registration order. Factory-first is what makes 「出廠名不可劫持」 structural
171
+ * rather than a matter of nobody having tried: a package planted at
172
+ * `kami/long-form` never gets asked.
173
+ */
174
+ export async function resolveTemplate(key) {
175
+ const bare = String(key ?? '').split('@')[0]
176
+ const factory = REGISTRY.get(bare)
177
+ if (factory !== undefined) return factory
178
+
179
+ for (const source of sources) {
180
+ if (!source.keys().includes(bare)) continue
181
+ assertNotReserved(bare)
182
+ const loaded = await source.load(bare)
183
+ if (loaded !== undefined) return loaded
184
+ }
185
+
186
+ throw validationError(
187
+ `unknown template "${key}"; available: ${availableTemplateKeys().join(', ')}`,
188
+ CODES.TEMPLATE_NOT_FOUND,
189
+ 'doc.meta.template',
190
+ )
191
+ }
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ import { basename, extname, resolve } from 'node:path'
3
+ import { readSource } from '../delivery/read.js'
4
+ import { archiveArtifact, refreshArtifact } from '../delivery/store.js'
5
+ import { resolveCreatedAt } from '../core/ir.js'
6
+ import { compileDocument } from '../render/compile.js'
7
+ import { installTemplateStore } from '../delivery/template-package.js'
8
+ import { EXIT, KsbError, ROOT_PATH, CODES } from '../core/errors.js'
9
+ import { startServer } from './server.js'
10
+
11
+ /**
12
+ * The detached half of `serve` (CONTRACT E1).
13
+ *
14
+ * `serve` has to return to the shell — an Agent that blocks on a preview server
15
+ * cannot do anything else, and a foreground server dies with the session that
16
+ * started it, which is precisely the state `close` exists to manage. So the CLI
17
+ * spawns this file detached and hands back the URL.
18
+ *
19
+ * The handshake is one JSON line on stdout, written the moment the port is
20
+ * really listening. That is what makes `serve` honest: the parent prints a URL
21
+ * only after something answers on it, and a failure here comes back as the same
22
+ * KSB_ finding shape any other command would have emitted, with its exit code.
23
+ */
24
+
25
+ const parseArgs = (argv) => {
26
+ const options = {}
27
+ for (let i = 0; i < argv.length; i += 2) {
28
+ const key = String(argv[i] ?? '').replace(/^--/, '')
29
+ options[key] = argv[i + 1]
30
+ }
31
+ return options
32
+ }
33
+
34
+ const say = (payload) => {
35
+ try {
36
+ process.stdout.write(`${JSON.stringify(payload)}\n`)
37
+ } catch {
38
+ // The parent may already be gone; a preview server must not die of EPIPE.
39
+ }
40
+ }
41
+
42
+ const toFinding = (error) =>
43
+ error instanceof KsbError
44
+ ? { finding: error.toFinding(), exitCode: error.exitCode }
45
+ : {
46
+ finding: { path: ROOT_PATH, code: CODES.PARSE_FAILED, message: error?.message ?? String(error) },
47
+ exitCode: EXIT.VALIDATION,
48
+ }
49
+
50
+ async function main() {
51
+ const options = parseArgs(process.argv.slice(2))
52
+ const input = resolve(String(options.input))
53
+ const project = String(options.project)
54
+ const port = Number(options.port ?? 0)
55
+
56
+ // `serve` and `render` must agree on what a source file becomes (see
57
+ // `compileDocument`), and that agreement now includes *which* templates exist:
58
+ // a preview that cannot open the package the exported artifact uses is the
59
+ // same drift, one layer up (F3 D1).
60
+ installTemplateStore(process.env)
61
+
62
+ const compile = async () => {
63
+ const { source, format } = readSource(input)
64
+ return await compileDocument({
65
+ source,
66
+ format,
67
+ template: options.template,
68
+ generator: options.generator,
69
+ createdAt: resolveCreatedAt(process.env),
70
+ })
71
+ }
72
+
73
+ // The session claims **one** name in the store and then keeps that copy alive
74
+ // (CONTRACT E1, seal patch F1). One name, because a live preview re-renders on
75
+ // every keystroke-save and archiving each of those would bury the project's
76
+ // presentation history under hundreds of near-identical records. *Alive*,
77
+ // because comments anchor to this artifact: if it froze at the opening render
78
+ // while the page moved on, a human could comment on a block the canonical does
79
+ // not contain, and the file meant to answer "which block did they mean?" would
80
+ // be the one thing unable to. So every later render replaces it atomically.
81
+ // Artifacts from other runs are never touched — history stays immutable.
82
+ const first = await compile()
83
+ const slug = basename(input, extname(input)) || 'document'
84
+ const { path: artifactPath } = archiveArtifact({
85
+ project,
86
+ slug,
87
+ html: first.html,
88
+ copies: [],
89
+ env: process.env,
90
+ })
91
+
92
+ // The first render is handed over rather than repeated, so the served bytes
93
+ // and the archived bytes are the same object; every later render goes through
94
+ // the real compiler.
95
+ const { url, port: actualPort, close } = await startServer({
96
+ compile,
97
+ initial: first,
98
+ persist: (rendered) => refreshArtifact(artifactPath, rendered.html),
99
+ sourcePath: input,
100
+ artifactPath,
101
+ port,
102
+ })
103
+
104
+ say({ ok: true, url, port: actualPort, pid: process.pid, artifact: artifactPath })
105
+
106
+ const shutdown = () => {
107
+ close().finally(() => process.exit(EXIT.OK))
108
+ }
109
+ process.on('SIGTERM', shutdown)
110
+ process.on('SIGINT', shutdown)
111
+ }
112
+
113
+ main().catch((error) => {
114
+ const { finding, exitCode } = toFinding(error)
115
+ say({ ok: false, errors: [finding], exitCode })
116
+ process.exit(exitCode)
117
+ })
@@ -0,0 +1,213 @@
1
+ import { IR_SCRIPT_TYPE } from '../core/ir.js'
2
+ import {
3
+ COMMENTS_PATH,
4
+ DEV_RUNTIME_MARKER,
5
+ ERROR_EVENT,
6
+ EVENTS_PATH,
7
+ RELOAD_EVENT,
8
+ } from './protocol.js'
9
+
10
+ /**
11
+ * The dev-only runtime: live-reload listener plus the block-anchored comment
12
+ * overlay (CONTRACT E1/E2, SPEC §13.1).
13
+ *
14
+ * **It is injected at serve time and never written to disk.** An artifact is a
15
+ * self-contained, offline, read-only document; a comment widget baked into one
16
+ * would POST to a server that no longer exists, and the file that a reader
17
+ * receives would carry behaviour they never asked for. So the artifact bytes
18
+ * on disk stay exactly what `render` produced, and the preview is the artifact
19
+ * *plus* this script — which is also why `render`/`replay` stay byte-identical
20
+ * while `serve` is running.
21
+ *
22
+ * **Anchoring reads the IR, it does not guess.** The overlay offers the block
23
+ * list straight out of the artifact's own embedded IR, so the id that reaches
24
+ * the log is one the artifact really contains and the human really chose. A
25
+ * click-to-anchor overlay would have to map DOM elements back onto blocks
26
+ * heuristically, and a heuristic that mis-anchors is worse than no anchor at
27
+ * all: the Agent would confidently edit the wrong block. Structured picking is
28
+ * the entire point of §13.1's「非自然語言猜謎」.
29
+ */
30
+
31
+ /** Rendered inside the injected `<style>`; kept small and namespaced. */
32
+ const OVERLAY_CSS = `
33
+ .${DEV_RUNTIME_MARKER}{position:fixed;right:16px;bottom:16px;z-index:2147483000;
34
+ font:13px/1.6 system-ui,-apple-system,"Noto Sans TC",sans-serif;color:#1a1a1a}
35
+ .${DEV_RUNTIME_MARKER} button{font:inherit;cursor:pointer;border:1px solid #3a3a3a;
36
+ background:#fff;color:#1a1a1a;border-radius:6px;padding:6px 12px}
37
+ .${DEV_RUNTIME_MARKER}-panel{display:none;width:320px;max-height:60vh;overflow:auto;
38
+ background:#fff;border:1px solid #3a3a3a;border-radius:8px;padding:12px;
39
+ box-shadow:0 8px 24px rgba(0,0,0,.18);margin-bottom:8px}
40
+ .${DEV_RUNTIME_MARKER}[data-open="1"] .${DEV_RUNTIME_MARKER}-panel{display:block}
41
+ .${DEV_RUNTIME_MARKER} select,.${DEV_RUNTIME_MARKER} textarea{width:100%;font:inherit;
42
+ margin:4px 0 8px;box-sizing:border-box;border:1px solid #bbb;border-radius:4px;padding:4px}
43
+ .${DEV_RUNTIME_MARKER} textarea{height:72px;resize:vertical}
44
+ .${DEV_RUNTIME_MARKER}-list{margin:8px 0 0;padding:0;list-style:none}
45
+ .${DEV_RUNTIME_MARKER}-list li{border-top:1px solid #eee;padding:6px 0}
46
+ .${DEV_RUNTIME_MARKER}-status{color:#8a4b00;min-height:1.6em}
47
+ `.trim()
48
+
49
+ /**
50
+ * The runtime source. Written as one string rather than a bundled module
51
+ * because it must survive being read by a browser with no build step and no
52
+ * network — the same constraint the artifacts themselves live under.
53
+ */
54
+ const runtimeSource = () => `
55
+ (function () {
56
+ var MARKER = ${JSON.stringify(DEV_RUNTIME_MARKER)};
57
+ var EVENTS = ${JSON.stringify(EVENTS_PATH)};
58
+ var COMMENTS = ${JSON.stringify(COMMENTS_PATH)};
59
+ var IR_TYPE = ${JSON.stringify(IR_SCRIPT_TYPE)};
60
+ var RELOAD = ${JSON.stringify(RELOAD_EVENT)};
61
+ var ERRORED = ${JSON.stringify(ERROR_EVENT)};
62
+
63
+ function readIr() {
64
+ var el = document.querySelector('script[type="' + IR_TYPE + '"]');
65
+ if (!el) return null;
66
+ try { return JSON.parse(el.textContent); } catch (e) { return null; }
67
+ }
68
+
69
+ function snippet(block) {
70
+ var text = block.title || block.text || block.html || '';
71
+ text = String(text).replace(/<[^>]*>/g, ' ').replace(/\\s+/g, ' ').trim();
72
+ return text.length > 28 ? text.slice(0, 28) + '…' : text;
73
+ }
74
+
75
+ /** Document-order walk over every nesting shape the IR uses. */
76
+ function walk(node, out) {
77
+ if (!node || typeof node !== 'object') return out;
78
+ if (typeof node.id === 'string') {
79
+ out.push({ id: node.id, type: node.type, label: snippet(node) });
80
+ }
81
+ var groups = [node.children, node.slides];
82
+ for (var g = 0; g < groups.length; g += 1) {
83
+ if (!Array.isArray(groups[g])) continue;
84
+ for (var i = 0; i < groups[g].length; i += 1) walk(groups[g][i], out);
85
+ }
86
+ if (Array.isArray(node.items)) {
87
+ for (var j = 0; j < node.items.length; j += 1) {
88
+ var item = node.items[j];
89
+ if (!Array.isArray(item)) continue;
90
+ for (var k = 0; k < item.length; k += 1) walk(item[k], out);
91
+ }
92
+ }
93
+ return out;
94
+ }
95
+
96
+ var ir = readIr();
97
+ var blocks = ir ? walk(ir.doc, []) : [];
98
+
99
+ var root = document.createElement('div');
100
+ root.className = MARKER;
101
+ root.setAttribute('data-open', '0');
102
+ root.innerHTML =
103
+ '<div class="' + MARKER + '-panel">' +
104
+ '<strong>留言(錨定 block)</strong>' +
105
+ '<select class="' + MARKER + '-block"></select>' +
106
+ '<textarea class="' + MARKER + '-text" placeholder="這個 block 要怎麼改?"></textarea>' +
107
+ '<button type="button" class="' + MARKER + '-send">送出</button>' +
108
+ '<p class="' + MARKER + '-status"></p>' +
109
+ '<ul class="' + MARKER + '-list"></ul>' +
110
+ '</div>' +
111
+ '<button type="button" class="' + MARKER + '-toggle">留言</button>';
112
+ document.body.appendChild(root);
113
+
114
+ var picker = root.querySelector('.' + MARKER + '-block');
115
+ var textarea = root.querySelector('.' + MARKER + '-text');
116
+ var status = root.querySelector('.' + MARKER + '-status');
117
+ var list = root.querySelector('.' + MARKER + '-list');
118
+
119
+ for (var b = 0; b < blocks.length; b += 1) {
120
+ var option = document.createElement('option');
121
+ option.value = blocks[b].id;
122
+ option.textContent = blocks[b].id + ' · ' + blocks[b].type +
123
+ (blocks[b].label ? ' · ' + blocks[b].label : '');
124
+ picker.appendChild(option);
125
+ }
126
+
127
+ function paint(entries) {
128
+ list.textContent = '';
129
+ for (var i = 0; i < entries.length; i += 1) {
130
+ var li = document.createElement('li');
131
+ li.textContent = '[' + entries[i].status + '] ' + entries[i].id + ' → ' +
132
+ entries[i].blockId + ':' + entries[i].text;
133
+ list.appendChild(li);
134
+ }
135
+ }
136
+
137
+ function refresh() {
138
+ fetch(COMMENTS, { headers: { accept: 'application/json' } })
139
+ .then(function (r) { return r.json(); })
140
+ .then(function (entries) { if (Array.isArray(entries)) paint(entries); })
141
+ .catch(function () { /* listing is a convenience; never block commenting */ });
142
+ }
143
+
144
+ root.querySelector('.' + MARKER + '-toggle').addEventListener('click', function () {
145
+ root.setAttribute('data-open', root.getAttribute('data-open') === '1' ? '0' : '1');
146
+ if (root.getAttribute('data-open') === '1') refresh();
147
+ });
148
+
149
+ root.querySelector('.' + MARKER + '-send').addEventListener('click', function () {
150
+ var body = { blockId: picker.value, text: textarea.value };
151
+ status.textContent = '送出中…';
152
+ fetch(COMMENTS, {
153
+ method: 'POST',
154
+ headers: { 'content-type': 'application/json' },
155
+ body: JSON.stringify(body)
156
+ })
157
+ .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, json: j }; }); })
158
+ .then(function (out) {
159
+ if (!out.ok) {
160
+ var first = out.json && out.json.errors && out.json.errors[0];
161
+ status.textContent = '失敗:' + (first ? first.code + ' ' + first.message : '未知錯誤');
162
+ return;
163
+ }
164
+ status.textContent = '已記錄 ' + out.json.id;
165
+ textarea.value = '';
166
+ refresh();
167
+ })
168
+ .catch(function (e) { status.textContent = '失敗:' + e.message; });
169
+ });
170
+
171
+ if (typeof EventSource === 'function') {
172
+ var source = new EventSource(EVENTS);
173
+ source.addEventListener(RELOAD, function () { location.reload(); });
174
+ source.addEventListener(ERRORED, function (event) {
175
+ status.textContent = '來源渲染失敗:' + event.data;
176
+ root.setAttribute('data-open', '1');
177
+ });
178
+ }
179
+ })();
180
+ `.trim()
181
+
182
+ /**
183
+ * The full injected fragment: style, runtime, and a marker attribute so the
184
+ * "absent from artifacts" pin has one unambiguous thing to look for.
185
+ */
186
+ export function devRuntimeFragment() {
187
+ return [
188
+ `<style data-${DEV_RUNTIME_MARKER}="1">${OVERLAY_CSS}</style>`,
189
+ `<script data-${DEV_RUNTIME_MARKER}="1">${runtimeSource()}</script>`,
190
+ ].join('\n')
191
+ }
192
+
193
+ const BODY_CLOSE = '</body>'
194
+
195
+ /**
196
+ * Inject the dev runtime into a rendered artifact, in memory.
197
+ *
198
+ * The artifact is not re-parsed and not modified anywhere else: the fragment
199
+ * goes immediately before `</body>`, after the template's own behaviour and
200
+ * after the IR payload, so the runtime can read the IR that is already there.
201
+ * When there is no `</body>` (a hand-written fixture, say) the fragment is
202
+ * appended — a preview without live reload is a bug, but a preview that throws
203
+ * is worse.
204
+ */
205
+ export function injectDevRuntime(html) {
206
+ const source = String(html ?? '')
207
+ const fragment = devRuntimeFragment()
208
+ const index = source.lastIndexOf(BODY_CLOSE)
209
+ if (index === -1) return `${source}\n${fragment}\n`
210
+ return `${source.slice(0, index)}${fragment}\n${source.slice(index)}`
211
+ }
212
+
213
+ export { DEV_RUNTIME_MARKER }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The wire vocabulary shared by the dev server and the runtime it injects.
3
+ *
4
+ * Both halves are written in this repository and shipped together, but they run
5
+ * in different processes and are wired by strings. One table of those strings
6
+ * is what stops the browser half from listening on a path the server half
7
+ * stopped serving — a drift that shows up as "reload silently stopped working",
8
+ * the single most expensive failure mode a preview server has.
9
+ *
10
+ * Every dev-only route lives under one reserved prefix so that nothing a
11
+ * template could ever emit collides with it.
12
+ */
13
+
14
+ /** Reserved prefix for everything the preview server adds to a page. */
15
+ export const DEV_PREFIX = '/__kamishibai'
16
+
17
+ /** SSE stream the injected runtime subscribes to. */
18
+ export const EVENTS_PATH = `${DEV_PREFIX}/events`
19
+ /** Comment collection: `GET` lists, `POST` appends. */
20
+ export const COMMENTS_PATH = `${DEV_PREFIX}/comments`
21
+
22
+ /** SSE event names. `reload` is the one CONTRACT E1 pins. */
23
+ export const RELOAD_EVENT = 'reload'
24
+ export const ERROR_EVENT = 'error'
25
+ /** Sent once on connect so a client can tell "subscribed" from "server hung". */
26
+ export const READY_EVENT = 'ready'
27
+
28
+ /**
29
+ * The marker that identifies the injected runtime.
30
+ *
31
+ * It is asserted *absent* from every rendered and exported artifact (CONTRACT
32
+ * E2). That pin only means something if the marker is a single distinctive
33
+ * token that appears nowhere else, so it is defined here and used verbatim by
34
+ * both the injector and the test.
35
+ */
36
+ export const DEV_RUNTIME_MARKER = 'kamishibai-dev-overlay'