@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,265 @@
1
+ import { el } from './element.js'
2
+ import { str } from './schema-parts.js'
3
+
4
+ /** The one `kind` v1 implements. The 18-diagram-type taxonomy is later work. */
5
+ export const DIAGRAM_KINDS = Object.freeze(['graph'])
6
+
7
+ const diagramNode = Object.freeze({
8
+ type: 'object',
9
+ required: ['id', 'label'],
10
+ properties: { id: { ...str, minLength: 1 }, label: str },
11
+ additionalProperties: false,
12
+ })
13
+ const diagramEdge = Object.freeze({
14
+ type: 'object',
15
+ required: ['from', 'to'],
16
+ properties: { from: { ...str, minLength: 1 }, to: { ...str, minLength: 1 }, label: str },
17
+ additionalProperties: false,
18
+ })
19
+
20
+ const isNonEmptyString = (value) => typeof value === 'string' && value.length > 0
21
+
22
+ /**
23
+ * Every problem with one diagram block, as human-readable messages.
24
+ * Empty array means the block is drawable.
25
+ *
26
+ * Validation happens before drawing, always: a diagram whose spec is broken
27
+ * (unknown kind, dangling edge, duplicate id) must fail with a `KSB_` code and
28
+ * a block path. Drawing "whatever survives" would produce a picture that
29
+ * silently disagrees with the spec it came from — the worst possible outcome
30
+ * for a document whose whole job is to be looked at.
31
+ */
32
+ export function diagramProblems(block) {
33
+ if (isNonEmptyString(block?.parseError)) return [block.parseError]
34
+
35
+ const problems = []
36
+ if (!DIAGRAM_KINDS.includes(block?.kind)) {
37
+ problems.push(
38
+ `未知的 diagram kind ${JSON.stringify(block?.kind ?? null)};v1 僅支援:${DIAGRAM_KINDS.join(', ')}`,
39
+ )
40
+ }
41
+
42
+ const nodes = block?.nodes
43
+ if (!Array.isArray(nodes) || nodes.length === 0) {
44
+ problems.push('diagram.nodes 必須是至少一個節點的陣列')
45
+ return problems
46
+ }
47
+
48
+ const ids = new Set()
49
+ for (const [index, node] of nodes.entries()) {
50
+ if (!isNonEmptyString(node?.id)) {
51
+ problems.push(`nodes[${index}].id 必須是非空字串`)
52
+ continue
53
+ }
54
+ if (ids.has(node.id)) {
55
+ problems.push(`nodes[${index}].id "${node.id}" 重複;節點 id 必須唯一`)
56
+ continue
57
+ }
58
+ ids.add(node.id)
59
+ if (typeof node.label !== 'string') {
60
+ problems.push(`nodes[${index}].label(id "${node.id}")必須是字串`)
61
+ }
62
+ }
63
+
64
+ const edges = block?.edges
65
+ if (!Array.isArray(edges)) {
66
+ problems.push('diagram.edges 必須是陣列(沒有邊就給空陣列)')
67
+ return problems
68
+ }
69
+ for (const [index, edge] of edges.entries()) {
70
+ for (const end of ['from', 'to']) {
71
+ if (!isNonEmptyString(edge?.[end])) {
72
+ problems.push(`edges[${index}].${end} 必須是非空字串`)
73
+ continue
74
+ }
75
+ if (!ids.has(edge[end])) {
76
+ problems.push(
77
+ `edges[${index}].${end} 指向不存在的節點 "${edge[end]}";` +
78
+ `已宣告的節點:${[...ids].join(', ')}`,
79
+ )
80
+ }
81
+ }
82
+ if (edge?.label !== undefined && typeof edge.label !== 'string') {
83
+ problems.push(`edges[${index}].label 若存在必須是字串`)
84
+ }
85
+ }
86
+ return problems
87
+ }
88
+
89
+ /**
90
+ * A `diagram` fence whose body could not be read at all.
91
+ *
92
+ * `nodes`/`edges` are deliberately absent rather than empty: the block has to
93
+ * be *unrenderable* by construction, so that even if the parse-error branch of
94
+ * validation were removed the shape check would still reject it. An empty-but-
95
+ * valid-looking diagram would draw a blank SVG and hide the authoring mistake.
96
+ */
97
+ export const diagramParseError = (parseError) =>
98
+ Object.freeze({ type: 'diagram', kind: 'graph', parseError })
99
+
100
+ /**
101
+ * `diagram` → inline SVG, drawn from a layout this module does not compute.
102
+ *
103
+ * The geometry deliberately lives outside the block: both Kami templates must
104
+ * draw the *same* picture from the same spec (CONTRACT D1), and a layout
105
+ * duplicated per template is a guarantee that they will eventually differ. It
106
+ * now also lives outside the *renderer* — layout needs to know the available
107
+ * width, which is a page-layout fact this module cannot see (wayfinder issue 13
108
+ * 試金石). The render layer measures and feeds `ctx.layoutOf(block)` in.
109
+ * What a template may still own is the paint — the classes below are styled by
110
+ * each stylesheet's own palette variables.
111
+ *
112
+ * The arrowhead marker id is derived from the block id, which is assigned in
113
+ * document order: unique within an artifact (two diagrams cannot capture each
114
+ * other's marker) and stable across renders (so the bytes stay identical).
115
+ */
116
+ export default Object.freeze({
117
+ type: 'diagram',
118
+
119
+ schema: Object.freeze({
120
+ required: ['kind', 'nodes', 'edges'],
121
+ properties: {
122
+ kind: { enum: [...DIAGRAM_KINDS] },
123
+ nodes: { type: 'array', minItems: 1, items: diagramNode },
124
+ edges: { type: 'array', items: diagramEdge },
125
+ },
126
+ }),
127
+
128
+ example: Object.freeze({
129
+ block: {
130
+ id: 'b18',
131
+ type: 'diagram',
132
+ kind: 'graph',
133
+ nodes: [
134
+ { id: 'input', label: '來源語料' },
135
+ { id: 'ir', label: 'block tree' },
136
+ { id: 'artifact', label: '離線產物' },
137
+ ],
138
+ edges: [
139
+ { from: 'input', to: 'ir', label: 'parse' },
140
+ { from: 'ir', to: 'artifact', label: 'render' },
141
+ { from: 'artifact', to: 'ir', label: 'replay' },
142
+ ],
143
+ },
144
+ }),
145
+
146
+ styleHooks: Object.freeze([
147
+ 'diagram',
148
+ 'diagram-svg',
149
+ 'diagram-arrow',
150
+ 'diagram-edge',
151
+ 'diagram-edge-line',
152
+ 'diagram-edge-label',
153
+ 'diagram-node',
154
+ 'diagram-node-box',
155
+ 'diagram-node-label',
156
+ ]),
157
+
158
+ create: ({ kind, nodes, edges }) => Object.freeze({ type: 'diagram', kind, nodes, edges }),
159
+
160
+ validate: diagramProblems,
161
+
162
+ /**
163
+ * How a failed `validate` is reported. Stated as plain data because this
164
+ * layer imports nothing — the core turns it into a `KsbError` with the block
165
+ * path attached. `test_f1_validate_codes_match_core` pins the code against
166
+ * `CODES.DIAGRAM_INVALID` so the two spellings cannot drift.
167
+ */
168
+ invalid: Object.freeze({
169
+ code: 'KSB_DIAGRAM_INVALID',
170
+ message: (problems) =>
171
+ `diagram block 無法繪製:${problems.join(';')}。` +
172
+ '規格錯了就報錯,不畫半張圖——空 SVG 會讓錯誤在視覺上消失。',
173
+ }),
174
+
175
+ render: (block, ctx) => {
176
+ const layout = ctx.layoutOf(block)
177
+ const arrowId = `ksb-arrow-${block.id}`
178
+ return el('figure', { class: 'diagram', 'data-diagram-kind': block.kind }, [
179
+ el(
180
+ 'svg',
181
+ {
182
+ class: 'diagram-svg',
183
+ xmlns: 'http://www.w3.org/2000/svg',
184
+ viewBox: `0 0 ${layout.width} ${layout.height}`,
185
+ width: layout.width,
186
+ height: layout.height,
187
+ role: 'img',
188
+ },
189
+ [
190
+ el('defs', null, [
191
+ el(
192
+ 'marker',
193
+ {
194
+ id: arrowId,
195
+ viewBox: '0 0 10 10',
196
+ refX: 9,
197
+ refY: 5,
198
+ markerWidth: 7,
199
+ markerHeight: 7,
200
+ orient: 'auto-start-reverse',
201
+ },
202
+ [el('path', { class: 'diagram-arrow', d: 'M 0 0 L 10 5 L 0 10 z' })],
203
+ ),
204
+ ]),
205
+ ...layout.edges.map((edge) =>
206
+ el(
207
+ 'g',
208
+ {
209
+ class: 'diagram-edge',
210
+ 'data-edge-from': edge.from,
211
+ 'data-edge-to': edge.to,
212
+ },
213
+ [
214
+ el('path', {
215
+ class: 'diagram-edge-line',
216
+ d: edge.d,
217
+ 'marker-end': `url(#${arrowId})`,
218
+ }),
219
+ // Filtered rather than left as `null`: a null child SSRs to an
220
+ // empty comment node, so an unlabelled edge would differ from a
221
+ // labelled one by a stray `<!---->` in the bytes.
222
+ ...(edge.label
223
+ ? [
224
+ el(
225
+ 'text',
226
+ {
227
+ class: 'diagram-edge-label',
228
+ x: edge.labelX,
229
+ y: edge.labelY,
230
+ 'text-anchor': edge.labelAnchor,
231
+ },
232
+ [edge.label],
233
+ ),
234
+ ]
235
+ : []),
236
+ ],
237
+ ),
238
+ ),
239
+ ...layout.nodes.map((node) =>
240
+ el('g', { class: 'diagram-node', 'data-node-id': node.id }, [
241
+ el('rect', {
242
+ class: 'diagram-node-box',
243
+ x: node.x,
244
+ y: node.y,
245
+ width: node.w,
246
+ height: node.h,
247
+ rx: 6,
248
+ }),
249
+ el(
250
+ 'text',
251
+ {
252
+ class: 'diagram-node-label',
253
+ x: node.cx,
254
+ y: node.cy + 5,
255
+ 'text-anchor': 'middle',
256
+ },
257
+ [node.label],
258
+ ),
259
+ ]),
260
+ ),
261
+ ],
262
+ ),
263
+ ])
264
+ },
265
+ })
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The neutral element tree — the whole conversion contract of the SDK.
3
+ *
4
+ * A block module's `render` returns *data*, never a component. That single
5
+ * choice is what makes a third-party block possible at all (wayfinder issue 13):
6
+ * if `render` returned Vue vnodes, every plugin would hard-depend on Vue 3, a
7
+ * template could never be pure data, and loading a plugin would be arbitrary
8
+ * code execution. Returning `{tag, attrs, children}` keeps Vue an implementation
9
+ * detail of `src/render/**` and leaves the public contract JSON-serializable.
10
+ *
11
+ * A node is one of:
12
+ *
13
+ * - a string — text content, escaped by the backend
14
+ * - `null` — a deliberately empty slot (the backend emits the same
15
+ * empty comment node Vue does, so its presence or absence
16
+ * is a byte-level decision the author controls)
17
+ * - `{tag, attrs?, children?}` — an element with child nodes
18
+ * - `{tag, attrs?, html}` — an element carrying raw inner HTML
19
+ *
20
+ * `children` and `html` are mutually exclusive: raw content is an island, and an
21
+ * island has no children the tree can reason about.
22
+ *
23
+ * Attribute values pass through verbatim, `null` included — a `null` attribute
24
+ * is omitted by the backend, which is how an optional attribute is expressed
25
+ * without a second node shape. Key order is preserved, and is part of the
26
+ * artifact's byte identity.
27
+ */
28
+
29
+ /** An element with child nodes. `attrs` and `children` are both optional. */
30
+ export function el(tag, attrs = null, children = null) {
31
+ const node = { tag }
32
+ if (attrs !== null && attrs !== undefined) node.attrs = attrs
33
+ if (children !== null && children !== undefined) node.children = children
34
+ return Object.freeze(node)
35
+ }
36
+
37
+ /**
38
+ * An element whose content is raw HTML — the escape hatch a `raw` island, a
39
+ * compiled `prose` run, or a table cell needs. The string is trusted: it has
40
+ * already been through the parser's inline compiler or is an explicit island.
41
+ */
42
+ export function rawEl(tag, attrs, html) {
43
+ const node = { tag }
44
+ if (attrs !== null && attrs !== undefined) node.attrs = attrs
45
+ node.html = String(html ?? '')
46
+ return Object.freeze(node)
47
+ }
48
+
49
+ /** Whether a value is an element node (as opposed to text, null, or garbage). */
50
+ export const isElement = (node) =>
51
+ node !== null && typeof node === 'object' && typeof node.tag === 'string'
@@ -0,0 +1,264 @@
1
+ import { el } from './element.js'
2
+ import { str } from './schema-parts.js'
3
+
4
+ /**
5
+ * `graph` — 節點邊圖 (wayfinder issue 11 P1「資料」; hover 高亮 issue 11 P5).
6
+ *
7
+ * ## The schema is the whole of 13 號票 裁決 5, verbatim
8
+ *
9
+ * A node is `{id, label}`. An edge is `{from, to, label}`. Nothing else, on
10
+ * either, ever: 「diagram 的語意欄位(副標/variant/群組)屬 plugin kind,
11
+ * 核心 `graph` 保持窄」. The battle-map vocabulary that most wants in here —
12
+ * 狀態色, effort, 群組, 節點型別 — is named by that ruling as the thing that
13
+ * belongs to a plugin kind, so `additionalProperties: false` on both shapes is
14
+ * the ruling made mechanical rather than a matter of restraint.
15
+ *
16
+ * That leaves `graph` narrower than `diagram`, which is the point of having
17
+ * both: `diagram` carries a `kind` and is the entry point for the 18-type
18
+ * taxonomy a plugin will grow (SPEC §3.1), while `graph` is the one shape the
19
+ * core promises to keep drawable forever.
20
+ *
21
+ * ## Why the SVG assembly is not shared with `diagram`
22
+ *
23
+ * It could be — the two pictures are drawn from the same layout engine. It is
24
+ * not, and that is a byte decision rather than a taste one: every existing
25
+ * `kami/long-form` artifact embeds `diagram`'s markup verbatim, and a shared
26
+ * helper that produced so much as one different byte would move artifacts this
27
+ * slice is not allowed to move (CONTRACT F6a H6, and `f2b-baseline` would say
28
+ * so immediately). The duplication buys the guarantee that `diagram` is not
29
+ * touched at all; the day both are allowed to move together, they merge.
30
+ *
31
+ * Geometry comes from `ctx.layoutOf(block)` and is never computed here, for the
32
+ * reason issue 13 gives as its 試金石: layout needs the width the page gives the
33
+ * block, which is a page-layout fact no block can see. The render layer measures
34
+ * and feeds it in (`measureLaidOutGraphs` in `src/render/index.js`).
35
+ */
36
+
37
+ const graphNode = Object.freeze({
38
+ type: 'object',
39
+ required: ['id', 'label'],
40
+ properties: { id: { ...str, minLength: 1 }, label: str },
41
+ additionalProperties: false,
42
+ })
43
+ const graphEdge = Object.freeze({
44
+ type: 'object',
45
+ required: ['from', 'to'],
46
+ properties: { from: { ...str, minLength: 1 }, to: { ...str, minLength: 1 }, label: str },
47
+ additionalProperties: false,
48
+ })
49
+
50
+ const isNonEmptyString = (value) => typeof value === 'string' && value.length > 0
51
+
52
+ /**
53
+ * Every problem with one graph block, as human-readable messages.
54
+ *
55
+ * A dangling edge is the failure no schema keyword can state, and drawing it
56
+ * anyway would crash the layout (it dereferences both endpoints) or, worse,
57
+ * quietly drop the edge — a dependency picture that omits a dependency is the
58
+ * one error a reader cannot detect by looking.
59
+ */
60
+ export function graphProblems(block) {
61
+ const problems = []
62
+
63
+ const nodes = block?.nodes
64
+ if (!Array.isArray(nodes) || nodes.length === 0) {
65
+ problems.push('graph.nodes 必須是至少一個節點的陣列')
66
+ return problems
67
+ }
68
+
69
+ const ids = new Set()
70
+ for (const [index, node] of nodes.entries()) {
71
+ if (!isNonEmptyString(node?.id)) {
72
+ problems.push(`nodes[${index}].id 必須是非空字串`)
73
+ continue
74
+ }
75
+ if (ids.has(node.id)) {
76
+ problems.push(`nodes[${index}].id "${node.id}" 重複;節點 id 必須唯一`)
77
+ continue
78
+ }
79
+ ids.add(node.id)
80
+ if (typeof node.label !== 'string') {
81
+ problems.push(`nodes[${index}].label(id "${node.id}")必須是字串`)
82
+ }
83
+ }
84
+
85
+ const edges = block?.edges
86
+ if (!Array.isArray(edges)) {
87
+ problems.push('graph.edges 必須是陣列(沒有邊就給空陣列)')
88
+ return problems
89
+ }
90
+ for (const [index, edge] of edges.entries()) {
91
+ for (const end of ['from', 'to']) {
92
+ if (!isNonEmptyString(edge?.[end])) {
93
+ problems.push(`edges[${index}].${end} 必須是非空字串`)
94
+ continue
95
+ }
96
+ if (!ids.has(edge[end])) {
97
+ problems.push(
98
+ `edges[${index}].${end} 指向不存在的節點 "${edge[end]}";` +
99
+ `已宣告的節點:${[...ids].join(', ')}`,
100
+ )
101
+ }
102
+ }
103
+ if (edge?.label !== undefined && typeof edge.label !== 'string') {
104
+ problems.push(`edges[${index}].label 若存在必須是字串`)
105
+ }
106
+ }
107
+ return problems
108
+ }
109
+
110
+ export default Object.freeze({
111
+ type: 'graph',
112
+
113
+ schema: Object.freeze({
114
+ required: ['nodes', 'edges'],
115
+ properties: {
116
+ nodes: { type: 'array', minItems: 1, items: graphNode },
117
+ edges: { type: 'array', items: graphEdge },
118
+ },
119
+ }),
120
+
121
+ example: Object.freeze({
122
+ block: {
123
+ id: 'b25',
124
+ type: 'graph',
125
+ nodes: [
126
+ { id: 'blocks', label: 'block 模組' },
127
+ { id: 'schema', label: 'IR schema' },
128
+ { id: 'render', label: '渲染層' },
129
+ { id: 'artifact', label: '離線產物' },
130
+ ],
131
+ edges: [
132
+ { from: 'blocks', to: 'schema', label: '推導' },
133
+ { from: 'blocks', to: 'render', label: '中性樹' },
134
+ { from: 'schema', to: 'artifact', label: '內嵌 IR' },
135
+ { from: 'render', to: 'artifact', label: '組裝' },
136
+ ],
137
+ },
138
+ }),
139
+
140
+ styleHooks: Object.freeze([
141
+ 'graph',
142
+ 'graph-svg',
143
+ 'graph-arrow',
144
+ 'graph-edge',
145
+ 'graph-edge-line',
146
+ 'graph-edge-label',
147
+ 'graph-node',
148
+ 'graph-node-box',
149
+ 'graph-node-label',
150
+ ]),
151
+
152
+ create: ({ nodes, edges }) => Object.freeze({ type: 'graph', nodes, edges }),
153
+
154
+ validate: graphProblems,
155
+
156
+ invalid: Object.freeze({
157
+ code: 'KSB_GRAPH_INVALID',
158
+ message: (problems) =>
159
+ `graph block 無法繪製:${problems.join(';')}。` +
160
+ '少畫一條邊的依賴圖,看起來與畫完的那一張一模一樣——所以規格錯了就報錯。',
161
+ }),
162
+
163
+ /**
164
+ * The hover contract, stated here because the client asset reads it:
165
+ * `data-graph` scopes one graph against the others on the page,
166
+ * `data-node-id` names a node, and `data-edge-from` / `data-edge-to` name an
167
+ * edge's endpoints. The script needs nothing else to know what is adjacent to
168
+ * what — the adjacency is already in the markup.
169
+ *
170
+ * The arrowhead marker id is derived from the block id, assigned in document
171
+ * order: unique within an artifact (two graphs cannot capture each other's
172
+ * marker) and stable across renders, so the bytes stay identical.
173
+ */
174
+ render: (block, ctx) => {
175
+ const layout = ctx.layoutOf(block)
176
+ const arrowId = `ksb-graph-arrow-${block.id}`
177
+ return el('figure', { class: 'graph', 'data-graph': block.id }, [
178
+ el(
179
+ 'svg',
180
+ {
181
+ class: 'graph-svg',
182
+ xmlns: 'http://www.w3.org/2000/svg',
183
+ viewBox: `0 0 ${layout.width} ${layout.height}`,
184
+ width: layout.width,
185
+ height: layout.height,
186
+ role: 'img',
187
+ },
188
+ [
189
+ el('defs', null, [
190
+ el(
191
+ 'marker',
192
+ {
193
+ id: arrowId,
194
+ viewBox: '0 0 10 10',
195
+ refX: 9,
196
+ refY: 5,
197
+ markerWidth: 7,
198
+ markerHeight: 7,
199
+ orient: 'auto-start-reverse',
200
+ },
201
+ [el('path', { class: 'graph-arrow', d: 'M 0 0 L 10 5 L 0 10 z' })],
202
+ ),
203
+ ]),
204
+ ...layout.edges.map((edge) =>
205
+ el(
206
+ 'g',
207
+ {
208
+ class: 'graph-edge',
209
+ 'data-edge-from': edge.from,
210
+ 'data-edge-to': edge.to,
211
+ },
212
+ [
213
+ el('path', {
214
+ class: 'graph-edge-line',
215
+ d: edge.d,
216
+ 'marker-end': `url(#${arrowId})`,
217
+ }),
218
+ // Filtered rather than left as `null`: a null child SSRs to an
219
+ // empty comment node, so an unlabelled edge would differ from a
220
+ // labelled one by a stray comment node in the bytes.
221
+ ...(edge.label
222
+ ? [
223
+ el(
224
+ 'text',
225
+ {
226
+ class: 'graph-edge-label',
227
+ x: edge.labelX,
228
+ y: edge.labelY,
229
+ 'text-anchor': edge.labelAnchor,
230
+ },
231
+ [edge.label],
232
+ ),
233
+ ]
234
+ : []),
235
+ ],
236
+ ),
237
+ ),
238
+ ...layout.nodes.map((node) =>
239
+ el('g', { class: 'graph-node', 'data-node-id': node.id, tabindex: '0' }, [
240
+ el('rect', {
241
+ class: 'graph-node-box',
242
+ x: node.x,
243
+ y: node.y,
244
+ width: node.w,
245
+ height: node.h,
246
+ rx: 6,
247
+ }),
248
+ el(
249
+ 'text',
250
+ {
251
+ class: 'graph-node-label',
252
+ x: node.cx,
253
+ y: node.cy + 5,
254
+ 'text-anchor': 'middle',
255
+ },
256
+ [node.label],
257
+ ),
258
+ ]),
259
+ ),
260
+ ],
261
+ ),
262
+ ])
263
+ },
264
+ })