@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,219 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
2
+ import { basename, join } from 'node:path'
3
+ import { engineVersion } from '../core/version.js'
4
+ import { CODES, EXIT, KsbError } from '../core/errors.js'
5
+ import { writeFileAtomic } from './atomic.js'
6
+ import { MARKER_FILENAME, markerPath, projectDir, resolveHome } from './home.js'
7
+
8
+ /**
9
+ * The central artifact store (SPEC §6.1 / §8). `render` and `replay` archive
10
+ * the canonical copy here; `list`, `open` and future replay/re-skin flows read
11
+ * from it. Nothing in this module reaches outside `resolveHome()`.
12
+ */
13
+
14
+ export const ARTIFACT_EXT = '.html'
15
+ /** Sidecar recording where delivery copies of an artifact were written. */
16
+ export const COPIES_SUFFIX = '.copies.json'
17
+
18
+ const markerContent = () => `${MARKER_FILENAME}: kamishibai@${engineVersion()}\n`
19
+
20
+ const writeFailed = (path, cause) =>
21
+ new KsbError({
22
+ code: CODES.WRITE_FAILED,
23
+ message: `could not write to central store at ${path}: ${cause.message}`,
24
+ exitCode: EXIT.VALIDATION,
25
+ })
26
+
27
+ /**
28
+ * CONTRACT B2 — the first-touch safety gate.
29
+ *
30
+ * `~/.kamishibai` is a protected asset: a store that already exists belongs to
31
+ * the user, so we only ever *add* to it. The marker is written exactly once, at
32
+ * creation; back-filling it into an existing store would be a mutation of
33
+ * someone else's directory, and would also lie about who created it.
34
+ *
35
+ * @returns {{root: string, created: boolean}}
36
+ */
37
+ export function ensureStoreRoot(env = process.env) {
38
+ const root = resolveHome(env)
39
+ if (existsSync(root)) return { root, created: false }
40
+ try {
41
+ mkdirSync(root, { recursive: true })
42
+ writeFileSync(markerPath(env), markerContent(), 'utf8')
43
+ } catch (cause) {
44
+ throw writeFailed(root, cause)
45
+ }
46
+ return { root, created: true }
47
+ }
48
+
49
+ /** Compact UTC stamp, e.g. `20260816T195453123Z`. */
50
+ export const archiveStamp = (now = new Date()) => now.toISOString().replace(/[-:.]/g, '')
51
+
52
+ /** How many suffixed names to try before admitting defeat. */
53
+ const MAX_NAME_ATTEMPTS = 1000
54
+
55
+ /** `<slug>`, then `<slug>-<stamp>`, then `<slug>-<stamp>-2`, `-3`, … */
56
+ function* candidateNames(slug, now) {
57
+ yield slug
58
+ const stamped = `${slug}-${archiveStamp(now)}`
59
+ yield stamped
60
+ for (let n = 2; n < MAX_NAME_ATTEMPTS; n += 1) yield `${stamped}-${n}`
61
+ }
62
+
63
+ /**
64
+ * Write the canonical copy under the first name that is free (CONTRACT B2).
65
+ *
66
+ * The exclusive `wx` flag *is* the check: an `existsSync` test followed by a
67
+ * default (truncating) write leaves a window in which two concurrent renders
68
+ * of the same slug both see "free" and the second silently erases the first.
69
+ * Artifacts in the central store are permanent records, so the filesystem —
70
+ * not a prior lookup — has to be the one that says the name was taken.
71
+ */
72
+ const writeExclusive = (dir, slug, html, now) => {
73
+ for (const name of candidateNames(slug, now)) {
74
+ const path = join(dir, `${name}${ARTIFACT_EXT}`)
75
+ try {
76
+ writeFileSync(path, html, { encoding: 'utf8', flag: 'wx' })
77
+ return { name, path }
78
+ } catch (cause) {
79
+ if (cause.code !== 'EEXIST') throw writeFailed(path, cause)
80
+ }
81
+ }
82
+ throw writeFailed(
83
+ join(dir, slug),
84
+ new Error(`no free artifact name after ${MAX_NAME_ATTEMPTS} attempts`),
85
+ )
86
+ }
87
+
88
+ /**
89
+ * Archive the canonical copy of a rendered artifact.
90
+ *
91
+ * @param {{project: string, slug: string, html: string, copies?: string[], env?: object}} input
92
+ * @returns {{name: string, path: string, dir: string}}
93
+ */
94
+ export function archiveArtifact({ project, slug, html, copies = [], env = process.env, now }) {
95
+ ensureStoreRoot(env)
96
+ const dir = projectDir(project, env)
97
+ try {
98
+ mkdirSync(dir, { recursive: true })
99
+ } catch (cause) {
100
+ throw writeFailed(dir, cause)
101
+ }
102
+
103
+ const { name, path } = writeExclusive(dir, slug, html, now)
104
+ mergeCopiesIndex(dir, name, copies, path)
105
+ return { name, path, dir }
106
+ }
107
+
108
+ /**
109
+ * Refresh a canonical copy **that the calling session itself created** with a
110
+ * newer render of the same source (CONTRACT E1, seal patch F1).
111
+ *
112
+ * This is the one sanctioned overwrite of an artifact, and the boundary matters
113
+ * more than the mechanism. The `wx` law above protects *history*: an artifact
114
+ * somebody else's run archived is a record, and records are never rewritten. A
115
+ * live `serve` session's own copy is not history yet — it is the working
116
+ * document being edited, and freezing it at the first render is what let the
117
+ * served IR and the canonical IR drift apart. Comments anchored to a block the
118
+ * canonical did not contain were then unresolvable by the very artifact they
119
+ * were filed against.
120
+ *
121
+ * The caller owns the "did I create this?" judgement; this function only
122
+ * guarantees the write is all-or-nothing (tmp + rename), so a reader — or a
123
+ * `comments add` running at that instant — sees one whole render or the other,
124
+ * never a torn one.
125
+ *
126
+ * @param {string} artifactPath the session's own canonical copy
127
+ * @param {string} html the newer render
128
+ * @returns {string} the artifact path
129
+ */
130
+ export function refreshArtifact(artifactPath, html) {
131
+ try {
132
+ return writeFileAtomic(artifactPath, html)
133
+ } catch (cause) {
134
+ throw writeFailed(artifactPath, cause)
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Record delivery copies against a claimed name, merging with whatever the
140
+ * index already held (CONTRACT B2 — 已存在→不動既有檔案、僅追加).
141
+ *
142
+ * A name freed by a deleted artifact can be re-claimed by `wx`, and its sidecar
143
+ * may outlive the artifact. Writing the new list flat would silently drop the
144
+ * earlier delivery history — the index is a cumulative record, not a snapshot
145
+ * of the last render. Overwriting is only permitted because the content written
146
+ * is a superset of what was there.
147
+ *
148
+ * And because it *is* an overwrite, it goes through the atomic writer (D6): the
149
+ * one legal truncation in the store must still be all-or-nothing, or a crash
150
+ * mid-write turns a cumulative record into an empty one.
151
+ */
152
+ const mergeCopiesIndex = (dir, name, copies, artifactPath) => {
153
+ const merged = [...new Set([...readCopies(dir, name), ...copies])]
154
+ try {
155
+ writeFileAtomic(join(dir, `${name}${COPIES_SUFFIX}`), `${JSON.stringify(merged)}\n`)
156
+ } catch (cause) {
157
+ throw writeFailed(artifactPath, cause)
158
+ }
159
+ }
160
+
161
+ const readCopies = (dir, name) => {
162
+ const sidecar = join(dir, `${name}${COPIES_SUFFIX}`)
163
+ if (!existsSync(sidecar)) return []
164
+ try {
165
+ const parsed = JSON.parse(readFileSync(sidecar, 'utf8'))
166
+ return Array.isArray(parsed) ? parsed.filter((p) => typeof p === 'string') : []
167
+ } catch {
168
+ // A corrupt sidecar must not hide the artifact itself: copies are an index,
169
+ // the artifact is the record.
170
+ return []
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Raw store entries for a project, newest last. HTML is returned so callers can
176
+ * read the embedded IR without this layer needing a parser.
177
+ *
178
+ * @returns {Array<{name: string, path: string, html: string, copies: string[], mtimeMs: number}>}
179
+ */
180
+ export function readEntries({ project, env = process.env }) {
181
+ const dir = projectDir(project, env)
182
+ if (!existsSync(dir)) return []
183
+
184
+ return readdirSync(dir)
185
+ .filter((file) => file.endsWith(ARTIFACT_EXT))
186
+ .map((file) => {
187
+ const path = join(dir, file)
188
+ const name = basename(file, ARTIFACT_EXT)
189
+ return {
190
+ name,
191
+ path,
192
+ html: readFileSync(path, 'utf8'),
193
+ copies: readCopies(dir, name),
194
+ mtimeMs: statSync(path).mtimeMs,
195
+ }
196
+ })
197
+ .sort((a, b) => a.mtimeMs - b.mtimeMs || a.name.localeCompare(b.name))
198
+ }
199
+
200
+ /** Verbatim CONTRACT token — `open latest` resolves to the newest artifact. */
201
+ export const LATEST = 'latest'
202
+
203
+ /**
204
+ * Resolve `<name|latest>` against a project's store.
205
+ * @returns {{name: string, path: string}|null} null when nothing matches
206
+ */
207
+ export function findArtifact({ project, name, env = process.env }) {
208
+ const entries = readEntries({ project, env })
209
+ if (entries.length === 0) return null
210
+ if (name === LATEST) {
211
+ const newest = entries[entries.length - 1]
212
+ return { name: newest.name, path: newest.path }
213
+ }
214
+ const wanted = String(name ?? '').endsWith(ARTIFACT_EXT)
215
+ ? basename(String(name), ARTIFACT_EXT)
216
+ : String(name ?? '')
217
+ const hit = entries.find((entry) => entry.name === wanted)
218
+ return hit ? { name: hit.name, path: hit.path } : null
219
+ }
@@ -0,0 +1,183 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import { CODES, EXIT, KsbError } from '../core/errors.js'
5
+ import { blockModule, blockTypes, registerBlockModule } from '../blocks/index.js'
6
+ import { PACKAGE_FILES } from './template-format.js'
7
+
8
+ /**
9
+ * The opt-in half of a template package: JS 轉子 override and `x-*` plugin
10
+ * blocks (CONTRACT D3, wayfinder issue 13 裁決 4).
11
+ *
12
+ * There is no sandbox, and that is a decision rather than an omission: 安裝即
13
+ * 信任 is the Obsidian-plugin model the user settled on (issue 13 裁決 4, this
14
+ * slice's P5). What is *not* optional is that a broken extension fails as an
15
+ * extension. Letting an `import()` rejection reach the generic handler would
16
+ * report `KSB_PARSE_FAILED` — telling an agent its document could not be parsed
17
+ * when the truth is a package on disk, which is the mistake F4 B9 named and
18
+ * gave `KSB_COMMAND_LOAD_FAILED` its own code to avoid.
19
+ *
20
+ * The two halves land in different places, and the difference is not
21
+ * incidental. A **plugin block** is new vocabulary: it goes into the global
22
+ * block registry through the same public entry point a third party would use
23
+ * (CONTRACT A4), and so wears the `x-` prefix and passes the same shape check.
24
+ * An **override** is *this template's* answer for a type the SDK already has —
25
+ * `kami/long-form` drawing callouts its own way — so it is template-scoped and
26
+ * reaches the pipeline through the render context. Registering it globally
27
+ * would make one installed package silently change how every other template
28
+ * draws.
29
+ */
30
+
31
+ const failed = (message) =>
32
+ new KsbError({
33
+ code: CODES.TEMPLATE_EXTENSION_FAILED,
34
+ message,
35
+ path: 'doc.meta.template',
36
+ exitCode: EXIT.VALIDATION,
37
+ })
38
+
39
+ /** Plugin types this process already registered, per package — import is once. */
40
+ const registered = new Set()
41
+
42
+ /** Test hygiene: forget which packages registered which plugin types. */
43
+ export const resetLoadedExtensions = () => registered.clear()
44
+
45
+ const importDefault = async (file, what) => {
46
+ let module
47
+ try {
48
+ module = await import(pathToFileURL(file).href)
49
+ } catch (cause) {
50
+ throw failed(`${what} 載入失敗:${cause?.message ?? String(cause)}(檔案:${file})`)
51
+ }
52
+ const mod = module?.default
53
+ if (mod === null || typeof mod !== 'object') {
54
+ throw failed(`${what} 必須 default export 一個模組物件(檔案:${file})`)
55
+ }
56
+ return mod
57
+ }
58
+
59
+ const subdirectories = (dir) => {
60
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return []
61
+ return readdirSync(dir).filter((entry) => statSync(join(dir, entry)).isDirectory()).sort()
62
+ }
63
+
64
+ const jsFiles = (dir) => {
65
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) return []
66
+ return readdirSync(dir).filter((entry) => entry.endsWith('.js')).sort()
67
+ }
68
+
69
+ /**
70
+ * Load `rotors/<型別>/override.js` — this template's own conversion for a type.
71
+ *
72
+ * An override of a type nobody registered is refused rather than kept: it can
73
+ * never fire, so keeping it would leave the author believing their callout was
74
+ * being drawn their way while the factory rotor drew it the other way, with
75
+ * exit 0 and a clean `lint`. That is the same invisible-divergence failure the
76
+ * whole guard family in this SDK exists to make loud.
77
+ */
78
+ async function loadOverrides({ dir, key }) {
79
+ const root = join(dir, PACKAGE_FILES.ROTORS_DIR)
80
+ const table = {}
81
+ for (const type of subdirectories(root)) {
82
+ const file = join(root, type, PACKAGE_FILES.OVERRIDE)
83
+ if (!existsSync(file)) continue
84
+ const what = `模板包 ${key} 的轉子 override \`${type}\``
85
+ const mod = await importDefault(file, what)
86
+ if (mod.type !== type) {
87
+ throw failed(`${what} 自稱 \`${mod.type}\`,與所在目錄不符——一個永遠指不到另一個。`)
88
+ }
89
+ if (typeof mod.render !== 'function') {
90
+ throw failed(`${what} 缺少 \`render\` 函式(轉子的全部工作就是這一個)。`)
91
+ }
92
+ if (blockModule(type) === undefined) {
93
+ throw failed(
94
+ `${what} 覆寫的型別沒有人註冊;已註冊的型別:${blockTypes().join(', ')}。` +
95
+ '覆寫一個不存在的型別永遠不會生效,而產物仍舊畫得出來——所以在這裡報。',
96
+ )
97
+ }
98
+ table[type] = mod.render
99
+ }
100
+ return Object.freeze(table)
101
+ }
102
+
103
+ /**
104
+ * Load `plugins/<型別>.js` — new block vocabulary the package brings with it.
105
+ *
106
+ * Registration goes through `registerBlockModule`, the same public entry point
107
+ * CONTRACT A4 proves a third party can reach without editing a file inside the
108
+ * SDK. Its `TypeError` becomes a KSB_ finding here rather than a stack trace,
109
+ * but the *rule set* is not restated: a second copy of the shape check would be
110
+ * free to disagree with the one the registry actually applies.
111
+ */
112
+ async function loadPlugins({ dir, key }) {
113
+ const root = join(dir, PACKAGE_FILES.PLUGINS_DIR)
114
+ const loaded = []
115
+ for (const entry of jsFiles(root)) {
116
+ const type = entry.replace(/\.js$/, '')
117
+ const file = join(root, entry)
118
+ const what = `模板包 ${key} 的 plugin block \`${type}\``
119
+ const mod = await importDefault(file, what)
120
+ if (mod.type !== type) {
121
+ throw failed(`${what} 自稱 \`${mod.type}\`,與檔名不符——一個永遠指不到另一個。`)
122
+ }
123
+ loaded.push(type)
124
+
125
+ const stamp = `${key}:${type}`
126
+ if (registered.has(stamp)) continue
127
+ try {
128
+ registerBlockModule(mod)
129
+ } catch (cause) {
130
+ throw failed(`${what} 未通過註冊表形狀校驗:${cause?.message ?? String(cause)}`)
131
+ }
132
+ registered.add(stamp)
133
+ }
134
+ return Object.freeze(loaded)
135
+ }
136
+
137
+ /**
138
+ * Load `chrome.js` — this package's fills for its 文體's chrome slots (F7a).
139
+ *
140
+ * Every export must be a function, and nothing here checks the *names*: the
141
+ * list of slots belongs to the layout, and `assertChromeSlots` already refuses
142
+ * a fill for a slot the 文體 does not offer, with the message that explains why
143
+ * a misspelling is silence rather than an error. A second copy of that rule
144
+ * here would be free to disagree with the one the renderer applies.
145
+ *
146
+ * What is checked is the one thing only this layer can see: that a *value* is
147
+ * callable. `ctx.chrome` skips a non-function silently — correct behaviour for
148
+ * an unfilled slot, and indistinguishable from a package that exported a
149
+ * string by mistake and now renders with no masthead and exit 0.
150
+ */
151
+ async function loadChrome({ dir, key }) {
152
+ const file = join(dir, PACKAGE_FILES.CHROME)
153
+ if (!existsSync(file)) return Object.freeze({})
154
+ const what = `模板包 ${key} 的 ${PACKAGE_FILES.CHROME}`
155
+ const mod = await importDefault(file, what)
156
+ const table = {}
157
+ for (const [slot, fill] of Object.entries(mod)) {
158
+ if (typeof fill !== 'function') {
159
+ throw failed(
160
+ `${what} 的槽位 \`${slot}\` 不是函式(得到 ${typeof fill})。` +
161
+ '非函式的填充會被靜靜跳過——產物照畫、exit 0、槽位空白,沒有人會發現。',
162
+ )
163
+ }
164
+ table[slot] = fill
165
+ }
166
+ return Object.freeze(table)
167
+ }
168
+
169
+ /**
170
+ * Every JS extension a package carries. Pure-data packages take the cheap path:
171
+ * nothing exists on any of the three paths, so nothing is imported at all.
172
+ *
173
+ * @param {{dir: string, key: string}} input
174
+ * @returns {Promise<{overrides: Record<string, Function>, plugins: readonly string[],
175
+ * chrome: Record<string, Function>}>}
176
+ */
177
+ export async function loadPackageExtensions({ dir, key }) {
178
+ return {
179
+ overrides: await loadOverrides({ dir, key }),
180
+ plugins: await loadPlugins({ dir, key }),
181
+ chrome: await loadChrome({ dir, key }),
182
+ }
183
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The template package format, as constants — the one place its filenames live.
3
+ *
4
+ * It is its own module so that the reader of a package (`template-package.js`)
5
+ * and the loader of its JS (`template-extensions.js`) can both name the layout
6
+ * without importing each other. Two copies of `'manifest.toml'` is precisely
7
+ * how a format grows a second, slightly different spelling.
8
+ *
9
+ * The shape is wayfinder issue 13 裁決 7's 目錄形 with two engineering
10
+ * simplifications, recorded here because a reader comparing the two will
11
+ * otherwise think one of them drifted:
12
+ *
13
+ * - `core-config.toml` and 轉子/<型別>/config are **one** file: `manifest.toml`,
14
+ * the latter as its `[blockConfig.<型別>]` tables. Both are pure data, and a
15
+ * single descriptor is what keeps the byte-determinism claim checkable.
16
+ * - `core-style` and `stylesheet/` are **one** `stylesheet.css`: the SDK ships
17
+ * no sass pipeline, and issue 13 裁決 6 leaves sass an internal-organisation
18
+ * freedom rather than a public contract.
19
+ *
20
+ * Everything else is issue 13's tree with ASCII directory names: `plugins/`
21
+ * unchanged, 轉子/<型別>/{override} as `rotors/<型別>/override.js`, plus the
22
+ * 預設 md that issue 14's 補充裁定 added to the format.
23
+ */
24
+
25
+ /** Verbatim CONTRACT constants — the package directory layout. */
26
+ export const PACKAGE_FILES = Object.freeze({
27
+ MANIFEST: 'manifest.toml',
28
+ STYLESHEET: 'stylesheet.css',
29
+ DEFAULT_DOC: 'default.md',
30
+ ROTORS_DIR: 'rotors',
31
+ OVERRIDE: 'override.js',
32
+ PLUGINS_DIR: 'plugins',
33
+ /**
34
+ * F7a — the package's fills for its 文體's chrome slots.
35
+ *
36
+ * The third JS door, opened for the reason 臨摹 exists to find (AGENTS §3.5
37
+ * 「凡臨摹不出來的,就是模板語言漏考量的東西」). Until this file existed a
38
+ * store package's `chrome` was the literal `{}`: the masthead, the colophon
39
+ * and the rail were things **only a bundled template could have**, so the
40
+ * first real skin anyone tried to 臨摹 came out headless while the factory
41
+ * template it was replacing kept its header. A package format that cannot
42
+ * express the one region every page starts with is not a package format.
43
+ *
44
+ * A flat file rather than a directory, because a slot fill is one function
45
+ * and the layout — not the package — owns the list of slot names
46
+ * (`assertChromeSlots` refuses a name the 文體 does not offer).
47
+ */
48
+ CHROME: 'chrome.js',
49
+ })
50
+
51
+ /** Verbatim CONTRACT constant — the package descriptor's filename. */
52
+ export const MANIFEST_FILENAME = PACKAGE_FILES.MANIFEST
53
+
54
+ /**
55
+ * A template with no required root form carries the empty string, not a missing
56
+ * key. TOML has no null, and an *absent* key would be indistinguishable from a
57
+ * manifest written by an older engine that did not know about roots — so the
58
+ * "no constraint" case is stated explicitly.
59
+ */
60
+ export const NO_ROOT = ''
61
+
62
+ /** Default `language` when a package does not state one. */
63
+ export const DEFAULT_LANGUAGE = 'zh-TW'
64
+
65
+ /**
66
+ * Verbatim CONTRACT F7a constant — the 臨摹 workspace namespace.
67
+ *
68
+ * A package under `draft/` is a *work in progress*: it renders, it lints, it can
69
+ * be pointed at with `-t` exactly like any other package, and that is the whole
70
+ * point — the 臨摹 loop is `init draft/<名> → 編輯 → render + snapshot 對照 →
71
+ * lint 綠 → 人驗 → promote`, and every step of it needs a package that really
72
+ * draws. What `draft/` buys is the one thing the store had no way to say: which
73
+ * of the packages on this machine is a finished skin and which is a sketch
74
+ * somebody is still cutting.
75
+ *
76
+ * It is a *marker*, not a fence, for the same reason `reserved` is one
77
+ * (`cli/commands/templates.js`): this surface reports the disk. Hiding a draft
78
+ * would make the listing disagree with the filesystem, and refusing to render
79
+ * one would make the workflow impossible to walk.
80
+ */
81
+ export const DRAFT_NAMESPACE = 'draft'
82
+
83
+ /**
84
+ * Whether a key names a package in the 臨摹 workspace (CONTRACT F7a H1).
85
+ *
86
+ * One definition, called by both the listing and `promote`, for the reason
87
+ * `isReservedKey` states next door: two spellings of one predicate are free to
88
+ * drift, and the direction they drift in is the bad one — a listing that marks a
89
+ * package `promote` refuses to move, or a `promote` that moves one the listing
90
+ * never called a draft.
91
+ */
92
+ export function isDraftKey(key) {
93
+ const split = splitTemplateKey(key)
94
+ return split !== null && split.namespace === DRAFT_NAMESPACE
95
+ }
96
+
97
+ /**
98
+ * A namespace or name segment. Anchored and free of dots on purpose: the
99
+ * segments are joined onto the store root, so `..` would let `-t` address any
100
+ * directory on the machine and read a "stylesheet" out of it.
101
+ */
102
+ const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]*$/
103
+
104
+ /** Split `<namespace>/<name>[@version]`; null when the key is not that shape. */
105
+ export function splitTemplateKey(key) {
106
+ const bare = String(key ?? '').split('@')[0]
107
+ const parts = bare.split('/')
108
+ if (parts.length !== 2) return null
109
+ const [namespace, name] = parts
110
+ if (!SEGMENT.test(namespace) || !SEGMENT.test(name)) return null
111
+ return { namespace, name, key: `${namespace}/${name}` }
112
+ }