@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,136 @@
1
+ import { blockArrayKeys, blockMatrixKeys } from '../core/blocks.js'
2
+ import { CODES, validationError } from '../core/errors.js'
3
+ import { GRID_COLUMNS, PLACEMENT_KEYS, hasPlacement, placementProblems } from '../blocks/placement.js'
4
+ import { GRID_ADDRESSED_FIELD } from '../layouts/index.js'
5
+
6
+ /**
7
+ * Where a 24-grid coordinate is allowed to mean anything.
8
+ *
9
+ * There are exactly two such places: inside an explicit `grid` container, and
10
+ * inside a layout that has **declared** its whole page addressable by grid
11
+ * coordinates (`gridAddressed`). Everywhere else a coordinate names a cell that
12
+ * does not exist, and CSS auto-placement drops it without a word — so the
13
+ * artifact renders, `lint` returns 0, and the layout is quietly not the one the
14
+ * author wrote.
15
+ *
16
+ * `:::place col=1 colSpan=8` under `kami/long-form` was exactly that: it passed
17
+ * every gate and the coordinates evaporated. Columns are not gentler than rows
18
+ * here — a span nobody applies is as invisible as a row nobody has — so **any**
19
+ * placement outside those two places is refused, which is the failure mode
20
+ * `KSB_PLACEMENT_INVALID`'s own wording condemns.
21
+ *
22
+ * **`bounded` is not the licence.** An earlier version of this gate exempted
23
+ * every bounded canvas, on the reasoning that a fixed logical page "is" a grid.
24
+ * It was not one: `bounded` says only that the canvas has a fixed size and gets
25
+ * scaled to fit, and it says nothing about anyone laying down the 24 tracks a
26
+ * bare `col: 1` would have to resolve against. Exempting it would therefore
27
+ * have re-opened the hole in the one place hardest to notice — the artifact
28
+ * scales beautifully and the coordinate is gone. The licence stays a claim a
29
+ * layout makes on purpose. Since F2f exactly one factory layout makes it:
30
+ * `deck`, whose slides lay the tracks (`src/blocks/slide.js`) — the claim and
31
+ * the tracks landed in the same slice, which is the only way it may be made.
32
+ *
33
+ * The exemption is **whole-page**, as F2a defined it: a layout that says the
34
+ * page is addressable says it about the page, not about one container in it.
35
+ * What the exemption is not is a hole — the relational rule still runs on the
36
+ * exempt path (`assertRelations` below), which is the half F2a deferred here.
37
+ *
38
+ * No block can check any of this for itself: it depends on the enclosing
39
+ * container *and* on the artifact's layout, neither of which is visible from
40
+ * inside a block module. Hence a gate at the layer that knows both, run before
41
+ * a byte is drawn — the same shape as `assertBlockSpecs`, for the same reason.
42
+ */
43
+
44
+ const GRID_TYPE = 'grid'
45
+
46
+ const carried = (block) => PLACEMENT_KEYS.filter((key) => block?.[key] !== undefined && block[key] !== null)
47
+
48
+ /**
49
+ * Walk every block in the tree, handing each one its own path.
50
+ *
51
+ * Shared by both gates below so they can never disagree about what a block's
52
+ * path is: a refusal that names `doc.children[0].slides[1].children[0]` is only
53
+ * actionable if it is the same string an author would get from the other gate.
54
+ */
55
+ function visitBlocks(doc, arrays, matrices, visit) {
56
+ const walk = (node, path, inGrid) => {
57
+ if (node === null || typeof node !== 'object') return
58
+ visit(node, path, inGrid)
59
+ const childInGrid = node.type === GRID_TYPE
60
+ for (const key of matrices) {
61
+ if (!Array.isArray(node[key])) continue
62
+ for (const [group, blocks] of node[key].entries()) {
63
+ if (!Array.isArray(blocks)) continue
64
+ for (const [index, child] of blocks.entries()) {
65
+ walk(child, `${path}.${key}[${group}][${index}]`, childInGrid)
66
+ }
67
+ }
68
+ }
69
+ for (const key of arrays) {
70
+ if (!Array.isArray(node[key])) continue
71
+ for (const [index, child] of node[key].entries()) {
72
+ walk(child, `${path}.${key}[${index}]`, childInGrid)
73
+ }
74
+ }
75
+ }
76
+ walk(doc, 'doc', false)
77
+ }
78
+
79
+ /**
80
+ * The **relational** rule on the exempt path — F2a's deferred half (終驗遞延).
81
+ *
82
+ * `col + colSpan - 1 ≤ 24` cannot be said in JSON Schema, so it is checked by
83
+ * the `grid` module's own `validate` — which only ever runs for `grid` blocks.
84
+ * The moment a layout declares `gridAddressed`, coordinates become legal
85
+ * *outside* any grid container, and that check stops running on them: F2a's
86
+ * gate returned at the first line and nothing downstream looked again. So on a
87
+ * grid-addressed page `col: 20 colSpan: 8` passed every gate and was then
88
+ * clamped by CSS to the 24th column — an artifact whose layout is silently not
89
+ * the one the author wrote, which is precisely what the refusal below condemns.
90
+ *
91
+ * Two defences, each pinned on its own: JSON Schema refuses a coordinate
92
+ * *outside* 1..24 with a block path (`KSB_IR_SCHEMA`), and this refuses a
93
+ * coordinate whose span *ends* outside them.
94
+ */
95
+ function assertRelations(doc, canvas, arrays, matrices) {
96
+ visitBlocks(doc, arrays, matrices, (node, path, inGrid) => {
97
+ // Inside a grid container the grid module already said it, in its own
98
+ // wording. Saying it twice would give one mistake two sentences.
99
+ if (inGrid || !hasPlacement(node)) return
100
+ const problems = placementProblems(path, node)
101
+ if (problems.length === 0) return
102
+ throw validationError(
103
+ `擺位非法:${problems.join(';')}。` +
104
+ `layout \`${canvas?.layout}\` 宣告自己是格軌定址畫布(\`${GRID_ADDRESSED_FIELD}\`),` +
105
+ `整頁就是那 ${GRID_COLUMNS} 格——越界即報錯:` +
106
+ '靜默夾持會讓版面悄悄不是作者寫的那一個。',
107
+ CODES.PLACEMENT_INVALID,
108
+ path,
109
+ )
110
+ })
111
+ }
112
+
113
+ /**
114
+ * @throws {import('../core/errors.js').KsbError} exit 1 / KSB_PLACEMENT_INVALID
115
+ */
116
+ export function assertPlacement(doc, canvas) {
117
+ const arrays = blockArrayKeys()
118
+ const matrices = blockMatrixKeys()
119
+ if (canvas?.[GRID_ADDRESSED_FIELD] === true) {
120
+ assertRelations(doc, canvas, arrays, matrices)
121
+ return
122
+ }
123
+
124
+ visitBlocks(doc, arrays, matrices, (node, path, inGrid) => {
125
+ if (inGrid || !hasPlacement(node)) return
126
+ throw validationError(
127
+ `${path} 帶擺位(${carried(node).join('/')}),但它既不在 grid 容器內,` +
128
+ `所屬 layout \`${canvas?.layout}\` 也沒有宣告自己是格軌定址畫布` +
129
+ `(\`${GRID_ADDRESSED_FIELD}\`)。` +
130
+ '24 格座標只有在版面真的鋪了那些格軌時才指得到東西——沒鋪的版面裡它會被 ' +
131
+ 'CSS 自動排版靜默忽略,於是作者寫的擺位與看到的版面不是同一個。',
132
+ CODES.PLACEMENT_INVALID,
133
+ path,
134
+ )
135
+ })
136
+ }
@@ -0,0 +1,74 @@
1
+ /*
2
+ * Kami slides playback (SPEC section 7.4, CONTRACT C5).
3
+ *
4
+ * A template-layer asset, not a raw island: it ships with the template and is
5
+ * inlined by the render layer, which is what lets SPEC section 11 ban script
6
+ * elements *inside islands* -- interaction is a component concern, never
7
+ * hand-written escape-hatch markup.
8
+ *
9
+ * Every character here is ASCII, and that is a size decision rather than a
10
+ * stylistic one: the skeleton assets are fed to `collectCodepoints`, so one
11
+ * section sign or one em dash in a comment pulls a whole font subset (~8.5KB)
12
+ * into every artifact that carries this file. Pinned across all six scanned
13
+ * surfaces by `test_f2g_skeleton_assets_cannot_derail_the_parser`.
14
+ *
15
+ * Deliberately ES5-shaped and dependency-free: it is embedded verbatim into one
16
+ * offline file, so it must parse in any evergreen browser without a build step.
17
+ * Comparison operators mean a bare left angle bracket does appear below. What
18
+ * must never appear -- in the code or, as this very sentence once proved, in a
19
+ * comment warning against it -- is any of the four sequences banned by
20
+ * `test_f2g_skeleton_assets_cannot_derail_the_parser`: naming two of them here
21
+ * and in the sibling skeleton asset was enough to make the parser read every
22
+ * deck artifact as one script element running to the end of the file, so that
23
+ * neither asset ever ran (CONTRACT F2g H2). Without JS every slide simply stays
24
+ * visible and the deck reads as a scrolling document.
25
+ */
26
+ (function () {
27
+ var deck = document.querySelector('.deck')
28
+ if (!deck) return
29
+ var slides = Array.prototype.slice.call(deck.querySelectorAll('.slide'))
30
+ if (slides.length === 0) return
31
+
32
+ var progress = deck.querySelector('.deck-progress')
33
+ var index = 0
34
+
35
+ function show(next) {
36
+ index = Math.max(0, Math.min(slides.length - 1, next))
37
+ for (var i = 0; i < slides.length; i += 1) {
38
+ var current = i === index
39
+ slides[i].classList.toggle('is-current', current)
40
+ slides[i].setAttribute('aria-hidden', current ? 'false' : 'true')
41
+ }
42
+ if (progress) progress.textContent = index + 1 + ' / ' + slides.length
43
+ }
44
+
45
+ function enterFullscreen() {
46
+ var target = document.documentElement
47
+ if (document.fullscreenElement) return
48
+ if (target.requestFullscreen) target.requestFullscreen()
49
+ }
50
+
51
+ function exitFullscreen() {
52
+ if (document.fullscreenElement && document.exitFullscreen) document.exitFullscreen()
53
+ }
54
+
55
+ document.addEventListener('keydown', function (event) {
56
+ if (event.metaKey || event.ctrlKey || event.altKey) return
57
+ var key = event.key
58
+ if (key === 'ArrowRight' || key === ' ' || key === 'Spacebar' || key === 'PageDown') {
59
+ event.preventDefault()
60
+ show(index + 1)
61
+ } else if (key === 'ArrowLeft' || key === 'PageUp') {
62
+ event.preventDefault()
63
+ show(index - 1)
64
+ } else if (key === 'f' || key === 'F') {
65
+ event.preventDefault()
66
+ enterFullscreen()
67
+ } else if (key === 'Escape' || key === 'Esc') {
68
+ exitFullscreen()
69
+ }
70
+ })
71
+
72
+ deck.classList.add('is-live')
73
+ show(0)
74
+ })()
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Skeleton asset: keep every bounded canvas exactly as large as it can be.
3
+ *
4
+ * Embedded verbatim into any artifact whose layout declares a bounded canvas,
5
+ * so a single edited character moves that artifact's bytes. It assumes nothing
6
+ * about the page beyond the canvas attribute -- a bounded layout this file has
7
+ * never heard of is scaled by it all the same, and gets the same flow geometry
8
+ * and the same print behaviour without having to ask for either.
9
+ *
10
+ * ## Nothing written here may perturb the HTML tokenizer (CONTRACT F2g H2)
11
+ *
12
+ * The four sequences banned by `test_f2g_skeleton_assets_cannot_derail_the_parser`
13
+ * are banned in comments too, and that is not pedantry. This file used to spell
14
+ * one of them out in this very paragraph, inside a sentence warning against it,
15
+ * while the sibling skeleton asset spelled out another in its own header. Apart
16
+ * they were harmless; together they walked the parser into its double-escaped
17
+ * state, where the end tag that should have closed the script is ordinary text.
18
+ * Every artifact carrying both assets therefore shipped a single script element
19
+ * that swallowed the rest of the document, embedded IR and all -- so neither
20
+ * asset ran at all, at any window size, and the artifact still looked plausible
21
+ * because a canvas that is never scaled is simply a canvas at scale 1.
22
+ *
23
+ * ## One scalar for both axes
24
+ *
25
+ * Fitting each axis separately would fill the window and stretch the type,
26
+ * which is the one outcome worse than a black border.
27
+ *
28
+ * ## A transform does not move the flow box (CONTRACT F2g H1)
29
+ *
30
+ * `transform` changes what is painted, never what the layout reserves, so a
31
+ * canvas scaled by k goes on occupying its unscaled height. Where one canvas
32
+ * fills the window that is invisible; where several are stacked in one host it
33
+ * is the whole geometry. Measured on a real browser before this was fixed: at
34
+ * k = 1.2 each canvas overflowed 180px into the next one and the content
35
+ * underneath became unclickable, and at k = 0.27 the same arithmetic opened
36
+ * 656px of dead space instead. So the advance owes the difference,
37
+ * `h * scale - h`, and margin-bottom is where that debt is paid: positive when
38
+ * the canvas grew, negative when it shrank, zero at k = 1. What is left is a
39
+ * gap of exactly nothing, which is the only gap that does not change meaning
40
+ * when everything beside it is scaled.
41
+ *
42
+ * ## Where a canvas centres itself
43
+ *
44
+ * In the space it is given -- and it is given the whole window only when it is
45
+ * the one canvas rendered in its host. Horizontally that always holds, so the
46
+ * horizontal offset is unconditional. Vertically it holds for a paged artifact
47
+ * (one page shown, the others not rendered) and fails for a stack, where
48
+ * centring each canvas in the window *is* the dead space above it. The test is
49
+ * structural on purpose: this file may not learn the name of a layout in order
50
+ * to lay it out.
51
+ */
52
+ ;(function () {
53
+ var ATTR = 'data-ksb-canvas'
54
+ var SCALE_ATTR = 'data-ksb-scale'
55
+
56
+ /*
57
+ * On paper the canvas is drawn at its natural size, so the transform and the
58
+ * flow correction that pays for it stand down together -- keeping one without
59
+ * the other would space the canvases by the very difference it corrects. The
60
+ * rule is written from here, not left to the stylesheet, because a skin that
61
+ * never wrote a print block is exactly the case this file exists to cover.
62
+ */
63
+ var standDown = document.createElement('style')
64
+ standDown.textContent =
65
+ '@media print{[' + ATTR + ']{transform:none !important;margin-bottom:0 !important}}'
66
+ ;(document.head || document.documentElement).appendChild(standDown)
67
+
68
+ /**
69
+ * What every canvas on the page should become -- read in full before any of
70
+ * them moves, because moving one can take a scrollbar with it and change what
71
+ * the next one would have measured.
72
+ */
73
+ function plan() {
74
+ var nodes = document.querySelectorAll('[' + ATTR + ']')
75
+ var steps = []
76
+ for (var i = 0; i < nodes.length; i += 1) {
77
+ var node = nodes[i]
78
+ var size = String(node.getAttribute(ATTR) || '').split('x')
79
+ var w = Number(size[0])
80
+ var h = Number(size[1])
81
+ if (!(w > 0) || !(h > 0)) continue
82
+ var host = node.parentNode
83
+ var vw = host && host.clientWidth ? host.clientWidth : window.innerWidth
84
+ var vh = window.innerHeight
85
+ var scale = Math.min(vw / w, vh / h)
86
+ steps.push({
87
+ node: node,
88
+ host: host,
89
+ /* Empty exactly when the box is not rendered: how a paged artifact says
90
+ that its other pages are not on screen at the moment. */
91
+ shown: node.getClientRects().length > 0,
92
+ scale: scale,
93
+ offsetX: (vw - w * scale) / 2,
94
+ offsetY: (vh - h * scale) / 2,
95
+ advance: h * scale - h,
96
+ })
97
+ }
98
+ return steps
99
+ }
100
+
101
+ /** Whether no other rendered canvas shares this one's host. */
102
+ function alone(steps, step) {
103
+ for (var i = 0; i < steps.length; i += 1) {
104
+ if (steps[i] !== step && steps[i].host === step.host && steps[i].shown) return false
105
+ }
106
+ return true
107
+ }
108
+
109
+ function fit() {
110
+ var steps = plan()
111
+ for (var i = 0; i < steps.length; i += 1) {
112
+ var step = steps[i]
113
+ var scale = step.scale
114
+ var x = step.offsetX
115
+ var y = alone(steps, step) ? step.offsetY : 0
116
+ step.node.style.transformOrigin = 'top left'
117
+ step.node.style.transform = 'translate(' + x + 'px, ' + y + 'px) scale(' + scale + ')'
118
+ step.node.style.marginBottom = step.advance + 'px'
119
+ step.node.setAttribute(SCALE_ATTR, String(scale))
120
+ }
121
+ }
122
+
123
+ window.addEventListener('resize', fit)
124
+ document.addEventListener('fullscreenchange', fit)
125
+ if (document.readyState === 'loading') {
126
+ document.addEventListener('DOMContentLoaded', fit)
127
+ } else {
128
+ fit()
129
+ }
130
+
131
+ document.addEventListener('keydown', function (event) {
132
+ if (event.key !== 'f' && event.key !== 'F') return
133
+ if (document.fullscreenElement) document.exitFullscreen()
134
+ else if (document.documentElement.requestFullscreen) document.documentElement.requestFullscreen()
135
+ })
136
+ })()
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Scale-to-fit — what a **bounded** 文體 gets because of what it is.
3
+ *
4
+ * A bounded artifact (a slide, a card, a one-page site, a résumé) has a *fixed
5
+ * logical canvas*: the author lays out 1280×720 once and the window shows all
6
+ * of it, whatever size the window happens to be (wayfinder issue 14 裁決 4).
7
+ * This is also the answer to v1's 「fullscreen 只多黑框」 — going fullscreen made
8
+ * the page bigger and the artifact exactly as small as before, because nothing
9
+ * was ever scaled.
10
+ *
11
+ * The arithmetic lives here, apart from the browser asset, so it can be
12
+ * asserted without a browser. **One scalar for both axes** is the whole
13
+ * correctness claim: two would fit the canvas exactly and distort every glyph
14
+ * in it, which is the failure mode that looks fine in a screenshot and is
15
+ * unreadable on a projector.
16
+ */
17
+
18
+ /**
19
+ * The attribute and the attribute builder are **defined one layer down**, with
20
+ * the resolved canvas (`src/layouts/canvas.js`), and re-exported here so this
21
+ * module stays the one place the render layer asks about scaling. The reason
22
+ * for the move is in that file: the block that draws a bounded canvas element
23
+ * must be able to spell the attribute without importing the render layer.
24
+ */
25
+ export { CANVAS_ATTR, canvasAttrs } from '../layouts/canvas.js'
26
+
27
+ /**
28
+ * The uniform scale and the offsets that centre the canvas in the viewport.
29
+ *
30
+ * @param {{canvas: {width: number, height: number},
31
+ * viewport: {width: number, height: number}}} input
32
+ * @returns {{scale: number, offsetX: number, offsetY: number}}
33
+ */
34
+ export function scaleToFit({ canvas, viewport }) {
35
+ const scale = Math.min(viewport.width / canvas.width, viewport.height / canvas.height)
36
+ return {
37
+ scale,
38
+ offsetX: (viewport.width - canvas.width * scale) / 2,
39
+ offsetY: (viewport.height - canvas.height * scale) / 2,
40
+ }
41
+ }
@@ -0,0 +1,131 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { CANVAS_BOUNDED } from '../layouts/index.js'
4
+ import { collectBlockTypes } from '../core/vocabulary.js'
5
+
6
+ /**
7
+ * Client assets — the behaviour an artifact gets because of *what it is*,
8
+ * not because of which template drew it.
9
+ *
10
+ * None of them is keyed on a template name, so a third-party template inherits
11
+ * the behaviour without asking and without being able to decline: a slide
12
+ * artifact that cannot be paged through is not a slide artifact, and a bounded
13
+ * canvas that does not scale is a black border.
14
+ *
15
+ * Two keys, two reasons:
16
+ *
17
+ * - **layout-keyed (skeleton)** — `playback` for a deck-rooted layout
18
+ * (wayfinder issue 13 P3), `scale-to-fit` for a bounded canvas
19
+ * (issue 14 裁決 4).
20
+ * - **block-keyed (interaction)** — `board-filter` and `graph-hover`
21
+ * (issue 11 P5), embedded only into artifacts that carry the block the
22
+ * interaction belongs to. That condition is what keeps every artifact drawn
23
+ * before F6a byte for byte unchanged: a document with no `board` and no
24
+ * `graph` gets exactly the script it got before, and a document with no
25
+ * behaviour at all still gets no script element at all.
26
+ */
27
+
28
+ /**
29
+ * `playback.client.js` used to live in `templates/kami/slides/`, which made
30
+ * "a deck can be paged through" a property of one template rather than of
31
+ * decks. Its own header still describes itself as a template asset: the file is
32
+ * embedded into every deck artifact **verbatim**, so a single edited character
33
+ * moves the artifact's bytes (CONTRACT A2). The header is corrected together
34
+ * with SPEC §7.4, in the slice that is allowed to move those bytes *and* to
35
+ * edit the SPEC — F2f was allowed the first and not the second, so it left the
36
+ * pair alone rather than making the file and the SPEC disagree.
37
+ */
38
+ const PLAYBACK_PATH = fileURLToPath(new URL('./playback.client.js', import.meta.url))
39
+ const SCALE_PATH = fileURLToPath(new URL('./scale-to-fit.client.js', import.meta.url))
40
+ const BOARD_FILTER_PATH = fileURLToPath(new URL('./board-filter.client.js', import.meta.url))
41
+ const GRAPH_HOVER_PATH = fileURLToPath(new URL('./graph-hover.client.js', import.meta.url))
42
+
43
+ /**
44
+ * Every asset this module can embed, by role — the **single source of the list**.
45
+ *
46
+ * Exported because the list has judges: the scan that keeps these files free of
47
+ * the sequences that would derail the HTML tokenizer, and free of non-ASCII,
48
+ * has to run over *all* of them. A hand-copied list in the test file is a list
49
+ * that silently misses the third asset the day one arrives — the same shape as
50
+ * the "至少" thresholds F2d's seal replaced with set equality. So the test
51
+ * derives its scan targets from here, and separately asserts this object's
52
+ * value set equals the `*.client.js` files actually sitting in this directory:
53
+ * adding a file without wiring it, or wiring one that does not exist, is red
54
+ * either way (CONTRACT F2g H4).
55
+ */
56
+ export const SKELETON_ASSETS = Object.freeze({
57
+ playback: PLAYBACK_PATH,
58
+ scale: SCALE_PATH,
59
+ boardFilter: BOARD_FILTER_PATH,
60
+ graphHover: GRAPH_HOVER_PATH,
61
+ })
62
+
63
+ /** Verbatim — the root form that makes an artifact a deck. */
64
+ const DECK_ROOT = 'deck'
65
+
66
+ /**
67
+ * Which block type pulls in which interaction asset (issue 11 P5).
68
+ *
69
+ * Order is load-bearing in the same, dull way the module list is: it is the
70
+ * order the assets are concatenated in, and therefore part of the artifact's
71
+ * bytes. It follows the canonical block order, so it never has to be argued
72
+ * about again.
73
+ */
74
+ const INTERACTION_ASSETS = Object.freeze([
75
+ Object.freeze({ type: 'board', path: BOARD_FILTER_PATH }),
76
+ Object.freeze({ type: 'graph', path: GRAPH_HOVER_PATH }),
77
+ ])
78
+
79
+ /**
80
+ * The inline script for an artifact, or `undefined` when the skeleton has
81
+ * nothing to add (a layout without behaviour emits no element at all, which is
82
+ * what keeps its artifacts byte-identical).
83
+ *
84
+ * @param {{rootForm?: string|null, canvas?: string}} layout
85
+ * @returns {string|undefined}
86
+ */
87
+ export function skeletonScript(layout) {
88
+ const assets = []
89
+ if (layout?.rootForm === DECK_ROOT) assets.push(readFileSync(PLAYBACK_PATH, 'utf8'))
90
+ if (layout?.canvas === CANVAS_BOUNDED) assets.push(readFileSync(SCALE_PATH, 'utf8'))
91
+ return assets.length === 0 ? undefined : assets.join('\n')
92
+ }
93
+
94
+ /**
95
+ * The inline script the *blocks in this document* ask for, or `undefined` when
96
+ * none of them does.
97
+ *
98
+ * Keyed on the document rather than on the template's declared vocabulary: a
99
+ * template may admit `board` and this document may contain none, and shipping
100
+ * the filter into an artifact with nothing to filter is dead weight in a file
101
+ * whose whole promise is that it is self-contained. It also makes the byte
102
+ * claim exact — every artifact rendered before F6a contains neither block, so
103
+ * every one of them still gets `undefined` here.
104
+ *
105
+ * @param {object} doc the canonical block tree
106
+ * @returns {string|undefined}
107
+ */
108
+ export function interactionScript(doc) {
109
+ const present = collectBlockTypes(doc)
110
+ const assets = INTERACTION_ASSETS.filter(({ type }) => present.has(type)).map(({ path }) =>
111
+ readFileSync(path, 'utf8'),
112
+ )
113
+ return assets.length === 0 ? undefined : assets.join('\n')
114
+ }
115
+
116
+ /**
117
+ * Everything that goes into the artifact's one behaviour script, in order:
118
+ * skeleton first (it decides what the page *is*), interaction after.
119
+ *
120
+ * `undefined` rather than an empty string when there is nothing to say — a
121
+ * layout without behaviour emits no script element at all, which is what keeps
122
+ * its artifacts byte-identical.
123
+ *
124
+ * @returns {string|undefined}
125
+ */
126
+ export function artifactScript(layout, doc) {
127
+ const parts = [skeletonScript(layout), interactionScript(doc)].filter(
128
+ (part) => typeof part === 'string',
129
+ )
130
+ return parts.length === 0 ? undefined : parts.join('\n')
131
+ }
@@ -0,0 +1,24 @@
1
+ import { createSSRApp } from 'vue'
2
+ import { renderToString } from 'vue/server-renderer'
3
+ import { toVNode } from './element.js'
4
+
5
+ /**
6
+ * Server-render a layout's root form against a doc block tree.
7
+ *
8
+ * The root is a plain function returning a neutral element tree, so Vue never
9
+ * sees the layout, the template or any block module — it only sees the vnodes
10
+ * `toVNode` builds. That is what makes the SSR backend replaceable and both a
11
+ * layout and a template package pure data (wayfinder issue 13 裁決 3).
12
+ *
13
+ * The root arrives as a function rather than as a package, because since F2a it
14
+ * comes from the **layout**: a template supplies skin, config and chrome, and
15
+ * the shape of the page is the 文體's (CONTRACT C1).
16
+ *
17
+ * @param {(doc: object, ctx: object) => object} root the layout's root form
18
+ */
19
+ export async function renderBody(root, doc, ctx) {
20
+ const app = createSSRApp({
21
+ render: () => toVNode(root(doc, ctx)),
22
+ })
23
+ return await renderToString(app)
24
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Layout geometry flows **into** the stylesheet, never out of it.
3
+ *
4
+ * `--measure: 740px` used to be a constant typed into
5
+ * `templates/kami/long-form/styles.css`, while `manifest.measure: 740` restated
6
+ * the same number as data so a diagram could be measured against it (量測報告
7
+ * §4.2). Two copies of one number, in two languages, with nothing keeping them
8
+ * in step — and the CSS copy was the one the reader actually saw.
9
+ *
10
+ * So the stylesheet now declares *where* a geometry value goes and the config
11
+ * says *what* it is: inside `:root`, a CSS comment reading `ksb:measure
12
+ * --measure` is replaced by the declaration `--measure: 740px;`.
13
+ *
14
+ * The marker is a CSS comment, so a stylesheet stays a valid stylesheet and can
15
+ * be opened, linted and edited by anything that understands CSS. It names the
16
+ * canvas knob it wants and the custom property to write it into, which is what
17
+ * lets `kami/slides` fill `--slide-measure` from `maxWidth` without the SDK
18
+ * knowing either name.
19
+ *
20
+ * Substituting in place (rather than prepending a `:root` block) is also what
21
+ * keeps CONTRACT C5 satisfiable at all: the emitted CSS is byte-for-byte the
22
+ * text that was there before the marker replaced the constant.
23
+ */
24
+
25
+ /** A CSS comment reading `ksb:<knob> --<custom-property>` — the one legal form. */
26
+ const MARKER = /\/\*\s*ksb:([A-Za-z][A-Za-z0-9]*)\s+(--[A-Za-z0-9-]+)\s*\*\//g
27
+
28
+ /**
29
+ * Fill every geometry marker in a template's stylesheet.
30
+ *
31
+ * What a marker may name is **whatever the resolved canvas holds as a number** —
32
+ * the two canvas dimensions every 文體 has, plus whichever knobs that 文體
33
+ * declared for itself (`article` declares `railWidth`; CONTRACT F2b G1). A
34
+ * hard-coded list here would mean a third-party layout could define a knob, a
35
+ * template could turn it, and the stylesheet still had no way to read it
36
+ * without an edit inside the SDK — which is the one thing the layout axis
37
+ * exists to make unnecessary.
38
+ *
39
+ * An unknown knob, or one that is not a number (a boolean switch, a side), is
40
+ * left exactly as written rather than replaced with `undefined`: a typo must
41
+ * not silently become a broken declaration that still parses.
42
+ * `test_f2a_style_markers_resolved` asserts no marker survives in the factory
43
+ * templates, so a typo is loud in the suite instead of in the artifact.
44
+ *
45
+ * @param {string} css the template's raw stylesheet
46
+ * @param {{maxWidth: number, measure: number}} canvas the resolved canvas
47
+ */
48
+ export function injectLayoutVars(css, canvas) {
49
+ return String(css ?? '').replace(MARKER, (whole, knob, property) => {
50
+ if (typeof canvas?.[knob] !== 'number') return whole
51
+ return `${property}: ${canvas[knob]}px;`
52
+ })
53
+ }
54
+
55
+ /** Whether any geometry marker is still unfilled — for the suite, not the pipeline. */
56
+ export const unresolvedMarkers = (css) => String(css ?? '').match(MARKER) ?? []