@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,181 @@
1
+ import { existsSync, mkdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { CODES, EXIT, KsbError } from '../core/errors.js'
4
+ import { writeFileAtomic } from './atomic.js'
5
+ import { knownLayouts, layoutModule } from '../layouts/index.js'
6
+ import { templatePackageDir } from './home.js'
7
+ import { DEFAULT_LANGUAGE, PACKAGE_FILES, splitTemplateKey } from './template-format.js'
8
+ import { SCAFFOLD_BLUEPRINTS, SCAFFOLD_SUPPORTED_LAYOUTS } from './scaffold-blueprints.js'
9
+ import { stringifyToml } from './toml.js'
10
+
11
+ export { SCAFFOLD_BLUEPRINTS, SCAFFOLD_SUPPORTED_LAYOUTS }
12
+
13
+ /**
14
+ * The scaffold (CONTRACT D4; wayfinder issue 13 裁決 7「必須有模板初始化腳手架」).
15
+ *
16
+ * The scaffold exists because the pure-data path is only *real* if someone can
17
+ * start on it. A format whose entry cost is "hand-write a TOML manifest with
18
+ * the right key names, guess which 文體 knobs exist, and hope the stylesheet's
19
+ * geometry markers are spelled the way the injector expects" is a format that
20
+ * everyone will fork an existing package to avoid — and then the pure-data
21
+ * default path is a claim rather than a practice.
22
+ *
23
+ * So what this writes is not a demo: it is a package that **renders on the way
24
+ * out**, and the contract pins exactly that (`render <包內預設 md> -t <該包>`
25
+ * succeeding, `lint` exit 0). The three files are the whole format: the
26
+ * declaration plus applied state, the skin, and the 預設 md that shows what the
27
+ * two of them look like when nobody has written any content yet.
28
+ */
29
+
30
+ /** The 文體 a scaffolded package rides unless the caller names another. */
31
+ export const SCAFFOLD_LAYOUT = 'article'
32
+
33
+ /**
34
+ * `path` follows the code, not the command (CONTRACT Verbatim 四新碼 path).
35
+ *
36
+ * A namespace refusal is *about the template key* wherever it is raised, so it
37
+ * carries `doc.meta.template` here exactly as it does on the render path — an
38
+ * agent matching on the pair (code, path) must not have to learn that this one
39
+ * command spells the same refusal differently. `KSB_TEMPLATE_PACKAGE_EXISTS`
40
+ * keeps the root path because it is about the store, not about a key in a
41
+ * document that does not exist yet.
42
+ */
43
+ const refuse = (code, message, path) =>
44
+ new KsbError({ code, message, ...(path === undefined ? {} : { path }), exitCode: EXIT.VALIDATION })
45
+
46
+ const scaffoldManifest = ({ namespace, name, layout, engine, blueprint }) =>
47
+ stringifyToml({
48
+ name,
49
+ namespace,
50
+ version: '0.1.0',
51
+ engine,
52
+ root: blueprint.root,
53
+ blocks: [...blueprint.blocks],
54
+ layout,
55
+ language: DEFAULT_LANGUAGE,
56
+ description: `${namespace}/${name} — kamishibai 模板包骨架`,
57
+ maxWidth: blueprint.maxWidth,
58
+ measure: blueprint.measure,
59
+ // 轉子/<型別>/config: the callout labels are *wording*, and wording is this
60
+ // template's business rather than the SDK's (CONTRACT A7). A scaffold that
61
+ // omitted them would render callouts with empty labels and look broken.
62
+ blockConfig: { callout: { labels: { note: 'NOTE', warn: 'WARNING' } } },
63
+ })
64
+
65
+ /**
66
+ * Everything that can be judged **without touching the filesystem** (複驗 F-2).
67
+ *
68
+ * Separated out and called first because ordering is itself a guarantee here.
69
+ * `initCommand` used to create the store root and register the factory packages
70
+ * *before* the key was ever looked at, so `init '../evil'` on a fresh machine
71
+ * left a whole `~/.kamishibai` behind on its way to refusing — a rejected
72
+ * command that still changed the world. Validation that runs after the side
73
+ * effects is not validation, it is an epilogue.
74
+ *
75
+ * @param {{key: string, layout?: string, reserved?: readonly string[]}} request
76
+ * @returns {{namespace: string, name: string, key: string, layout: string}}
77
+ */
78
+ export function validateScaffoldRequest({ key, layout = SCAFFOLD_LAYOUT, reserved = [] }) {
79
+ const split = splitTemplateKey(key)
80
+ if (split === null) {
81
+ throw new KsbError({
82
+ code: CODES.USAGE,
83
+ message:
84
+ `模板鍵 "${key}" 不是 \`<namespace>/<name>\` 形;兩段皆須以英數開頭,` +
85
+ '其後只准英數、`_` 與 `-`(它們會直接成為庫裡的目錄名)。',
86
+ exitCode: EXIT.USAGE,
87
+ })
88
+ }
89
+
90
+ if (reserved.includes(split.namespace)) {
91
+ throw refuse(
92
+ CODES.TEMPLATE_NAMESPACE_RESERVED,
93
+ `\`${split.namespace}\` 是出廠保留的 namespace(保留:${[...reserved].join(', ')}),` +
94
+ '不得用來建立模板包——冒用出廠名的包在列表上與出廠包無從分辨。',
95
+ 'doc.meta.template',
96
+ )
97
+ }
98
+
99
+ // 未註冊的文體走既有的碼與既有的 path(F2a Verbatim:KSB_LAYOUT_NOT_FOUND/`doc`)。
100
+ // 它與「註冊了但腳手架還沒支援」是兩件不同的事,混成同一個碼會讓人分不清
101
+ // 「我打錯了文體名」與「這個文體還沒輪到」。
102
+ if (layoutModule(layout) === undefined) {
103
+ throw refuse(
104
+ CODES.LAYOUT_NOT_FOUND,
105
+ `layout \`${layout}\` 沒有人註冊;已註冊的文體:${knownLayouts().join(', ')}。`,
106
+ 'doc',
107
+ )
108
+ }
109
+
110
+ if (!SCAFFOLD_SUPPORTED_LAYOUTS.includes(layout)) {
111
+ throw new KsbError({
112
+ code: CODES.USAGE,
113
+ message:
114
+ `腳手架尚未支援文體 \`${layout}\`;目前支援:${SCAFFOLD_SUPPORTED_LAYOUTS.join(', ')}。` +
115
+ '骨架包附的預設 md 是照文體寫的,硬生出來的包畫不出東西,' +
116
+ '而它印給你的那句「開箱即用」也會是假的。',
117
+ exitCode: EXIT.USAGE,
118
+ })
119
+ }
120
+
121
+ return { ...split, layout }
122
+ }
123
+
124
+ /**
125
+ * Write a fresh package into the store.
126
+ *
127
+ * Refuses an occupied directory rather than merging into it: a package the user
128
+ * already has is a protected asset by the same rule the store itself is
129
+ * (CONTRACT B2「已存在→不動既有檔案」), and a scaffold that half-overwrote one
130
+ * would destroy the stylesheet somebody had been editing.
131
+ *
132
+ * @param {{key: string, layout?: string, engine: string, reserved?: readonly string[],
133
+ * env?: object}} input
134
+ * @returns {{template: string, dir: string, files: string[], source: string}}
135
+ */
136
+ export function scaffoldTemplatePackage({ key, layout, engine, reserved = [], env = process.env }) {
137
+ const validated = validateScaffoldRequest({ key, layout, reserved })
138
+ const { namespace, name } = validated
139
+ layout = validated.layout
140
+
141
+ const dir = templatePackageDir(namespace, name, env)
142
+ // 三件套**任一**存在即拒(複驗 F-3)。原本只看 manifest.toml,於是一個
143
+ // 「manifest 被刪掉、stylesheet 與預設 md 還在」的半成品目錄會被當成空地,
144
+ // 兩個檔案就地被覆寫——而那正是最可能有人正在改的狀態。
145
+ const occupied = [PACKAGE_FILES.MANIFEST, PACKAGE_FILES.STYLESHEET, PACKAGE_FILES.DEFAULT_DOC]
146
+ .filter((filename) => existsSync(join(dir, filename)))
147
+ if (occupied.length > 0) {
148
+ throw refuse(
149
+ CODES.TEMPLATE_PACKAGE_EXISTS,
150
+ `模板包 ${validated.key} 的目錄已經有東西了(${dir}):${occupied.join('、')}。` +
151
+ '腳手架不覆寫既有的檔案:裡面的 stylesheet 與預設 md 可能是有人正在改的東西。',
152
+ )
153
+ }
154
+
155
+ // Guaranteed present: `validateScaffoldRequest` refuses any 文體 this table
156
+ // has no blueprint for, which is the same list `-l` is narrowed to.
157
+ const blueprint = SCAFFOLD_BLUEPRINTS[layout]
158
+ const files = [
159
+ [PACKAGE_FILES.MANIFEST, scaffoldManifest({ namespace, name, layout, engine, blueprint })],
160
+ [PACKAGE_FILES.STYLESHEET, blueprint.stylesheet()],
161
+ [PACKAGE_FILES.DEFAULT_DOC, blueprint.document({ namespace, name })],
162
+ ]
163
+
164
+ try {
165
+ mkdirSync(dir, { recursive: true })
166
+ for (const [filename, text] of files) writeFileAtomic(join(dir, filename), text)
167
+ } catch (cause) {
168
+ throw new KsbError({
169
+ code: CODES.WRITE_FAILED,
170
+ message: `could not write template package at ${dir}: ${cause.message}`,
171
+ exitCode: EXIT.VALIDATION,
172
+ })
173
+ }
174
+
175
+ return {
176
+ template: validated.key,
177
+ dir,
178
+ files: files.map(([filename]) => filename),
179
+ source: join(dir, PACKAGE_FILES.DEFAULT_DOC),
180
+ }
181
+ }
@@ -0,0 +1,192 @@
1
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { CODES, EXIT, KsbError } from '../core/errors.js'
4
+ import { writeFileAtomic } from './atomic.js'
5
+ import { templatePackageDir } from './home.js'
6
+ import { MANIFEST_FILENAME, NO_ROOT } from './template-format.js'
7
+ import { packageDirs, packageHasCode } from './template-package.js'
8
+ import { parseToml, stringifyToml } from './toml.js'
9
+
10
+ export { MANIFEST_FILENAME, NO_ROOT }
11
+
12
+ /**
13
+ * The template namespace inside the central store (CONTRACT E3, VC2).
14
+ *
15
+ * Until now a template was a JS module the engine happened to bundle. That is
16
+ * enough to *render* with, and not enough to be a **package**: nothing on disk
17
+ * said which templates this installation has, what versions they are, or what
18
+ * they can draw. `templates/<namespace>/<name>/manifest.toml` is that record —
19
+ * the probe surface an Agent (or a human, or the next slice's `init`/`import`)
20
+ * can read without importing the engine.
21
+ *
22
+ * The manifest mirrors the JS manifest rather than replacing it: the module is
23
+ * still the single source, and the TOML is a faithful projection of it, so the
24
+ * two can be diffed field by field instead of drifting.
25
+ */
26
+
27
+ /**
28
+ * Verbatim CONTRACT constant — manifest.toml keys, in output order.
29
+ * Order is part of the byte-consistency claim, so it lives here, once.
30
+ *
31
+ * The first six are E3's and may never move: they are the pinned projection.
32
+ * The rest are F3's, appended — and appended because the manifest stopped being
33
+ * a *description* of a template and became the instruction that loads one. A
34
+ * package whose descriptor omits its 文體 and its applied state is a package
35
+ * only the engine that bundled it can draw with, which is the ledger/loading
36
+ * point split this slice closes. Optional keys are written only when the
37
+ * manifest carries them, so the emitted key list is always a subsequence of
38
+ * this one.
39
+ */
40
+ export const MANIFEST_KEYS = Object.freeze([
41
+ 'name',
42
+ 'namespace',
43
+ 'version',
44
+ 'engine',
45
+ 'root',
46
+ 'blocks',
47
+ 'layout',
48
+ 'language',
49
+ 'description',
50
+ 'maxWidth',
51
+ 'measure',
52
+ 'blockConfig',
53
+ ])
54
+
55
+ /** The six E3 pinned as the projection, before F3 appended to it. */
56
+ export const MANIFEST_KEYS_E3 = Object.freeze(MANIFEST_KEYS.slice(0, 6))
57
+
58
+ const writeFailed = (path, cause) =>
59
+ new KsbError({
60
+ code: CODES.WRITE_FAILED,
61
+ message: `could not write template package at ${path}: ${cause.message}`,
62
+ exitCode: EXIT.VALIDATION,
63
+ })
64
+
65
+ const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value)
66
+ const isNonEmptyString = (value) => typeof value === 'string' && value.length > 0
67
+ const isTable = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
68
+
69
+ /** Keys carried only when the manifest actually has them, in MANIFEST_KEYS order. */
70
+ const OPTIONAL_PROJECTION = Object.freeze([
71
+ ['language', isNonEmptyString],
72
+ ['description', isNonEmptyString],
73
+ ['maxWidth', isFiniteNumber],
74
+ ['measure', isFiniteNumber],
75
+ ['blockConfig', (value) => isTable(value) && Object.keys(value).length > 0],
76
+ ])
77
+
78
+ /**
79
+ * Project a JS template manifest onto the TOML table (CONTRACT E3, widened by F3).
80
+ *
81
+ * Still a *projection*, not a second source: the module remains the truth and
82
+ * the TOML mirrors it field by field so the two can be diffed rather than
83
+ * drift. F3 only widens what gets mirrored — the 文體 the template rides and
84
+ * the applied state it turns that 文體's knobs to — because those are the
85
+ * fields a *loader* needs and a *listing* did not.
86
+ *
87
+ * An absent optional field is omitted rather than written as an empty value:
88
+ * `maxWidth = 0` would be a legible number that happens to be a lie, and a
89
+ * reader has no way to tell it apart from a template that meant it.
90
+ *
91
+ * @param {object} manifest
92
+ * @param {string} engine engine version this package was registered by
93
+ */
94
+ export function manifestTable(manifest, engine) {
95
+ if (!isNonEmptyString(manifest.layout)) {
96
+ throw new KsbError({
97
+ code: CODES.TEMPLATE_PACKAGE_INVALID,
98
+ message:
99
+ `模板 ${manifest.namespace}/${manifest.name} 沒有宣告所屬 layout,無法投影成可載入的包。`,
100
+ // Same code, same path, wherever it is raised (CONTRACT Verbatim 四新碼 path):
101
+ // an agent keying on (code, path) must not meet two spellings of one refusal.
102
+ path: 'doc.meta.template',
103
+ exitCode: EXIT.VALIDATION,
104
+ })
105
+ }
106
+ const table = {
107
+ name: manifest.name,
108
+ namespace: manifest.namespace,
109
+ version: manifest.version,
110
+ engine,
111
+ root: manifest.root ?? NO_ROOT,
112
+ blocks: [...manifest.blocks],
113
+ layout: manifest.layout,
114
+ }
115
+ for (const [key, ok] of OPTIONAL_PROJECTION) {
116
+ if (ok(manifest[key])) table[key] = manifest[key]
117
+ }
118
+ return table
119
+ }
120
+
121
+ /**
122
+ * Register (or refresh) one built-in template package.
123
+ *
124
+ * The write is skipped when the bytes already match: registration runs on every
125
+ * render, and rewriting an identical file would churn mtimes inside a store
126
+ * whose whole purpose is to be a stable record.
127
+ *
128
+ * @returns {{namespace: string, name: string, version: string, root: string,
129
+ * path: string, written: boolean}}
130
+ */
131
+ export function registerTemplatePackage({ manifest, engine, env = process.env }) {
132
+ const table = manifestTable(manifest, engine)
133
+ const dir = templatePackageDir(manifest.namespace, manifest.name, env)
134
+ const path = join(dir, MANIFEST_FILENAME)
135
+ const text = stringifyToml(table)
136
+
137
+ let written = false
138
+ try {
139
+ if (!existsSync(path) || readFileSync(path, 'utf8') !== text) {
140
+ mkdirSync(dir, { recursive: true })
141
+ writeFileAtomic(path, text)
142
+ written = true
143
+ }
144
+ } catch (cause) {
145
+ throw writeFailed(path, cause)
146
+ }
147
+
148
+ return {
149
+ namespace: table.namespace,
150
+ name: table.name,
151
+ version: table.version,
152
+ root: table.root,
153
+ path,
154
+ written,
155
+ }
156
+ }
157
+
158
+ const readPackage = ({ namespace, name, dir, path }) => {
159
+ const hasCode = packageHasCode(dir)
160
+ let table
161
+ try {
162
+ table = parseToml(readFileSync(path, 'utf8'))
163
+ } catch {
164
+ // An unreadable manifest is still a package on disk: dropping it silently
165
+ // would make `templates` disagree with the filesystem, which is the one
166
+ // thing this listing exists to prevent.
167
+ return { namespace, name, version: '', root: NO_ROOT, layout: '', hasCode, path }
168
+ }
169
+ return {
170
+ namespace: typeof table.namespace === 'string' ? table.namespace : namespace,
171
+ name: typeof table.name === 'string' ? table.name : name,
172
+ version: typeof table.version === 'string' ? table.version : '',
173
+ root: typeof table.root === 'string' ? table.root : NO_ROOT,
174
+ // The 文體 a package rides is now the first thing a reader needs from a
175
+ // listing: since F2a it decides the shape of the page, and since F3 a
176
+ // package that names one nobody registered is a package that will not draw.
177
+ layout: typeof table.layout === 'string' ? table.layout : '',
178
+ hasCode,
179
+ path,
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Every registered package, sorted by `<namespace>/<name>` so the listing is
185
+ * deterministic regardless of directory-read order.
186
+ *
187
+ * @returns {Array<{namespace: string, name: string, version: string, root: string,
188
+ * layout: string, hasCode: boolean, path: string}>}
189
+ */
190
+ export function readTemplatePackages(env = process.env) {
191
+ return packageDirs(env).map(readPackage)
192
+ }
@@ -0,0 +1,195 @@
1
+ import { CODES, EXIT, KsbError } from '../core/errors.js'
2
+
3
+ /**
4
+ * A deliberately tiny TOML writer/reader for template package manifests
5
+ * (CONTRACT E3, SPEC §5.2「描述檔格式定為 TOML」).
6
+ *
7
+ * Scope is the whole point: a full TOML implementation would be several hundred
8
+ * lines of surface that nothing in this SDK exercises — and a dependency whose
9
+ * formatting choices we do not control would break the byte-consistency claim
10
+ * the manifest is pinned on. What is written here is read back here, and both
11
+ * halves are asserted against the JS manifest they mirror.
12
+ *
13
+ * F3 widens the alphabet exactly as far as a *loadable* package needs and no
14
+ * further (CONTRACT P2): numbers, because the applied state a template turns its
15
+ * layout's knobs to (`measure`, `maxWidth`) is arithmetic and storing it as a
16
+ * string would push the parsing into every reader; and one level of named
17
+ * tables (`[blockConfig.callout.labels]`), because 轉子/<型別>/config is nested
18
+ * by nature and flattening it would invent a key-mangling convention that only
19
+ * this file understands.
20
+ *
21
+ * Determinism survives the widening, and that is the part with a load-bearing
22
+ * consequence: scalar keys are written in the caller's insertion order, then
23
+ * table sections in theirs, depth first. Arrays keep their element order, and no
24
+ * value is ever re-wrapped or re-flowed. The same manifest therefore serialises
25
+ * to the same bytes on every machine, which is what lets
26
+ * `registerTemplatePackage` skip an identical rewrite.
27
+ */
28
+
29
+ /** The scalar value shapes a manifest may carry. */
30
+ const isStringArray = (value) => Array.isArray(value) && value.every((v) => typeof v === 'string')
31
+
32
+ /**
33
+ * A finite number. `NaN`/`Infinity` have no TOML spelling, and writing one would
34
+ * produce a manifest this parser could not read back — a package that is broken
35
+ * only on the reading side is the drift this module exists to refuse.
36
+ */
37
+ const isNumber = (value) => typeof value === 'number' && Number.isFinite(value)
38
+
39
+ /** A nested table: a plain object, never an array and never null. */
40
+ const isTable = (value) =>
41
+ value !== null && typeof value === 'object' && !Array.isArray(value)
42
+
43
+ const manifestError = (message) =>
44
+ new KsbError({
45
+ code: CODES.PARSE_FAILED,
46
+ message,
47
+ exitCode: EXIT.VALIDATION,
48
+ })
49
+
50
+ /** TOML basic string: only the escapes the manifest alphabet can actually need. */
51
+ const quote = (value) =>
52
+ `"${String(value)
53
+ .replace(/\\/g, '\\\\')
54
+ .replace(/"/g, '\\"')
55
+ .replace(/\n/g, '\\n')
56
+ .replace(/\r/g, '\\r')
57
+ .replace(/\t/g, '\\t')}"`
58
+
59
+ const unquote = (raw) => {
60
+ const body = raw.slice(1, -1)
61
+ return body.replace(/\\(.)/g, (_, c) => {
62
+ if (c === 'n') return '\n'
63
+ if (c === 'r') return '\r'
64
+ if (c === 't') return '\t'
65
+ return c
66
+ })
67
+ }
68
+
69
+ const formatValue = (value) => {
70
+ if (typeof value === 'string') return quote(value)
71
+ if (isNumber(value)) return String(value)
72
+ if (typeof value === 'boolean') return String(value)
73
+ if (isStringArray(value)) return `[${value.map(quote).join(', ')}]`
74
+ throw manifestError(
75
+ `manifest value must be a string, number, boolean, string array or table, got ${typeof value}`,
76
+ )
77
+ }
78
+
79
+ /**
80
+ * Emit one table: its scalars, then its sub-tables, each under its own header.
81
+ *
82
+ * Two rules, both load-bearing:
83
+ *
84
+ * - Scalars come first. A key written after a `[section]` header reads back as
85
+ * belonging to that section — TOML's one genuinely surprising rule, and the
86
+ * one way this writer could produce a file it cannot itself parse back into
87
+ * the table it was given.
88
+ * - A header is written only for a table that carries values of its own, or
89
+ * for an empty leaf. `[blockConfig.callout.labels]` already declares every
90
+ * ancestor on the way down, so emitting `[blockConfig]` and
91
+ * `[blockConfig.callout]` above it adds two empty stanzas that say nothing
92
+ * and read as though something is missing from them. The empty *leaf* is the
93
+ * exception: it is the only place a header carries the whole fact.
94
+ */
95
+ const emitTable = (table, path, lines) => {
96
+ const nested = []
97
+ for (const [key, value] of Object.entries(table)) {
98
+ if (isTable(value)) {
99
+ nested.push([key, value])
100
+ continue
101
+ }
102
+ lines.push(`${key} = ${formatValue(value)}`)
103
+ }
104
+ for (const [key, value] of nested) {
105
+ const child = [...path, key]
106
+ const entries = Object.values(value)
107
+ if (entries.length === 0 || entries.some((v) => !isTable(v))) {
108
+ lines.push('')
109
+ lines.push(`[${child.join('.')}]`)
110
+ }
111
+ emitTable(value, child, lines)
112
+ }
113
+ return lines
114
+ }
115
+
116
+ /**
117
+ * Serialise a table to TOML.
118
+ * @param {Record<string, string|number|boolean|string[]|object>} table
119
+ * @returns {string} newline-terminated TOML text
120
+ */
121
+ export function stringifyToml(table) {
122
+ return `${emitTable(table, [], []).join('\n')}\n`
123
+ }
124
+
125
+ const KEY_LINE = /^([A-Za-z0-9_-]+)\s*=\s*(.+)$/
126
+ const TABLE_HEADER = /^\[([A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*)\]$/
127
+ const STRING_VALUE = /^"(?:[^"\\]|\\.)*"$/
128
+ const NUMBER_VALUE = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/
129
+
130
+ const parseValue = (raw, key) => {
131
+ const text = raw.trim()
132
+ if (STRING_VALUE.test(text)) return unquote(text)
133
+ if (NUMBER_VALUE.test(text)) return Number(text)
134
+ if (text === 'true') return true
135
+ if (text === 'false') return false
136
+ if (text.startsWith('[') && text.endsWith(']')) {
137
+ const inner = text.slice(1, -1).trim()
138
+ if (inner.length === 0) return []
139
+ const parts = inner.split(',').map((p) => p.trim()).filter((p) => p.length > 0)
140
+ for (const part of parts) {
141
+ if (!STRING_VALUE.test(part)) throw manifestError(`array entry of "${key}" is not a string: ${part}`)
142
+ }
143
+ return parts.map(unquote)
144
+ }
145
+ throw manifestError(`value of "${key}" is not a manifest value: ${text}`)
146
+ }
147
+
148
+ /**
149
+ * Walk (creating as needed) to the table a `[a.b.c]` header names.
150
+ *
151
+ * A segment that already holds a scalar is refused rather than overwritten: the
152
+ * file would then say two contradictory things about one key, and whichever the
153
+ * reader happened to keep would be arbitrary.
154
+ */
155
+ const descend = (root, segments, line) => {
156
+ let cursor = root
157
+ for (const segment of segments) {
158
+ const next = cursor[segment]
159
+ if (next === undefined) cursor[segment] = {}
160
+ else if (!isTable(next)) {
161
+ throw manifestError(`line ${line}: \`${segments.join('.')}\` is already a value, not a table`)
162
+ }
163
+ cursor = cursor[segment]
164
+ }
165
+ return cursor
166
+ }
167
+
168
+ /**
169
+ * Parse a TOML table. Anything this writer cannot have produced is a hard error
170
+ * rather than a silently dropped key — a manifest that half-parses is exactly
171
+ * the "template resolves to something unexpected" failure E3 exists to make
172
+ * impossible, and F3 turns that manifest into a *loading* instruction, so a
173
+ * dropped key would now change what gets drawn rather than only what gets
174
+ * listed.
175
+ *
176
+ * @param {string} text
177
+ * @returns {Record<string, string|number|boolean|string[]|object>}
178
+ */
179
+ export function parseToml(text) {
180
+ const table = {}
181
+ let cursor = table
182
+ for (const [index, line] of String(text ?? '').split('\n').entries()) {
183
+ const trimmed = line.trim()
184
+ if (trimmed.length === 0 || trimmed.startsWith('#')) continue
185
+ const header = TABLE_HEADER.exec(trimmed)
186
+ if (header !== null) {
187
+ cursor = descend(table, header[1].split('.'), index + 1)
188
+ continue
189
+ }
190
+ const match = KEY_LINE.exec(trimmed)
191
+ if (match === null) throw manifestError(`line ${index + 1} is not a key/value pair: ${trimmed}`)
192
+ cursor[match[1]] = parseValue(match[2], match[1])
193
+ }
194
+ return table
195
+ }
@@ -0,0 +1,35 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+ import { CODES, EXIT, KsbError } from '../core/errors.js'
4
+
5
+ const write = (outPath, data, encoding) => {
6
+ const absolute = resolve(outPath)
7
+ try {
8
+ mkdirSync(dirname(absolute), { recursive: true })
9
+ writeFileSync(absolute, data, encoding === undefined ? undefined : encoding)
10
+ } catch (cause) {
11
+ throw new KsbError({
12
+ code: CODES.WRITE_FAILED,
13
+ message: `could not write artifact to ${absolute}: ${cause.message}`,
14
+ exitCode: EXIT.VALIDATION,
15
+ })
16
+ }
17
+ return absolute
18
+ }
19
+
20
+ /**
21
+ * Write an artifact to disk, creating parent directories as needed.
22
+ * @returns {{path: string, bytes: number}} absolute path and byte length
23
+ */
24
+ export function writeArtifact(outPath, html) {
25
+ return { path: write(outPath, html, 'utf8'), bytes: Buffer.byteLength(html, 'utf8') }
26
+ }
27
+
28
+ /**
29
+ * Write a binary deliverable (PDF / PPTX / PNG) through the same single writer,
30
+ * so the export chain cannot invent its own error code for a failed write.
31
+ * @returns {{path: string, bytes: number}}
32
+ */
33
+ export function writeBytes(outPath, buffer) {
34
+ return { path: write(outPath, buffer), bytes: buffer.length }
35
+ }