@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,140 @@
1
+ import { walkBlocks } from './blocks.js'
2
+ import { PLUGIN_TYPE_PREFIX, PLUGIN_TYPE_WILDCARD, pluginBlockTypes } from '../blocks/index.js'
3
+ import { CODES, validationError } from './errors.js'
4
+
5
+ /**
6
+ * `manifest.blocks` is a *guard*, not a brochure.
7
+ *
8
+ * A template draws the block types it knows and skips the rest. Skipping is the
9
+ * right behaviour for a renderer — but as the only behaviour it is a silent
10
+ * data-loss channel: handing a deck tree to `kami/long-form` (via `replay -t`
11
+ * or a block-tree JSON that names the wrong template) dropped every slide,
12
+ * produced an empty-bodied artifact, and still reported exit 0, `lint` 0 and a
13
+ * slide count. Nothing in the pipeline could see it.
14
+ *
15
+ * So the vocabulary a template declares is checked against the document before
16
+ * anything is drawn. A template that cannot express the document must say so
17
+ * (CONTRACT C4) rather than quietly render less than it was given.
18
+ *
19
+ * Two claims are checked, because one cannot stand in for the other:
20
+ *
21
+ * - `manifest.blocks` — which types this template can draw.
22
+ * - `manifest.root` — which *shape* the document must have at its root.
23
+ *
24
+ * The mirror case is what forces the second: `kami/slides` must declare the
25
+ * whole long-form vocabulary (prose, list, table … are all legal inside a
26
+ * slide), so a flat document tree passes the type check completely — and then
27
+ * the template draws only `deck.slides`, i.e. nothing, with a progress readout
28
+ * of `1 / 0` contradicting itself on screen. Type-legal, shape-wrong.
29
+ */
30
+
31
+ /** Every block type present in a tree, in document order of first appearance. */
32
+ export function collectBlockTypes(doc) {
33
+ const seen = new Map()
34
+ for (const { block, path } of walkBlocks(doc)) {
35
+ const type = block?.type
36
+ if (typeof type !== 'string' || seen.has(type)) continue
37
+ seen.set(type, path)
38
+ }
39
+ return seen
40
+ }
41
+
42
+ /** The block types sitting directly under `doc` — the document's root form. */
43
+ const rootForms = (doc) =>
44
+ (doc?.children ?? []).map((block) => block?.type).filter((type) => typeof type === 'string')
45
+
46
+ /**
47
+ * Throw unless the document's root form matches the one its 文體 requires.
48
+ *
49
+ * The requirement is the **layout's**, and only the layout's. 「replay 換皮不換
50
+ * 文體」 stopped being a rule two templates happened to obey and became a
51
+ * structure every deck template inherits (wayfinder issue 13 裁決 1) — which is
52
+ * exactly the claim a `manifest.root ?? layout.rootForm` fallback would give
53
+ * back: a template declaring `root: 'section'` on the `deck` layout would have
54
+ * *overwritten* its 文體's requirement, sail past this guard, and then be drawn
55
+ * by a root form that looks for a deck and finds none. The artifact that comes
56
+ * out has an empty body and a playback script attached to it — the exact zombie
57
+ * `src/layouts/deck.js` claims to have abolished.
58
+ *
59
+ * A template's own `manifest.root` is therefore a *restatement*, not a source:
60
+ * `assertLayoutRootForm` refuses any template whose restatement disagrees, so
61
+ * by the time this runs the two are known to be the same sentence.
62
+ */
63
+ function assertRootForm({ doc, manifest, layout, key }) {
64
+ const required = layout === undefined ? manifest.root : layout.rootForm
65
+ if (typeof required !== 'string' || required.length === 0) return
66
+ const present = rootForms(doc)
67
+ if (present.includes(required)) return
68
+ const got = present.length === 0 ? '空的 doc' : `doc 形(頂層為 ${[...new Set(present)].join(', ')})`
69
+ throw validationError(
70
+ `template "${key}" 需要 \`${required}\` 根形,收到 ${got}。` +
71
+ `簡報須以 ${required} 語料著作(frontmatter \`template: ${key}\` + \`---\` 切頁符),` +
72
+ '書轉簡報屬文體轉換而非換皮:replay 只換皮,不重寫文體。' +
73
+ `否則此模板只會畫出 ${required} 以外一律丟棄的空殼。`,
74
+ CODES.TEMPLATE_BLOCK_UNSUPPORTED,
75
+ 'doc.children',
76
+ )
77
+ }
78
+
79
+ /**
80
+ * `manifest.blocks` stays the **one** admission rule, for plugin blocks too.
81
+ *
82
+ * A manifest is written before any given plugin exists, so it cannot name
83
+ * `x-gallery` by hand — but it can admit the *class*: listing the `x-*` wildcard
84
+ * says "this template is willing to draw plugin blocks". Admitting every
85
+ * registered plugin unconditionally would have been the easy answer and the
86
+ * wrong one — it hands a template blocks it has no stylesheet for, and the
87
+ * guard exists precisely because a block nobody painted renders as invisible
88
+ * data loss with exit 0.
89
+ *
90
+ * Registration is checked *before* either form of declaration, and that order
91
+ * is the point. An `x-…` name nobody registered has no module, so it would sail
92
+ * through admission and then be dropped by `renderChildren` — exit 0, block
93
+ * gone, which is the exact data-loss channel this guard exists to make loud.
94
+ * Requiring it only on the wildcard branch left the hole open on the other one:
95
+ * a manifest that hand-types `x-galery` for `x-gallery` is a typo, and a typo
96
+ * must not be the thing that silently deletes content (seal F7).
97
+ */
98
+ function admits(manifest, type) {
99
+ const declared = manifest.blocks ?? []
100
+ const isPlugin = type.startsWith(PLUGIN_TYPE_PREFIX)
101
+ if (isPlugin && !pluginBlockTypes().includes(type)) return false
102
+ if (declared.includes(type)) return true
103
+ // The class wildcard admits plugin blocks only; it is never a way for a
104
+ // template to acquire core types it never declared.
105
+ return isPlugin && declared.includes(PLUGIN_TYPE_WILDCARD)
106
+ }
107
+
108
+ /**
109
+ * Throw unless every block type in `doc` is admitted by the template.
110
+ *
111
+ * Refusal is loud, for core and plugin types alike: this guard's whole reason
112
+ * for existing is that silently drawing less than you were given is a data-loss
113
+ * channel nothing downstream can see (CONTRACT C4). A plugin block is not a
114
+ * softer case — an unadmitted one means the template has no paint for it.
115
+ */
116
+ function assertDeclaredTypes({ doc, manifest, key }) {
117
+ for (const [type, path] of collectBlockTypes(doc)) {
118
+ if (admits(manifest, type)) continue
119
+ throw validationError(
120
+ `template "${key}" 的詞彙表不含 block 型別 \`${type}\`;` +
121
+ `該模板宣告:${[...(manifest.blocks ?? [])].join(', ')}。改用能表達它的模板,` +
122
+ '或先把文件轉成該模板的詞彙——渲染層不會靜默濾除看不懂的 block。',
123
+ CODES.TEMPLATE_BLOCK_UNSUPPORTED,
124
+ path,
125
+ )
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Assert that a template can express this document, in both type and shape.
131
+ *
132
+ * @param {{doc: object, manifest: {namespace: string, name: string,
133
+ * blocks: string[], root?: string|null}, layout?: {rootForm?: string|null}}} input
134
+ * @throws {import('./errors.js').KsbError} exit 1 / KSB_TEMPLATE_BLOCK_UNSUPPORTED
135
+ */
136
+ export function assertTemplateVocabulary({ doc, manifest, layout }) {
137
+ const key = `${manifest.namespace}/${manifest.name}`
138
+ assertDeclaredTypes({ doc, manifest, key })
139
+ assertRootForm({ doc, manifest, layout, key })
140
+ }
@@ -0,0 +1,71 @@
1
+ import { appendFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { basename, dirname, join } from 'node:path'
3
+
4
+ /**
5
+ * Crash-safe file replacement: write a sibling temp file, then `rename` it over
6
+ * the target (CONTRACT D6, the S2 ride-along).
7
+ *
8
+ * The sidecar index is the case that forced this. `writeFileSync` truncates
9
+ * first and writes after, so a process killed in between leaves a **zero-byte
10
+ * or half-written** index — and the reader treats a corrupt sidecar as "no
11
+ * delivery copies", i.e. the record is silently gone rather than visibly broken.
12
+ * `rename` within one directory is atomic: a reader sees either the whole old
13
+ * file or the whole new one, never a torn one.
14
+ *
15
+ * The temp name carries the pid and a per-process counter, so two concurrent
16
+ * writers cannot land on the same scratch file and corrupt each other — which
17
+ * is the same reasoning that makes the artifact write exclusive (`wx`).
18
+ */
19
+
20
+ let sequence = 0
21
+
22
+ const tempPathFor = (targetPath) => {
23
+ sequence += 1
24
+ return join(dirname(targetPath), `.${basename(targetPath)}.${process.pid}.${sequence}.tmp`)
25
+ }
26
+
27
+ /**
28
+ * Append one record line to an append-only log (CONTRACT E2 的留言 sidecar).
29
+ *
30
+ * The rename dance above is the wrong tool here and would be actively worse: a
31
+ * read-all/rewrite-all cycle turns an append-only history into a full-file
32
+ * replacement, so a crash — or a second writer — can lose records that were
33
+ * already durable. A single `O_APPEND` write of one line is what an append log
34
+ * actually needs: the kernel places every writer at the current end of file, so
35
+ * concurrent appends interleave by record instead of overwriting each other.
36
+ *
37
+ * The line is written in one call for that reason; callers must therefore pass
38
+ * one complete record **without** its trailing newline — this function adds the
39
+ * terminator, so that the record and its delimiter cannot be split across two
40
+ * writes and interleaved with somebody else's.
41
+ *
42
+ * @param {string} targetPath absolute path of the log
43
+ * @param {string} line one record, without its trailing newline
44
+ * @returns {string} the target path
45
+ */
46
+ export function appendLineAtomic(targetPath, line) {
47
+ if (line.includes('\n')) {
48
+ throw new Error('append record must be a single line; embedded newline would split the record')
49
+ }
50
+ appendFileSync(targetPath, `${line}\n`, { encoding: 'utf8', flag: 'a' })
51
+ return targetPath
52
+ }
53
+
54
+ /**
55
+ * @param {string} targetPath absolute path to replace
56
+ * @param {string} data file contents
57
+ * @returns {string} the target path
58
+ */
59
+ export function writeFileAtomic(targetPath, data) {
60
+ const tmp = tempPathFor(targetPath)
61
+ try {
62
+ writeFileSync(tmp, data, { encoding: 'utf8', flag: 'wx' })
63
+ renameSync(tmp, targetPath)
64
+ } catch (cause) {
65
+ // A failed write must not leave scratch files behind: the store is a
66
+ // permanent record, and residue there is indistinguishable from content.
67
+ rmSync(tmp, { force: true })
68
+ throw cause
69
+ }
70
+ return targetPath
71
+ }
@@ -0,0 +1,180 @@
1
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs'
2
+ import { dirname, resolve } from 'node:path'
3
+ import { CODES, EXIT, KsbError } from '../core/errors.js'
4
+ import { appendLineAtomic } from './atomic.js'
5
+
6
+ /**
7
+ * The block-anchored comment loop (CONTRACT E2, SPEC §13.1).
8
+ *
9
+ * Two properties carry the whole design:
10
+ *
11
+ * 1. **Comments are data, never edits.** Nothing here touches the artifact. A
12
+ * comment is a row in a log beside it, so the artifact stays exactly what
13
+ * the renderer produced and the file remains the single source of truth.
14
+ * 2. **The log is append-only.** Resolving a comment appends a *new* record
15
+ * with the same id and `status: "resolved"`; it never rewrites the line that
16
+ * recorded the original opinion. A human's comment is testimony — the
17
+ * record of what was said must survive the decision to act on it, which is
18
+ * also what lets an Agent see that a comment was raised at all after it has
19
+ * been dealt with.
20
+ *
21
+ * Reading folds the log by id, last record wins. Every record therefore has
22
+ * the same five keys, which is what keeps `comments --json` a homogeneous array
23
+ * rather than a union of two shapes.
24
+ */
25
+
26
+ /** Verbatim CONTRACT constant — the sidecar suffix, appended to the artifact path. */
27
+ export const COMMENTS_SUFFIX = '.comments.jsonl'
28
+ /** Verbatim CONTRACT constant — comment fields, in output order. */
29
+ export const COMMENT_KEYS = Object.freeze(['id', 'blockId', 'text', 'ts', 'status'])
30
+ /** Verbatim CONTRACT constant — the two legal statuses. */
31
+ export const STATUS_OPEN = 'open'
32
+ export const STATUS_RESOLVED = 'resolved'
33
+
34
+ /** `<canonical>.comments.jsonl` — beside the artifact it belongs to. */
35
+ export function commentsPath(artifactPath) {
36
+ return `${resolve(artifactPath)}${COMMENTS_SUFFIX}`
37
+ }
38
+
39
+ const validationError = (code, message) =>
40
+ new KsbError({ code, message, exitCode: EXIT.VALIDATION })
41
+
42
+ /**
43
+ * Raw records in write order. A malformed line is skipped rather than fatal:
44
+ * the log may be appended to by a server that died mid-line, and one torn
45
+ * record must not make every earlier comment unreadable.
46
+ */
47
+ export function readCommentRecords(artifactPath) {
48
+ const path = commentsPath(artifactPath)
49
+ if (!existsSync(path)) return []
50
+ let text
51
+ try {
52
+ text = readFileSync(path, 'utf8')
53
+ } catch (cause) {
54
+ throw validationError(
55
+ CODES.ARTIFACT_UNREADABLE,
56
+ `could not read comments sidecar ${path}: ${cause.message}`,
57
+ )
58
+ }
59
+ const records = []
60
+ for (const line of text.split('\n')) {
61
+ if (line.trim().length === 0) continue
62
+ try {
63
+ const parsed = JSON.parse(line)
64
+ if (parsed && typeof parsed.id === 'string') records.push(parsed)
65
+ } catch {
66
+ continue
67
+ }
68
+ }
69
+ return records
70
+ }
71
+
72
+ /** Exactly the five pinned keys, in the pinned order. */
73
+ const toEntry = (record) => ({
74
+ id: String(record.id),
75
+ blockId: String(record.blockId ?? ''),
76
+ text: String(record.text ?? ''),
77
+ ts: String(record.ts ?? ''),
78
+ status: record.status === STATUS_RESOLVED ? STATUS_RESOLVED : STATUS_OPEN,
79
+ })
80
+
81
+ /**
82
+ * Effective comments: folded by id (last record wins), ordered by the first
83
+ * time each id appeared, so resolving a comment never reorders the list.
84
+ *
85
+ * @returns {Array<{id: string, blockId: string, text: string, ts: string, status: string}>}
86
+ */
87
+ export function listComments(artifactPath) {
88
+ const order = []
89
+ const byId = new Map()
90
+ for (const record of readCommentRecords(artifactPath)) {
91
+ if (!byId.has(record.id)) order.push(record.id)
92
+ byId.set(record.id, record)
93
+ }
94
+ return order.map((id) => toEntry(byId.get(id)))
95
+ }
96
+
97
+ /** `c1`, `c2`, … — one past the highest id already in the log. */
98
+ const nextCommentId = (records) => {
99
+ let highest = 0
100
+ for (const record of records) {
101
+ const match = /^c(\d+)$/.exec(String(record.id))
102
+ if (match !== null) highest = Math.max(highest, Number(match[1]))
103
+ }
104
+ return `c${highest + 1}`
105
+ }
106
+
107
+ const appendRecord = (artifactPath, entry) => {
108
+ const path = commentsPath(artifactPath)
109
+ try {
110
+ mkdirSync(dirname(path), { recursive: true })
111
+ appendLineAtomic(path, JSON.stringify(entry))
112
+ } catch (cause) {
113
+ throw validationError(
114
+ CODES.WRITE_FAILED,
115
+ `could not append to comments sidecar ${path}: ${cause.message}`,
116
+ )
117
+ }
118
+ return entry
119
+ }
120
+
121
+ /**
122
+ * Append one comment, anchored to a block that must exist.
123
+ *
124
+ * `blockIds` is supplied by the caller (read off the artifact's IR) rather than
125
+ * parsed here: this layer never learns what an artifact looks like inside, and
126
+ * the server and the CLI must validate against the *same* set or the two write
127
+ * paths would disagree about which anchors are legal.
128
+ *
129
+ * @param {{artifactPath: string, blockId: string, text: string,
130
+ * blockIds: Set<string>|string[], now?: Date}} input
131
+ */
132
+ export function appendComment({ artifactPath, blockId, text, blockIds, now = new Date() }) {
133
+ const legal = blockIds instanceof Set ? blockIds : new Set(blockIds ?? [])
134
+ const anchor = String(blockId ?? '').trim()
135
+ const body = String(text ?? '').trim()
136
+
137
+ if (anchor.length === 0) {
138
+ throw validationError(CODES.BLOCK_NOT_FOUND, 'comment must name a blockId')
139
+ }
140
+ if (!legal.has(anchor)) {
141
+ throw validationError(
142
+ CODES.BLOCK_NOT_FOUND,
143
+ `block "${anchor}" is not in this artifact's IR; known ids: ${[...legal].join(', ')}`,
144
+ )
145
+ }
146
+ if (body.length === 0) {
147
+ throw validationError(CODES.INPUT_EMPTY, 'comment text must not be empty')
148
+ }
149
+
150
+ const id = nextCommentId(readCommentRecords(artifactPath))
151
+ return appendRecord(artifactPath, {
152
+ id,
153
+ blockId: anchor,
154
+ text: body,
155
+ ts: now.toISOString(),
156
+ status: STATUS_OPEN,
157
+ })
158
+ }
159
+
160
+ /**
161
+ * Mark a comment resolved by appending a resolution record. The original line
162
+ * stays exactly where it was.
163
+ *
164
+ * @param {{artifactPath: string, id: string, now?: Date}} input
165
+ */
166
+ export function resolveComment({ artifactPath, id, now = new Date() }) {
167
+ const wanted = String(id ?? '').trim()
168
+ const current = listComments(artifactPath).find((entry) => entry.id === wanted)
169
+ if (current === undefined) {
170
+ throw validationError(
171
+ CODES.COMMENT_NOT_FOUND,
172
+ `no comment "${wanted}" on ${resolve(artifactPath)}`,
173
+ )
174
+ }
175
+ return appendRecord(artifactPath, {
176
+ ...current,
177
+ ts: now.toISOString(),
178
+ status: STATUS_RESOLVED,
179
+ })
180
+ }
@@ -0,0 +1,70 @@
1
+ import { homedir } from 'node:os'
2
+ import { join, resolve } from 'node:path'
3
+
4
+ /**
5
+ * The single resolver for the central store root (CONTRACT B1).
6
+ *
7
+ * Every path that touches `~/.kamishibai` must come through here. Two callers
8
+ * each doing their own `join(homedir(), …)` is how a test suite ends up writing
9
+ * into the developer's real store: one of them will miss the override, and
10
+ * nothing downstream can tell that it did.
11
+ */
12
+
13
+ /** Verbatim CONTRACT constant. */
14
+ export const HOME_ENV = 'KAMISHIBAI_HOME'
15
+ /** Verbatim CONTRACT constant — default store root is `~/.kamishibai`. */
16
+ export const DEFAULT_HOME_DIRNAME = '.kamishibai'
17
+ /** Verbatim CONTRACT constant — 庫佈局 `artifacts/<專案名>/<slug>.html`. */
18
+ export const ARTIFACTS_DIRNAME = 'artifacts'
19
+ /** Verbatim CONTRACT constant — 標記檔名. */
20
+ export const MARKER_FILENAME = 'created-by'
21
+ /** Verbatim CONTRACT constant — `templates/<namespace>/<name>/manifest.toml`. */
22
+ export const TEMPLATES_DIRNAME = 'templates'
23
+ /** Verbatim CONTRACT constant — `<KAMISHIBAI_HOME>/run/serve.pid`. */
24
+ export const RUN_DIRNAME = 'run'
25
+ /** Verbatim CONTRACT constant — the serve pidfile's basename. */
26
+ export const SERVE_PIDFILE = 'serve.pid'
27
+
28
+ /** Absolute store root: `KAMISHIBAI_HOME` wins, else `~/.kamishibai`. */
29
+ export function resolveHome(env = process.env) {
30
+ const override = env?.[HOME_ENV]
31
+ if (typeof override === 'string' && override.trim().length > 0) {
32
+ return resolve(override.trim())
33
+ }
34
+ return join(homedir(), DEFAULT_HOME_DIRNAME)
35
+ }
36
+
37
+ /** `<HOME>/artifacts` */
38
+ export function artifactsRoot(env = process.env) {
39
+ return join(resolveHome(env), ARTIFACTS_DIRNAME)
40
+ }
41
+
42
+ /** `<HOME>/artifacts/<project>` */
43
+ export function projectDir(project, env = process.env) {
44
+ return join(artifactsRoot(env), project)
45
+ }
46
+
47
+ /** `<HOME>/created-by` */
48
+ export function markerPath(env = process.env) {
49
+ return join(resolveHome(env), MARKER_FILENAME)
50
+ }
51
+
52
+ /** `<HOME>/templates` — the template namespace root (CONTRACT E3). */
53
+ export function templatesRoot(env = process.env) {
54
+ return join(resolveHome(env), TEMPLATES_DIRNAME)
55
+ }
56
+
57
+ /** `<HOME>/templates/<namespace>/<name>` */
58
+ export function templatePackageDir(namespace, name, env = process.env) {
59
+ return join(templatesRoot(env), namespace, name)
60
+ }
61
+
62
+ /** `<HOME>/run` — ephemeral process state, never a record. */
63
+ export function runDir(env = process.env) {
64
+ return join(resolveHome(env), RUN_DIRNAME)
65
+ }
66
+
67
+ /** `<HOME>/run/serve.pid` */
68
+ export function servePidfile(env = process.env) {
69
+ return join(runDir(env), SERVE_PIDFILE)
70
+ }
@@ -0,0 +1,31 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { CODES, EXIT, KsbError } from '../core/errors.js'
3
+
4
+ /**
5
+ * The single cross-platform opener (SPEC §8 開啟三通道 ①). One chain, tried in
6
+ * order — WSL first because that is where this SDK is developed and `xdg-open`
7
+ * there resolves to a Linux browser that cannot show the file to the user.
8
+ */
9
+ export const OPEN_CHAIN = Object.freeze(['wslview', 'xdg-open', 'explorer.exe', 'open'])
10
+
11
+ /**
12
+ * @param {string} target absolute path of the artifact to open
13
+ * @param {(cmd: string, args: string[]) => void} [run] injected for tests
14
+ * @returns {string} the launcher that succeeded
15
+ */
16
+ export function openPath(target, run = (cmd, args) => execFileSync(cmd, args, { stdio: 'ignore' })) {
17
+ const tried = []
18
+ for (const cmd of OPEN_CHAIN) {
19
+ try {
20
+ run(cmd, [target])
21
+ return cmd
22
+ } catch {
23
+ tried.push(cmd)
24
+ }
25
+ }
26
+ throw new KsbError({
27
+ code: CODES.OPEN_FAILED,
28
+ message: `could not open ${target}; tried: ${tried.join(', ')}`,
29
+ exitCode: EXIT.VALIDATION,
30
+ })
31
+ }
@@ -0,0 +1,141 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { basename, dirname, join, resolve } from 'node:path'
3
+ import { CODES, EXIT, KsbError } from '../core/errors.js'
4
+
5
+ /**
6
+ * Project resolution (SPEC §9.1). The order is contractual:
7
+ *
8
+ * `--project` → `.kamishibai.toml` → git root → cwd
9
+ *
10
+ * The anchor file sits above git root in the order on purpose: a non-git
11
+ * directory (or a sub-repo checked out inside another repo) still needs a
12
+ * stable project identity, and running from a nested subdirectory must resolve
13
+ * to the same project as running from its root.
14
+ */
15
+
16
+ /** Verbatim CONTRACT constant — the project anchor file. */
17
+ export const ANCHOR_FILENAME = '.kamishibai.toml'
18
+ const GIT_MARKER = '.git'
19
+
20
+ /** Resolution layers, in contract order — exported so tests read the真源. */
21
+ export const RESOLUTION_ORDER = Object.freeze(['--project', ANCHOR_FILENAME, 'git-root', 'cwd'])
22
+
23
+ /**
24
+ * Project names are validated in two tiers, because the two sources are not
25
+ * the same kind of input (seal 補釘 F1):
26
+ *
27
+ * - What the user *typed* (`--project`) is rejected when illegal. Silently
28
+ * rewriting it would archive into a directory they never asked for.
29
+ * - What we *derived* (anchor file / git root / cwd) is normalised. Nobody
30
+ * chooses to work in `~/Google Drive/My Project`, and S1 ran there fine —
31
+ * refusing to render because a parent folder has a space is our bug, not
32
+ * the user's.
33
+ */
34
+ const INVALID_NAME = /[/\\\s]/
35
+
36
+ /** Substituted for any character that may not appear in a directory name. */
37
+ export const NAME_SEPARATOR = '-'
38
+ /** Last resort when normalisation leaves nothing usable. */
39
+ export const FALLBACK_PROJECT = 'unnamed-project'
40
+
41
+ const rejectName = (name, why) =>
42
+ new KsbError({
43
+ code: CODES.USAGE,
44
+ message: `invalid project name "${name}": ${why}`,
45
+ exitCode: EXIT.USAGE,
46
+ })
47
+
48
+ /** Strict tier — for names the user supplied verbatim. */
49
+ export function assertProjectName(name) {
50
+ const trimmed = String(name ?? '').trim()
51
+ if (trimmed.length === 0) throw rejectName(name, 'must not be empty')
52
+ if (trimmed === '.' || trimmed === '..' || trimmed.includes('..')) {
53
+ throw rejectName(trimmed, 'must not contain path traversal')
54
+ }
55
+ if (INVALID_NAME.test(trimmed)) {
56
+ throw rejectName(trimmed, 'must not contain path separators or whitespace')
57
+ }
58
+ return trimmed
59
+ }
60
+
61
+ /**
62
+ * Lenient tier — deterministic normalisation for derived names.
63
+ *
64
+ * Deterministic is the whole point: the same directory must resolve to the
65
+ * same project on every run, or a project's history silently splits in two.
66
+ * The result is then put through the strict check, so normalisation can never
67
+ * emit something the strict tier would have rejected.
68
+ */
69
+ export function normaliseProjectName(name) {
70
+ const normalised = String(name ?? '')
71
+ .trim()
72
+ // eslint-disable-next-line no-control-regex
73
+ .replace(/[/\\\s\u0000-\u001f]+/g, NAME_SEPARATOR)
74
+ .replace(/\.{2,}/g, NAME_SEPARATOR)
75
+ .replace(new RegExp(`\\${NAME_SEPARATOR}{2,}`, 'g'), NAME_SEPARATOR)
76
+ .replace(new RegExp(`^\\${NAME_SEPARATOR}+|\\${NAME_SEPARATOR}+$`, 'g'), '')
77
+
78
+ if (normalised.length === 0 || normalised === '.') return FALLBACK_PROJECT
79
+ return assertProjectName(normalised)
80
+ }
81
+
82
+ /** Walk from `start` up to the filesystem root, returning the first hit. */
83
+ const walkUp = (start, probe) => {
84
+ let dir = resolve(start)
85
+ for (;;) {
86
+ const hit = probe(dir)
87
+ if (hit !== null) return hit
88
+ const parent = dirname(dir)
89
+ if (parent === dir) return null
90
+ dir = parent
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Read the project name out of an anchor file. Only the `name = "…"` key is
96
+ * consulted — S2 needs the anchor *read*, not a general TOML implementation
97
+ * (the manifest parser lands with the template-authoring slice). An anchor
98
+ * without a usable name still anchors: the directory holding it wins.
99
+ */
100
+ const anchorName = (file) => {
101
+ let text
102
+ try {
103
+ text = readFileSync(file, 'utf8')
104
+ } catch (cause) {
105
+ throw new KsbError({
106
+ code: CODES.ARTIFACT_UNREADABLE,
107
+ message: `could not read project anchor ${file}: ${cause.message}`,
108
+ exitCode: EXIT.VALIDATION,
109
+ })
110
+ }
111
+ const match = /^\s*name\s*=\s*["']([^"'\n]+)["']/m.exec(text)
112
+ return match ? match[1].trim() : basename(dirname(file))
113
+ }
114
+
115
+ const fromAnchor = (cwd) =>
116
+ walkUp(cwd, (dir) => {
117
+ const candidate = join(dir, ANCHOR_FILENAME)
118
+ return existsSync(candidate) ? candidate : null
119
+ })
120
+
121
+ const fromGitRoot = (cwd) => walkUp(cwd, (dir) => (existsSync(join(dir, GIT_MARKER)) ? dir : null))
122
+
123
+ /**
124
+ * @param {{project?: string, cwd?: string}} input
125
+ * @returns {{name: string, source: string}} resolved project and which layer won
126
+ */
127
+ export function resolveProject({ project, cwd = process.cwd() } = {}) {
128
+ if (project !== undefined && project !== null && String(project).length > 0) {
129
+ return { name: assertProjectName(project), source: '--project' }
130
+ }
131
+
132
+ const anchor = fromAnchor(cwd)
133
+ if (anchor !== null) {
134
+ return { name: normaliseProjectName(anchorName(anchor)), source: ANCHOR_FILENAME }
135
+ }
136
+
137
+ const gitRoot = fromGitRoot(cwd)
138
+ if (gitRoot !== null) return { name: normaliseProjectName(basename(gitRoot)), source: 'git-root' }
139
+
140
+ return { name: normaliseProjectName(basename(resolve(cwd))), source: 'cwd' }
141
+ }