@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,376 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { CODES, EXIT, KsbError } from '../core/errors.js'
4
+ import { validateGateDeclaration } from '../core/lint-gates.js'
5
+ import { isReservedKey, registerTemplateSource, resetTemplateSources } from '../render/templates.js'
6
+ import {
7
+ LAYOUT_KNOBS_FIELD,
8
+ layoutModule,
9
+ registerTemplateLayoutSource,
10
+ resetTemplateLayoutSources,
11
+ } from '../layouts/index.js'
12
+ import { templatePackageDir, templatesRoot } from './home.js'
13
+ import { loadPackageExtensions } from './template-extensions.js'
14
+ import {
15
+ DEFAULT_LANGUAGE,
16
+ MANIFEST_FILENAME,
17
+ NO_ROOT,
18
+ PACKAGE_FILES,
19
+ splitTemplateKey,
20
+ } from './template-format.js'
21
+ import { parseToml } from './toml.js'
22
+
23
+ export { MANIFEST_FILENAME, NO_ROOT, PACKAGE_FILES, splitTemplateKey }
24
+
25
+ /**
26
+ * A template package on disk, **loaded** rather than merely listed (CONTRACT F3 D1).
27
+ *
28
+ * E3 gave the store a template namespace and stopped one step short: a package
29
+ * could be registered, listed by `templates`, counted by `debug` — and then
30
+ * `render -t` came back `KSB_TEMPLATE_NOT_FOUND`, because the resolver read a
31
+ * static registry of the two templates the engine happened to bundle (量測報告
32
+ * §5.2). The namespace was a ledger of things nobody could use. This module is
33
+ * the other half: the same directory, read back into the object the render
34
+ * pipeline already consumes.
35
+ *
36
+ * The default path is **pure data** (wayfinder issue 13 裁決 7): a manifest that
37
+ * declares which 文體 the package rides and how its knobs are turned, a
38
+ * stylesheet, and a 預設 md that shows what the template looks like when nobody
39
+ * has written anything yet (issue 14 補充裁定). Nothing here is executable, so
40
+ * nothing here needs to be trusted. JS is opt-in, and lives next door in
41
+ * `template-extensions.js`.
42
+ */
43
+
44
+ const invalid = (message, path = 'doc.meta.template') =>
45
+ new KsbError({
46
+ code: CODES.TEMPLATE_PACKAGE_INVALID,
47
+ message,
48
+ path,
49
+ exitCode: EXIT.VALIDATION,
50
+ })
51
+
52
+ const subdirectories = (dir) => {
53
+ if (!existsSync(dir)) return []
54
+ return readdirSync(dir)
55
+ .filter((entry) => statSync(join(dir, entry)).isDirectory())
56
+ .sort()
57
+ }
58
+
59
+ /**
60
+ * Every package directory in the store, sorted by `<namespace>/<name>`.
61
+ *
62
+ * A directory counts as a package when it holds a manifest — the same rule the
63
+ * listing has always used, kept in one place so `templates` and `render` can
64
+ * never disagree about what is installed.
65
+ *
66
+ * @returns {Array<{namespace: string, name: string, key: string, dir: string, path: string}>}
67
+ */
68
+ export function packageDirs(env = process.env) {
69
+ const root = templatesRoot(env)
70
+ const found = []
71
+ for (const namespace of subdirectories(root)) {
72
+ for (const name of subdirectories(join(root, namespace))) {
73
+ const dir = join(root, namespace, name)
74
+ const path = join(dir, PACKAGE_FILES.MANIFEST)
75
+ if (!existsSync(path)) continue
76
+ found.push({ namespace, name, key: `${namespace}/${name}`, dir, path })
77
+ }
78
+ }
79
+ return found
80
+ }
81
+
82
+ /** `<namespace>/<name>` of every package installed in the store. */
83
+ export const storeTemplateKeys = (env = process.env) => packageDirs(env).map((pkg) => pkg.key)
84
+
85
+ const jsFilesUnder = (dir) => {
86
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return []
87
+ return readdirSync(dir).flatMap((entry) => {
88
+ const full = join(dir, entry)
89
+ if (statSync(full).isDirectory()) return jsFilesUnder(full)
90
+ return full.endsWith('.js') ? [full] : []
91
+ })
92
+ }
93
+
94
+ /**
95
+ * Whether a package carries executable code (CONTRACT P5).
96
+ *
97
+ * There is no sandbox — 安裝即信任 is the settled trust model (issue 13 裁決 4).
98
+ * What a trust-on-install model still owes its user is *legibility*: being able
99
+ * to see, without opening a single file, which of the packages installed here
100
+ * will run code when they draw. So the listing says so, and this is where it
101
+ * learns the answer.
102
+ */
103
+ export const packageHasCode = (dir) =>
104
+ jsFilesUnder(join(dir, PACKAGE_FILES.ROTORS_DIR)).length > 0 ||
105
+ jsFilesUnder(join(dir, PACKAGE_FILES.PLUGINS_DIR)).length > 0 ||
106
+ // F7a's third door counts too. A package whose chrome is JS runs code when it
107
+ // draws, exactly like a rotor override does, and the whole use of this field
108
+ // is to let a reader see that without opening a file (CONTRACT P5).
109
+ existsSync(join(dir, PACKAGE_FILES.CHROME))
110
+
111
+ const requireString = (table, key, where) => {
112
+ const value = table[key]
113
+ if (typeof value !== 'string' || value.length === 0) {
114
+ throw invalid(
115
+ `模板包 ${where} 的 ${PACKAGE_FILES.MANIFEST} 缺少字串欄位 \`${key}\`` +
116
+ `(目前值:${JSON.stringify(value ?? null)})。`,
117
+ )
118
+ }
119
+ return value
120
+ }
121
+
122
+ const optionalNumber = (table, key, where) => {
123
+ const value = table[key]
124
+ if (value === undefined) return undefined
125
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
126
+ throw invalid(
127
+ `模板包 ${where} 的 \`${key}\` 必須是正數的 CSS 像素值,得到 ${JSON.stringify(value)}。`,
128
+ )
129
+ }
130
+ return value
131
+ }
132
+
133
+ const optionalString = (table, key, where, fallback) => {
134
+ const value = table[key]
135
+ if (value === undefined) return fallback
136
+ if (typeof value !== 'string') {
137
+ throw invalid(`模板包 ${where} 的 \`${key}\` 必須是字串,得到 ${JSON.stringify(value)}。`)
138
+ }
139
+ return value
140
+ }
141
+
142
+ /**
143
+ * The 文體's own knobs, as this package turns them (CONTRACT F2b G1/G7).
144
+ *
145
+ * A pure-data package must be able to turn every knob its 文體 declares —
146
+ * `rail = true` in a `manifest.toml` is the whole of 「第三方也能有側欄」, and a
147
+ * loader that only understood the two canvas dimensions would have made the
148
+ * knob a privilege of the two templates that ship inside the SDK.
149
+ *
150
+ * Which knobs exist is asked of the layout, never listed here: this layer knows
151
+ * the store, not the 文體 axis. An unregistered layout yields nothing and the
152
+ * render path refuses the package by name a moment later
153
+ * (`KSB_LAYOUT_NOT_FOUND`), which is the better error of the two.
154
+ *
155
+ * The type is enforced against the knob's own factory value, because TOML will
156
+ * happily carry `rail = "true"` — a string that is `true` to every branch that
157
+ * ever tests it.
158
+ */
159
+ function knobValues(table, layout, where) {
160
+ const declared = layoutModule(layout)?.[LAYOUT_KNOBS_FIELD]
161
+ if (declared === undefined) return {}
162
+ const turned = {}
163
+ for (const [name, factory] of Object.entries(declared)) {
164
+ const value = table[name]
165
+ if (value === undefined) continue
166
+ if (typeof value !== typeof factory) {
167
+ throw invalid(
168
+ `模板包 ${where} 的旋鈕 \`${name}\` 必須是 ${typeof factory},` +
169
+ `得到 ${JSON.stringify(value)}。`,
170
+ )
171
+ }
172
+ turned[name] = value
173
+ }
174
+ return turned
175
+ }
176
+
177
+ /**
178
+ * The manifest as the render layer's data, refused loudly when it is not.
179
+ *
180
+ * Every refusal names the field, because the failure this guards against is a
181
+ * package that half-loads: a missing `layout` defaulted to the article 文體
182
+ * would draw *some* artifact and call it this template's, which is the same
183
+ * silent-wrong-output shape `manifest.blocks` and the root-form guard exist to
184
+ * refuse (CONTRACT D2).
185
+ */
186
+ export function readPackageManifest({ namespace, name, dir, path }) {
187
+ const where = `${namespace}/${name}`
188
+ if (!existsSync(path)) {
189
+ throw invalid(`模板包 ${where} 缺少 ${PACKAGE_FILES.MANIFEST}(找過:${path})。`)
190
+ }
191
+
192
+ let table
193
+ try {
194
+ table = parseToml(readFileSync(path, 'utf8'))
195
+ } catch (cause) {
196
+ throw invalid(`模板包 ${where} 的 ${PACKAGE_FILES.MANIFEST} 讀不出來:${cause.message}`)
197
+ }
198
+
199
+ if (requireString(table, 'namespace', where) !== namespace || requireString(table, 'name', where) !== name) {
200
+ throw invalid(
201
+ `模板包 ${where} 的 ${PACKAGE_FILES.MANIFEST} 自稱 ` +
202
+ `\`${table.namespace}/${table.name}\`,與所在目錄不符。` +
203
+ '解析鍵取自目錄,宣稱取自檔案——兩者不同就會有一個永遠指不到。',
204
+ )
205
+ }
206
+
207
+ const blocks = table.blocks
208
+ if (!Array.isArray(blocks) || blocks.length === 0 || blocks.some((b) => typeof b !== 'string')) {
209
+ throw invalid(
210
+ `模板包 ${where} 的 \`blocks\` 必須是非空的 block 型別字串陣列——` +
211
+ '它是模板的准入表,空表等於一份畫不出任何東西的模板。',
212
+ )
213
+ }
214
+
215
+ const blockConfig = table.blockConfig
216
+ if (blockConfig !== undefined && (blockConfig === null || typeof blockConfig !== 'object' || Array.isArray(blockConfig))) {
217
+ throw invalid(`模板包 ${where} 的 \`blockConfig\` 必須是 \`[blockConfig.<型別>]\` 具名表。`)
218
+ }
219
+
220
+ // F7b — `[gates]`: which of `lint`'s declared-threshold rules this package
221
+ // opts into, and at what number (SPEC §5.4). The *shape* is judged by the
222
+ // linter's own spec table rather than restated here, for the reason this file
223
+ // gives twice already: two spellings of one rule drift, and the direction they
224
+ // drift in is a manifest that loads with a gate the linter has never heard of.
225
+ const gates = validateGateDeclaration(table.gates, where)
226
+
227
+ const root = optionalString(table, 'root', where, NO_ROOT)
228
+ const layout = requireString(table, 'layout', where)
229
+ return Object.freeze({
230
+ namespace,
231
+ name,
232
+ version: requireString(table, 'version', where),
233
+ description: optionalString(table, 'description', where, ''),
234
+ language: optionalString(table, 'language', where, DEFAULT_LANGUAGE),
235
+ layout,
236
+ root: root === NO_ROOT ? null : root,
237
+ blocks: Object.freeze([...blocks]),
238
+ blockConfig: Object.freeze(blockConfig ?? {}),
239
+ gates,
240
+ ...(optionalNumber(table, 'maxWidth', where) === undefined ? {} : { maxWidth: table.maxWidth }),
241
+ ...(optionalNumber(table, 'measure', where) === undefined ? {} : { measure: table.measure }),
242
+ ...knobValues(table, layout, where),
243
+ })
244
+ }
245
+
246
+ const requireFile = (dir, filename, where, why) => {
247
+ const path = join(dir, filename)
248
+ if (!existsSync(path)) {
249
+ throw invalid(`模板包 ${where} 缺少 ${filename}——${why}(找過:${path})。`)
250
+ }
251
+ return path
252
+ }
253
+
254
+ /** Loaded packages, so one process never imports the same extension twice. */
255
+ const cache = new Map()
256
+
257
+ /** Test hygiene: forget every loaded package (and let extensions be re-registered). */
258
+ export function resetTemplatePackageCache() {
259
+ cache.clear()
260
+ }
261
+
262
+ /**
263
+ * Load one store package into the object `renderWithTemplatePackage` consumes.
264
+ *
265
+ * @returns {Promise<object|undefined>} undefined when no package lives at that key
266
+ */
267
+ export async function loadStoreTemplate(key, env = process.env) {
268
+ const split = splitTemplateKey(key)
269
+ if (split === null) return undefined
270
+ const { namespace, name } = split
271
+ const dir = templatePackageDir(namespace, name, env)
272
+ const path = join(dir, PACKAGE_FILES.MANIFEST)
273
+ if (!existsSync(path)) return undefined
274
+ if (cache.has(dir)) return cache.get(dir)
275
+
276
+ const where = split.key
277
+ const manifest = readPackageManifest({ namespace, name, dir, path })
278
+ const stylesheet = requireFile(
279
+ dir,
280
+ PACKAGE_FILES.STYLESHEET,
281
+ where,
282
+ '模板包的皮是必需品,沒有它的產物只是一份沒有樣式的骨架',
283
+ )
284
+ // The 預設 md is part of the format, not a courtesy (issue 14 補充裁定):
285
+ // 「預設長怎樣」歸模板, and its carrier is the starter document that renders
286
+ // into the template's own default appearance. A package without one cannot
287
+ // answer the question it exists to answer.
288
+ const source = requireFile(
289
+ dir,
290
+ PACKAGE_FILES.DEFAULT_DOC,
291
+ where,
292
+ '預設 md 是「這個模板長什麼樣」的唯一載體,缺了它包就沒有開箱樣貌',
293
+ )
294
+
295
+ const { overrides, plugins, chrome } = await loadPackageExtensions({ dir, key: where })
296
+
297
+ const template = Object.freeze({
298
+ manifest,
299
+ key: where,
300
+ // F7a: a store package can fill its 文體's chrome slots too (`chrome.js`).
301
+ // This used to be the literal `{}` — a hard-coded claim that only a bundled
302
+ // template may have a masthead, which made the first serious 臨摹 come out
303
+ // headless while the factory skin it replaced kept its header.
304
+ chrome,
305
+ language: manifest.language,
306
+ /** A pure-data package ships no fonts: nothing to subset, nothing to embed. */
307
+ fonts: Object.freeze([]),
308
+ styles: () => readFileSync(stylesheet, 'utf8'),
309
+ overrides,
310
+ plugins,
311
+ dir,
312
+ source,
313
+ })
314
+ cache.set(dir, template)
315
+ return template
316
+ }
317
+
318
+ /** The store as a template source the render layer can consult (CONTRACT D1). */
319
+ export const storeTemplateSource = (env = process.env) =>
320
+ Object.freeze({
321
+ keys: () => storeTemplateKeys(env),
322
+ load: (key) => loadStoreTemplate(key, env),
323
+ })
324
+
325
+ /**
326
+ * The store as a **layout index** the parser can consult (CONTRACT F2f E4).
327
+ *
328
+ * It answers one question and reads one field: which 文體 does each installed
329
+ * package ride. That is what turns `---` into a page break for a store deck
330
+ * package — the half F3 deferred (`src/layouts/template-index.js` carries the
331
+ * reasoning).
332
+ *
333
+ * Two packages are deliberately left out of the answer:
334
+ *
335
+ * - one whose manifest will not read. It cannot be drawn with either, and
336
+ * `loadStoreTemplate` will say so by name the moment anybody tries; letting
337
+ * the exception out here would break the parse of an unrelated document
338
+ * because of a broken package the document never mentions.
339
+ * - one squatting on a factory namespace (封緘 F2). `resolveTemplate` refuses
340
+ * those, so treating one as a deck template would split a document's pages
341
+ * for a key that then cannot draw them.
342
+ */
343
+ export const storeLayoutSource = (env = process.env) =>
344
+ Object.freeze({
345
+ entries: () =>
346
+ packageDirs(env).flatMap((pkg) => {
347
+ if (isReservedKey(pkg.key)) return []
348
+ try {
349
+ return [{ key: pkg.key, layout: readPackageManifest(pkg).layout }]
350
+ } catch {
351
+ return []
352
+ }
353
+ }),
354
+ })
355
+
356
+ /**
357
+ * Wire the store into the resolution chain.
358
+ *
359
+ * The render layer owns the chain and knows nothing about `~/.kamishibai`; this
360
+ * layer owns the store and knows nothing about how a template is drawn. Calling
361
+ * this is what joins them, and every command that can resolve a template key
362
+ * calls it — `test_f3_store_is_wired_into_every_render_path` is the gate that
363
+ * says so, because a path that forgot would silently answer
364
+ * `KSB_TEMPLATE_NOT_FOUND` for a package sitting right there on disk, which is
365
+ * the exact failure this slice exists to remove.
366
+ */
367
+ export function installTemplateStore(env = process.env) {
368
+ resetTemplateSources()
369
+ registerTemplateSource(storeTemplateSource(env))
370
+ // The same act, for the other consumer: the parser has to know which 文體 an
371
+ // installed package rides before it decides what `---` means. Wiring the two
372
+ // together here is what keeps them from disagreeing — a command that had the
373
+ // loader but not the index would draw a deck package as one long slide.
374
+ resetTemplateLayoutSources()
375
+ registerTemplateLayoutSource(storeLayoutSource(env))
376
+ }
@@ -0,0 +1,240 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { CODES, EXIT, KsbError } from '../core/errors.js'
4
+ import { writeFileAtomic } from './atomic.js'
5
+ import { templatePackageDir, templatesRoot } from './home.js'
6
+ import {
7
+ DRAFT_NAMESPACE,
8
+ MANIFEST_FILENAME,
9
+ PACKAGE_FILES,
10
+ isDraftKey,
11
+ splitTemplateKey,
12
+ } from './template-format.js'
13
+ import { parseToml, stringifyToml } from './toml.js'
14
+
15
+ /**
16
+ * `promote` — the last step of the 臨摹 loop (CONTRACT F7a H1).
17
+ *
18
+ * `init draft/<名>` opens a workspace; everything between it and here is the
19
+ * Agent's own reading-and-writing (H2's Not-building: the SDK grows no
20
+ * image-parsing hand). What the SDK owes the workflow is its *end*: the moment
21
+ * a sketch stops being a sketch has to be a mechanical move with a receipt, not
22
+ * a `mv` somebody types and a manifest they remember to edit.
23
+ *
24
+ * Remembering is exactly what fails. The package directory and the manifest's
25
+ * own `namespace`/`name` are two statements of one fact, and a hand-moved
26
+ * package leaves them disagreeing: the store lists it at its new key (that is
27
+ * read off the path) while the manifest — the thing `readPackage` prefers, and
28
+ * the thing a human opens — still names `draft/`. So the rewrite travels with
29
+ * the move, in one function, and neither can happen without the other.
30
+ */
31
+
32
+ const refuse = (code, message, exitCode, path) =>
33
+ new KsbError({ code, message, exitCode, ...(path === undefined ? {} : { path }) })
34
+
35
+ /**
36
+ * The key a caller wrote, split — or a usage refusal naming which of the two
37
+ * arguments was malformed.
38
+ *
39
+ * Shape is judged before anything touches the disk, per the same 複驗 F-2 rule
40
+ * `validateScaffoldRequest` records: a command that rejects your input must
41
+ * leave the store exactly as it found it, and a validation that runs after the
42
+ * side effects is an epilogue rather than a gate.
43
+ */
44
+ const requireKey = (key, role) => {
45
+ const split = splitTemplateKey(key)
46
+ if (split === null) {
47
+ throw refuse(
48
+ CODES.USAGE,
49
+ `${role} "${key}" 不是 \`<namespace>/<name>\` 形;兩段皆須以英數開頭,` +
50
+ '其後只准英數、`_` 與 `-`(它們會直接成為庫裡的目錄名)。',
51
+ EXIT.USAGE,
52
+ )
53
+ }
54
+ return split
55
+ }
56
+
57
+ /** Every file and directory sitting under a package directory, recursively empty-safe. */
58
+ const isEmptyDir = (dir) => existsSync(dir) && readdirSync(dir).length === 0
59
+
60
+ /**
61
+ * Everything judgeable without writing (CONTRACT F7a H1 三拒絕面).
62
+ *
63
+ * The three refusals are three *different* mistakes and carry three different
64
+ * codes, because an agent keying on the code has to be able to tell "I named a
65
+ * package that is not there" from "I aimed at a name somebody already owns":
66
+ *
67
+ * - 來源非 `draft/` → `KSB_USAGE`. Not a missing thing, a wrong kind of thing.
68
+ * `promote` is the workspace's exit; asked to move a package that was never
69
+ * in the workspace it has no meaning to execute, and silently doing a rename
70
+ * would make the command's name a lie.
71
+ * - 來源不存在 → `KSB_TEMPLATE_NOT_FOUND`, the code the render path already
72
+ * raises for a key with nothing behind it. One spelling of one refusal.
73
+ * - 目標撞名 → `KSB_TEMPLATE_PACKAGE_EXISTS`, the same code `init` raises for
74
+ * the same fact, and refused for the same reason it is there: the occupied
75
+ * directory may hold a stylesheet somebody is editing, and a `promote` that
76
+ * merged into it would destroy work while reporting success.
77
+ *
78
+ * A reserved target is refused on top of the three, because the alternative is
79
+ * `promote` cheerfully manufacturing the one thing `init` and `resolveTemplate`
80
+ * both exist to prevent — a package at `kami/<名>` that lists as official and
81
+ * can never be loaded. A `draft/` target is refused too: that is a rename, and
82
+ * a command called `promote` that quietly performs one is worse than one that
83
+ * says it does not do that.
84
+ *
85
+ * @param {{from: string, to: string, reserved?: readonly string[], env?: object}} request
86
+ * @returns {{source: object, target: object, sourceDir: string, targetDir: string}}
87
+ */
88
+ export function validatePromoteRequest({ from, to, reserved = [], env = process.env }) {
89
+ const source = requireKey(from, '來源模板鍵')
90
+ const target = requireKey(to, '目標模板鍵')
91
+
92
+ if (!isDraftKey(source.key)) {
93
+ throw refuse(
94
+ CODES.USAGE,
95
+ `promote 只搬 \`${DRAFT_NAMESPACE}/\` 底下的臨摹草稿,而來源是 ${source.key}。` +
96
+ '轉正是工作區的出口,不是通用的改名指令——' +
97
+ '對一個本來就不在工作區裡的包執行它,沒有任何一件事會被「轉正」。',
98
+ EXIT.USAGE,
99
+ 'doc.meta.template',
100
+ )
101
+ }
102
+
103
+ if (target.namespace === DRAFT_NAMESPACE) {
104
+ throw refuse(
105
+ CODES.USAGE,
106
+ `promote 的目標不得留在 \`${DRAFT_NAMESPACE}/\`(收到 ${target.key}):` +
107
+ '那是改名,不是轉正。要換草稿名字請自己搬目錄。',
108
+ EXIT.USAGE,
109
+ 'doc.meta.template',
110
+ )
111
+ }
112
+
113
+ if (reserved.includes(target.namespace)) {
114
+ throw refuse(
115
+ CODES.TEMPLATE_NAMESPACE_RESERVED,
116
+ `\`${target.namespace}\` 是出廠保留的 namespace(保留:${[...reserved].join(', ')}),` +
117
+ '不得作為轉正目標——冒用出廠名的包在列表上與出廠包無從分辨,而且永遠載不起來。',
118
+ EXIT.VALIDATION,
119
+ 'doc.meta.template',
120
+ )
121
+ }
122
+
123
+ const sourceDir = templatePackageDir(source.namespace, source.name, env)
124
+ const targetDir = templatePackageDir(target.namespace, target.name, env)
125
+
126
+ if (!existsSync(join(sourceDir, MANIFEST_FILENAME))) {
127
+ throw refuse(
128
+ CODES.TEMPLATE_NOT_FOUND,
129
+ `找不到臨摹草稿 ${source.key}(${sourceDir} 沒有 ${MANIFEST_FILENAME})。` +
130
+ `草稿由 \`kamishibai init ${source.key}\` 開出來,用 \`kamishibai templates\` 看現有的有哪些。`,
131
+ EXIT.VALIDATION,
132
+ 'doc.meta.template',
133
+ )
134
+ }
135
+
136
+ // 三件套**任一**存在即拒,與 `scaffoldTemplatePackage` 同一條(複驗 F-3):
137
+ // 只看 manifest.toml 的話,一個「manifest 被刪、stylesheet 還在」的半成品
138
+ // 目錄會被當成空地,而那正是最可能有人正在改的狀態。
139
+ const occupied = [PACKAGE_FILES.MANIFEST, PACKAGE_FILES.STYLESHEET, PACKAGE_FILES.DEFAULT_DOC]
140
+ .filter((filename) => existsSync(join(targetDir, filename)))
141
+ if (occupied.length > 0) {
142
+ throw refuse(
143
+ CODES.TEMPLATE_PACKAGE_EXISTS,
144
+ `模板包 ${target.key} 的目錄已經有東西了(${targetDir}):${occupied.join('、')}。` +
145
+ 'promote 不覆寫既有的檔案:裡面的 stylesheet 與預設 md 可能是有人正在改的東西。',
146
+ EXIT.VALIDATION,
147
+ )
148
+ }
149
+
150
+ return { source, target, sourceDir, targetDir }
151
+ }
152
+
153
+ /**
154
+ * Rewrite the moved package's own account of its identity.
155
+ *
156
+ * Parsed and re-emitted rather than string-replaced: `draft` is a plausible
157
+ * substring of a description, a block name or a stylesheet path, and a textual
158
+ * swap would rewrite whichever of them happened to match. The writer preserves
159
+ * insertion order, so every other key comes back byte-for-byte where it was.
160
+ *
161
+ * `name` travels with `namespace` because the two are one fact with the
162
+ * directory: `promote draft/x acme/y` that rewrote only the namespace would
163
+ * leave a manifest calling itself `acme/x` inside a directory called `acme/y`,
164
+ * and the listing (which reads the manifest) and the resolver (which reads the
165
+ * path) would then disagree about what is installed.
166
+ */
167
+ const rewriteIdentity = (dir, target) => {
168
+ const path = join(dir, MANIFEST_FILENAME)
169
+ let table
170
+ try {
171
+ table = parseToml(readFileSync(path, 'utf8'))
172
+ } catch (cause) {
173
+ throw refuse(
174
+ CODES.TEMPLATE_PACKAGE_INVALID,
175
+ `轉正後無法解析 ${path}:${cause.message}。` +
176
+ '包已經搬到新位置,但 manifest 讀不動——請手動修好 manifest 再列一次 templates。',
177
+ EXIT.VALIDATION,
178
+ 'doc.meta.template',
179
+ )
180
+ }
181
+ writeFileAtomic(path, stringifyToml({ ...table, namespace: target.namespace, name: target.name }))
182
+ return path
183
+ }
184
+
185
+ /**
186
+ * Move one 臨摹 draft out of the workspace and into a real namespace.
187
+ *
188
+ * Zero residue is a named acceptance face rather than a side effect: a
189
+ * `promote` that left the draft in place would leave two packages claiming to
190
+ * be the same skin, and the next `render -t draft/<名>` would keep drawing the
191
+ * stale one — which looks exactly like the promotion never happening, except
192
+ * that it did. So the source directory is gone when this returns, and the
193
+ * emptied `draft/` namespace directory goes with it.
194
+ *
195
+ * @param {{from: string, to: string, reserved?: readonly string[], env?: object}} input
196
+ * @returns {{from: string, to: string, dir: string, manifest: string, source: string}}
197
+ */
198
+ export function promoteTemplatePackage({ from, to, reserved = [], env = process.env }) {
199
+ const { source, target, sourceDir, targetDir } = validatePromoteRequest({
200
+ from,
201
+ to,
202
+ reserved,
203
+ env,
204
+ })
205
+
206
+ try {
207
+ // The target namespace directory need not exist yet — `common-dev/` is
208
+ // brand new the first time anything is promoted into it, and `rename` will
209
+ // not create a parent for you.
210
+ mkdirSync(dirname(targetDir), { recursive: true })
211
+ renameSync(sourceDir, targetDir)
212
+ } catch (cause) {
213
+ throw refuse(
214
+ CODES.WRITE_FAILED,
215
+ `無法把 ${source.key} 搬到 ${target.key}(${sourceDir} → ${targetDir}):${cause.message}。`,
216
+ EXIT.VALIDATION,
217
+ )
218
+ }
219
+
220
+ const manifest = rewriteIdentity(targetDir, target)
221
+
222
+ // 零殘留:草稿的 namespace 目錄空了就一併收掉。`packageDirs` 不看沒有 manifest
223
+ // 的目錄,所以留著不會出現在列表上——但它會出現在 `ls`,而一個空的 `draft/`
224
+ // 讀起來像「還有草稿沒清」。失敗不致命(別人可能正好在裡面開新草稿),所以
225
+ // 這一步不擋成功的搬移。
226
+ try {
227
+ const draftRoot = join(templatesRoot(env), source.namespace)
228
+ if (isEmptyDir(draftRoot)) rmdirSync(draftRoot)
229
+ } catch {
230
+ // 目錄非空或被別人握著:轉正本身已經完成,這只是清掃。
231
+ }
232
+
233
+ return {
234
+ from: source.key,
235
+ to: target.key,
236
+ dir: targetDir,
237
+ manifest,
238
+ source: join(targetDir, PACKAGE_FILES.DEFAULT_DOC),
239
+ }
240
+ }