@lincyaw/kernel 1.0.0-rc.0 → 1.0.0-rc.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 (119) hide show
  1. package/README.md +7 -7
  2. package/package.json +6 -2
  3. package/src/access.ts +18 -0
  4. package/src/anchors.ts +5 -1
  5. package/src/authoring.ts +56 -0
  6. package/src/blocks.ts +0 -8
  7. package/src/component-code.ts +6 -0
  8. package/src/contracts/agent.ts +5 -82
  9. package/src/contracts/anchors.ts +46 -0
  10. package/src/contracts/authoring-actions.ts +60 -0
  11. package/src/contracts/authoring-clients.ts +213 -0
  12. package/src/contracts/authoring.ts +180 -0
  13. package/src/contracts/checks.ts +29 -0
  14. package/src/contracts/classroom.ts +49 -0
  15. package/src/contracts/diagnostics.ts +37 -0
  16. package/src/contracts/document-audio.ts +150 -0
  17. package/src/contracts/document-comments.ts +31 -0
  18. package/src/contracts/document-history.ts +63 -0
  19. package/src/contracts/document-image.ts +32 -0
  20. package/src/contracts/document-reads.ts +57 -0
  21. package/src/contracts/document-voice.ts +44 -0
  22. package/src/contracts/document.ts +240 -0
  23. package/src/contracts/execution.ts +4 -5
  24. package/src/contracts/locale.ts +3 -0
  25. package/src/contracts/offline.ts +2 -0
  26. package/src/contracts/plugin.ts +183 -0
  27. package/src/contracts/presenter-panels.ts +17 -0
  28. package/src/contracts/preview.ts +305 -310
  29. package/src/contracts/production-assets.ts +61 -0
  30. package/src/contracts/production.ts +109 -0
  31. package/src/contracts/projects.ts +59 -0
  32. package/src/contracts/server.ts +1 -51
  33. package/src/contracts/speech-settings.ts +16 -0
  34. package/src/contracts/speech.ts +82 -70
  35. package/src/contracts/steps.ts +15 -0
  36. package/src/contracts/transcription-adoption.ts +79 -0
  37. package/src/contracts/transcription.ts +112 -0
  38. package/src/contracts/voice.ts +97 -0
  39. package/src/contracts/voiceover-execution.ts +68 -0
  40. package/src/contracts/workspace.ts +458 -0
  41. package/src/contracts.ts +28 -278
  42. package/src/diagnostics.ts +7 -0
  43. package/src/document-authoring-actions.ts +47 -0
  44. package/src/document-caption-timings.ts +64 -0
  45. package/src/document-colors.ts +25 -0
  46. package/src/document-comments.ts +118 -0
  47. package/src/document-components.ts +251 -0
  48. package/src/document-operations.ts +132 -0
  49. package/src/document-pages.ts +215 -0
  50. package/src/document-reads.ts +81 -0
  51. package/src/document-schema.ts +85 -0
  52. package/src/document-voices.ts +153 -0
  53. package/src/document.ts +252 -0
  54. package/src/durations.ts +7 -0
  55. package/src/errors.ts +8 -0
  56. package/src/execution.ts +5 -1
  57. package/src/fingerprint.ts +1 -1
  58. package/src/follow.ts +26 -30
  59. package/src/git.ts +1 -1
  60. package/src/index.ts +66 -13
  61. package/src/ir.ts +54 -5
  62. package/src/jupyter-kernel.ts +9 -0
  63. package/src/kernel.ts +38 -25
  64. package/src/keyboard.ts +43 -0
  65. package/src/locale.ts +13 -0
  66. package/src/lock.ts +4 -8
  67. package/src/logging.ts +1 -1
  68. package/src/{markdown.ts → lower-myst.ts} +2 -1
  69. package/src/node/assets.ts +23 -0
  70. package/src/node/authoring-actions.ts +50 -0
  71. package/src/node/authoring-clients.ts +14 -0
  72. package/src/node/document-audio.ts +22 -0
  73. package/src/node/document-history.ts +11 -0
  74. package/src/node/document-image.ts +8 -0
  75. package/src/node/document-reads.ts +10 -0
  76. package/src/node/document-voice.ts +7 -0
  77. package/src/node/ffmpeg.ts +29 -0
  78. package/src/node/file-version.ts +6 -0
  79. package/src/node/fonts.ts +22 -0
  80. package/src/node/index.ts +29 -133
  81. package/src/node/installed-packages.ts +11 -0
  82. package/src/node/lines.ts +24 -0
  83. package/src/node/locale.ts +7 -0
  84. package/src/node/network.ts +11 -0
  85. package/src/node/operator-speech.ts +91 -0
  86. package/src/node/packages.ts +74 -0
  87. package/src/node/paths.ts +7 -0
  88. package/src/node/projects.ts +7 -0
  89. package/src/node/resources.ts +86 -0
  90. package/src/node/revision.ts +6 -0
  91. package/src/node/run-directory.ts +70 -0
  92. package/src/node/transcription-adoption.ts +9 -0
  93. package/src/node/transcription.ts +10 -0
  94. package/src/node/voiceover-routes.ts +73 -0
  95. package/src/node/voiceover.ts +199 -0
  96. package/src/page-source.ts +359 -0
  97. package/src/parameters.ts +81 -0
  98. package/src/preview-edit.ts +18 -0
  99. package/src/preview-events.ts +57 -0
  100. package/src/preview-frame.ts +1220 -0
  101. package/src/props.ts +17 -0
  102. package/src/push-stream.ts +67 -0
  103. package/src/reading.ts +12 -7
  104. package/src/recording.ts +38 -0
  105. package/src/renderer-components.ts +30 -0
  106. package/src/responses.ts +49 -0
  107. package/src/run-outputs.ts +15 -0
  108. package/src/runner.py +3 -3
  109. package/src/services.ts +6 -6
  110. package/src/slides.ts +36 -0
  111. package/src/speech-words.ts +76 -0
  112. package/src/speech.ts +13 -0
  113. package/src/step-keys.ts +38 -0
  114. package/src/steps.ts +7 -0
  115. package/src/transcription-adoption.ts +105 -0
  116. package/src/uri-decoding.ts +8 -0
  117. package/src/urls.ts +13 -0
  118. package/src/views.ts +47 -0
  119. package/src/voice.ts +86 -0
@@ -0,0 +1,359 @@
1
+ import { htmlAttributes } from './html.ts'
2
+ import { irBlocks, nodeMdast } from './blocks.ts'
3
+ import { mdastText } from './reading.ts'
4
+ import { urlSlug } from './urls.ts'
5
+ import type { IRDocument, IRSection, MdastNode } from './ir.ts'
6
+ import type { SourceCapabilities } from './contracts/workspace.ts'
7
+
8
+ interface Line { text: string, start: number, end: number }
9
+
10
+ export type PageEditReason = 'page-multiple-files' | 'page-boundary' | 'page-hierarchy' | 'page-implicit-title' | 'page-formatted-title'
11
+ export interface PageEditCapability { rename: boolean, move: boolean, reason?: PageEditReason }
12
+
13
+ /** Coarse IR-level availability; every write also validates the current source snapshot. */
14
+ export function pageEditCapability(doc: IRDocument | undefined, id: string): PageEditCapability {
15
+ const section = doc?.sections.find(section => section.id === id)
16
+ if (!doc || !section) return { rename: false, move: false, reason: 'page-boundary' }
17
+ if (doc.sections.some(page => page.source.file !== doc.meta.entry || [...irBlocks(page.blocks)].some(block => block.source.file !== doc.meta.entry)))
18
+ return { rename: false, move: false, reason: 'page-multiple-files' }
19
+ const index = doc.sections.indexOf(section)
20
+ const move = section.depth > 0 && [doc.sections[index - 1], doc.sections[index + 1]].some(other => other?.depth === section.depth)
21
+ return { rename: true, move, ...move ? {} : { reason: 'page-hierarchy' } }
22
+ }
23
+
24
+ function sourceLines(source: string): Line[] {
25
+ const lines: Line[] = []
26
+ let start = 0
27
+ for (const match of source.matchAll(/\r\n|\n|\r/g)) {
28
+ lines.push({ text: source.slice(start, match.index), start, end: match.index + match[0].length })
29
+ start = match.index + match[0].length
30
+ }
31
+ lines.push({ text: source.slice(start), start, end: source.length })
32
+ if (lines[0]?.text.startsWith('\uFEFF')) { lines[0].text = lines[0].text.slice(1); lines[0].start = 1 }
33
+ return lines
34
+ }
35
+
36
+ /** Fence state is only a safety check; the compiler's ranges determine placement. */
37
+ function rootLines(lines: Line[]): boolean[] {
38
+ const stack: { char: string, count: number, literal: boolean }[] = []
39
+ let comment = false
40
+ const roots = lines.map(({ text }) => {
41
+ const root = stack.length === 0 && !comment
42
+ if (comment) { if (text.includes('-->')) comment = false; return false }
43
+ const last = stack.at(-1)
44
+ if (last?.literal) {
45
+ const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(text)
46
+ if (close && close[1]![0] === last.char && close[1]!.length >= last.count) stack.pop()
47
+ return false
48
+ }
49
+ const fence = /^ {0,3}(`{3,}|~{3,}|:{3,})(.*)$/.exec(text)
50
+ if (fence) {
51
+ const run = fence[1]!, tail = fence[2]!.trim()
52
+ if (!tail && last && run[0] === last.char && run.length >= last.count) stack.pop()
53
+ else if (run[0] !== ':' || /^\{[^}]+\}/.test(tail)) stack.push({ char: run[0]!, count: run.length, literal: !/^\{[^}]+\}/.test(tail) })
54
+ return root
55
+ }
56
+ if (text.includes('<!--') && !text.slice(text.indexOf('<!--') + 4).includes('-->')) comment = true
57
+ return root
58
+ })
59
+ roots.push(stack.length === 0 && !comment)
60
+ return roots
61
+ }
62
+
63
+ function heading(lines: Line[], roots: boolean[], at: number): { depth: number, end: number } | undefined {
64
+ if (!roots[at]) return undefined
65
+ const hash = /^ {0,3}(#{1,6})(?:[ \t]+|$)/.exec(lines[at]?.text ?? '')
66
+ if (hash) return { depth: hash[1]!.length, end: at }
67
+ if (lines[at]?.text.trim() && roots[at + 1]) {
68
+ const underline = /^ {0,3}(=+|-+)[ \t]*$/.exec(lines[at + 1]?.text ?? '')
69
+ if (underline) return { depth: underline[1]![0] === '=' ? 1 : 2, end: at + 1 }
70
+ }
71
+ return undefined
72
+ }
73
+
74
+ function sectionDepth(lines: Line[]): number {
75
+ if (!/^---[ \t]*$/.test(lines[0]?.text ?? '')) return 2
76
+ for (let at = 1; at < lines.length; at++) {
77
+ const text = lines[at]!.text
78
+ if (/^---[ \t]*$/.test(text)) return 2
79
+ if (!/^sectionDepth\s*:/.test(text)) continue
80
+ const value = /^sectionDepth\s*:\s*(\d+)\s*(?:#.*)?$/.exec(text)
81
+ if (!value) throw new Error('source-changed')
82
+ const depth = Number(value[1])
83
+ return depth >= 1 && Number.isSafeInteger(depth) ? depth : 2
84
+ }
85
+ throw new Error('source-changed')
86
+ }
87
+
88
+ function labelStart(lines: Line[], at: number, expected: string): number {
89
+ let before = at - 1
90
+ while (before >= 0 && !lines[before]!.text.trim()) before--
91
+ const label = /^\(([^)]+)\)=[ \t]*$/.exec(lines[before]?.text.trim() ?? '')
92
+ if (!label) return at
93
+ if (label[1] !== expected) throw new Error('source-changed')
94
+ return before
95
+ }
96
+
97
+ interface PageSpan {
98
+ section: IRSection
99
+ start: number
100
+ end: number
101
+ heading: number
102
+ headingEnd: number
103
+ depth: number
104
+ labelled: boolean
105
+ }
106
+
107
+ /** Range checks are shared with insertion; source is sliced verbatim rather than rendered back to Markdown. */
108
+ function pageSpans(doc: IRDocument, source: string): { spans: PageSpan[], lines: Line[], newline: string } {
109
+ if (!doc.sections.length) throw new Error('page-boundary')
110
+ if (doc.sections.some(section => section.source.file !== doc.meta.entry || [...irBlocks(section.blocks)].some(block => block.source.file !== doc.meta.entry))) throw new Error('page-multiple-files')
111
+ const lines = sourceLines(source), roots = rootLines(lines), threshold = sectionDepth(lines)
112
+ // MyST cells carry boundaries and metadata beyond the section source ranges.
113
+ if (lines.some((line, index) => roots[index] && /^ {0,3}\+{3,}/.test(line.text))) throw new Error('page-boundary')
114
+ const starts = doc.sections.map(section => {
115
+ const at = section.source.line - 1
116
+ if (at < 0 || at >= lines.length || !roots[at]) throw new Error('page-boundary')
117
+ return labelStart(lines, at, section.id)
118
+ })
119
+ const spans = doc.sections.map((section, index): PageSpan => {
120
+ const at = section.source.line - 1
121
+ const actual = heading(lines, roots, at)
122
+ const last = (section.source.endLine ?? section.source.line) - 1
123
+ const boundary = starts[index + 1] ?? lines.length
124
+ if (last < at || last >= boundary || !roots[last + 1] || lines.slice(last + 1, boundary).some(line => line.text.trim())) throw new Error('page-boundary')
125
+ if (actual && (actual.depth > threshold || section.depth > 0 && actual.depth !== section.depth)) throw new Error('page-boundary')
126
+ if (!actual && section.depth > 0) throw new Error('page-boundary')
127
+ // Unexpected page headings mean the IR ranges no longer describe this source snapshot.
128
+ for (let line = (actual?.end ?? at) + 1; line <= last; line++) {
129
+ const nested = heading(lines, roots, line)
130
+ if (nested && nested.depth <= threshold) throw new Error('page-boundary')
131
+ if (nested) line = nested.end
132
+ }
133
+ const start = lines[starts[index]!]!.start
134
+ const end = index + 1 < starts.length ? lines[starts[index + 1]!]!.start : source.length
135
+ return { section, start, end, heading: at, headingEnd: actual?.end ?? at, depth: actual?.depth ?? 0, labelled: starts[index] !== at }
136
+ })
137
+ return { spans, lines, newline: /\r\n|\n|\r/.exec(source)?.[0] ?? '\n' }
138
+ }
139
+
140
+ function pageLabel(span: PageSpan, newline: string, capabilities: SourceCapabilities): string {
141
+ if (span.labelled) return ''
142
+ if (!/^[\p{L}\p{N}_.-]{1,200}$/u.test(span.section.id)) throw new Error('page-boundary')
143
+ if (/[^\x00-\x7f]/.test(span.section.id) && !capabilities.unicodePageTargets) throw new Error('page-compiler')
144
+ return `(${span.section.id})=${newline}${newline}`
145
+ }
146
+
147
+ function escapedTitle(title: string): string {
148
+ // MyST's table tokenizer still sees an escaped pipe before a Setext underline.
149
+ return title.replace(/[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{}~]/g, char => `\\${char}`).replace(/\|/g, '&#124;')
150
+ }
151
+
152
+ /** A native text edit also retains the page's existing identity, including formatted headings. */
153
+ export function pinPageSource(doc: IRDocument, id: string, source: string, capabilities: SourceCapabilities = {}): string {
154
+ const { spans, lines, newline } = pageSpans(doc, source)
155
+ const span = spans.find(span => span.section.id === id)
156
+ if (!span) throw new Error('page-boundary')
157
+ if (!span.depth) throw new Error('page-implicit-title')
158
+ const label = pageLabel(span, newline, capabilities)
159
+ if (!label) return source
160
+ const start = lines[span.heading]!.start
161
+ return `${source.slice(0, start)}${label}${source.slice(start)}`
162
+ }
163
+
164
+ /** Renaming pins the original page ID and changes only its visible title line. */
165
+ export function renamePageSource(doc: IRDocument, id: string, source: string, title: string, capabilities: SourceCapabilities = {}): string {
166
+ if (!title.trim() || /[\r\n\0]/.test(title)) throw new Error('page-title')
167
+ const { spans, lines, newline } = pageSpans(doc, source)
168
+ const span = spans.find(span => span.section.id === id)
169
+ if (!span) throw new Error('page-boundary')
170
+ if (!span.depth) throw new Error('page-implicit-title')
171
+ const first = lines[span.heading]!
172
+ const hash = /^( {0,3})(#{1,6})[ \t]+(.*?)(?:[ \t]+#+[ \t]*)?$/.exec(first.text)
173
+ const shown = hash ? hash[3]!.trim() : first.text.trim()
174
+ // Complex heading syntax needs a source-aware text lens. Refuse it rather than flattening formatting.
175
+ if (shown !== span.section.title && shown !== escapedTitle(span.section.title)) throw new Error('page-formatted-title')
176
+ if (title.trim() === span.section.title) return source
177
+ const line = hash ? `${hash[1]}${hash[2]} ${escapedTitle(title.trim())}` : escapedTitle(title.trim())
178
+ const label = pageLabel(span, newline, capabilities)
179
+ return `${source.slice(0, first.start)}${label}${line}${source.slice(first.start + first.text.length)}`
180
+ }
181
+
182
+ /** Moves a complete peer page; labels in the affected interval keep duplicate-title identities stable. */
183
+ export function movePageSource(doc: IRDocument, id: string, before: string | null, source: string, capabilities: SourceCapabilities = {}): string {
184
+ const { spans, lines, newline } = pageSpans(doc, source)
185
+ const from = spans.findIndex(span => span.section.id === id)
186
+ const to = before === null ? spans.length : spans.findIndex(span => span.section.id === before)
187
+ if (from < 0 || to < 0) throw new Error('page-boundary')
188
+ if (from === to || from + 1 === to) return source
189
+ const first = Math.min(from, to), last = Math.max(from, to - 1)
190
+ const peers = spans.slice(first, last + 1)
191
+ const depth = spans[from]!.section.depth
192
+ if (!depth || peers.some(span => span.section.depth !== depth || span.depth !== depth)) throw new Error('page-hierarchy')
193
+ const pieces = peers.map(span => {
194
+ const offset = lines[span.heading]!.start - span.start
195
+ const raw = source.slice(span.start, span.end)
196
+ return { id: span.section.id, raw: `${raw.slice(0, offset)}${pageLabel(span, newline, capabilities)}${raw.slice(offset)}` }
197
+ })
198
+ const moved = pieces.splice(from - first, 1)[0]!
199
+ pieces.splice((to > from ? to - 1 : to) - first, 0, moved)
200
+ const separated = pieces.map(piece => /(?:\r\n|\n|\r)[ \t]*(?:\r\n|\n|\r)[ \t]*$/.test(piece.raw) ? piece.raw : `${piece.raw}${piece.raw.endsWith(newline) ? newline : `${newline}${newline}`}`).join('')
201
+ return `${source.slice(0, peers[0]!.start)}${separated}${source.slice(peers.at(-1)!.end)}`
202
+ }
203
+
204
+ export interface PageRemoval {
205
+ content: string
206
+ /** The page selected only after the new source revision has compiled. */
207
+ selected: string
208
+ retainedEmpty: boolean
209
+ }
210
+
211
+ /** Removed labels include nested Markdown targets, not only the page's top-level ID. */
212
+ function pageTargets(section: IRSection, raw: string): Set<string> {
213
+ const targets = new Set([section.id])
214
+ const walk = (node: MdastNode): void => {
215
+ if (!['crossReference', 'linkReference', 'imageReference', 'footnoteReference'].includes(node.type)) {
216
+ for (const key of ['identifier', 'label', 'html_id']) if (typeof node[key] === 'string') targets.add(node[key] as string)
217
+ }
218
+ if (node.type === 'footnoteDefinition' && typeof node.identifier === 'string') targets.add(`fn-${node.identifier}`)
219
+ if (node.type === 'heading') {
220
+ const text = mdastText(node)
221
+ targets.add(urlSlug(text))
222
+ targets.add(text.normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu, '').trim().replace(/[\s_-]+/g, '-') || 'section')
223
+ }
224
+ if (node.type === 'html' && typeof node.value === 'string') for (const attribute of htmlAttributes(node.value, new Set(['id', 'name']))) targets.add(attribute.value)
225
+ for (const child of node.children ?? []) walk(child)
226
+ }
227
+ for (const block of irBlocks(section.blocks)) {
228
+ targets.add(block.id)
229
+ for (const node of nodeMdast(block.node)) walk(node)
230
+ }
231
+ for (const match of raw.matchAll(/^ {0,3}\(([^)\r\n]+)\)=[ \t]*$/gm)) targets.add(match[1]!)
232
+ return targets
233
+ }
234
+
235
+ export function pageRemovalTargets(doc: IRDocument, id: string, source: string): Set<string> {
236
+ const { spans } = pageSpans(doc, source)
237
+ const span = spans.find(span => span.section.id === id)
238
+ if (!span) throw new Error('page-boundary')
239
+ return pageTargets(span.section, source.slice(span.start, span.end))
240
+ }
241
+
242
+ /** Explicit links from another document use workspace-relative entry paths. */
243
+ export function pageIncomingReferences(doc: IRDocument, entry: string, targets: ReadonlySet<string>): { section: string, block?: string }[] {
244
+ const found: { section: string, block?: string }[] = []
245
+ const directory = entry.slice(0, entry.lastIndexOf('/') + 1)
246
+ const paths = new Set([`/${entry}`, `/${directory}`, `/${directory.replace(/\/$/, '')}`, `/${directory}index.html`])
247
+ const pointsToTarget = (url: string, source: string): boolean => {
248
+ try {
249
+ const resolved = new URL(url, `https://presmium.invalid/${source}`)
250
+ return resolved.origin === 'https://presmium.invalid' && paths.has(decodeURIComponent(resolved.pathname)) && targets.has(decodeURIComponent(resolved.hash.slice(1)))
251
+ } catch { return url.includes('#') }
252
+ }
253
+ const walk = (node: MdastNode, source: string): boolean => {
254
+ const urls = ['url', 'href', 'link'].flatMap(key => typeof node[key] === 'string' ? [node[key] as string] : [])
255
+ if (node.type === 'html' && typeof node.value === 'string') for (const attribute of htmlAttributes(node.value, new Set(['href']))) urls.push(attribute.value)
256
+ if (urls.some(url => pointsToTarget(url, source))) return true
257
+ return (node.children ?? []).some(child => walk(child, source))
258
+ }
259
+ for (const section of doc.sections) for (const block of irBlocks(section.blocks)) {
260
+ if ([...nodeMdast(block.node)].some(node => walk(node, block.source.file))) found.push({ section: section.id, block: block.id })
261
+ }
262
+ const configReferences = (value: unknown): boolean => {
263
+ if (!value || typeof value !== 'object') return false
264
+ if (Array.isArray(value)) return value.some(configReferences)
265
+ return Object.entries(value).some(([key, nested]) => ['url', 'href', 'link'].includes(key) && typeof nested === 'string' ? pointsToTarget(nested, doc.meta.entry) : configReferences(nested))
266
+ }
267
+ if (configReferences(doc.config)) found.push({ section: doc.sections[0]?.id ?? '' })
268
+ return found
269
+ }
270
+
271
+ function referencesTargets(node: unknown, targets: Set<string>, entry: string): boolean {
272
+ if (!node || typeof node !== 'object') return false
273
+ if (Array.isArray(node)) return node.some(value => referencesTargets(value, targets, entry))
274
+ const record = node as Record<string, unknown>
275
+ if (record.pages && typeof record.pages === 'object' && Object.keys(record.pages).some(key => targets.has(key))) return true
276
+ if (['crossReference', 'linkReference', 'imageReference', 'footnoteReference'].includes(String(record.type))) {
277
+ if ([record.identifier, record.label].some(value => typeof value === 'string' && targets.has(value))) return true
278
+ }
279
+ const pointsToTarget = (url: string): boolean => {
280
+ const hash = url.indexOf('#')
281
+ if (hash < 0) return false
282
+ const path = url.slice(0, hash).split('?')[0]!
283
+ if (path && path !== entry && path !== `./${entry}` && !entry.endsWith(`/${path}`)) return false
284
+ try { return targets.has(decodeURIComponent(url.slice(hash + 1))) } catch { return true }
285
+ }
286
+ if (['url', 'href', 'link'].some(key => typeof record[key] === 'string' && pointsToTarget(record[key] as string))) return true
287
+ if (record.type === 'html' && typeof record.value === 'string') {
288
+ if ([...htmlAttributes(record.value, new Set(['href']))].some(attribute => pointsToTarget(attribute.value))) return true
289
+ }
290
+ return Object.entries(record).some(([key, value]) => {
291
+ // Component properties and configuration can hold explicit section/block bindings.
292
+ if (['section', 'block', 'target', 'binding'].includes(key) && typeof value === 'string' && targets.has(value)) return true
293
+ return referencesTargets(value, targets, entry)
294
+ })
295
+ }
296
+
297
+ /** Removes one complete page, retaining all other source slices and their explicit identities.
298
+ * Workspace-wide incoming references must be checked by the caller before committing this plan.
299
+ */
300
+ export function removePageSource(doc: IRDocument, id: string, source: string, emptyTitle: string, capabilities: SourceCapabilities = {}): PageRemoval {
301
+ const { spans, lines, newline } = pageSpans(doc, source)
302
+ const index = spans.findIndex(span => span.section.id === id)
303
+ if (index < 0) throw new Error('page-boundary')
304
+ const removed = spans[index]!, targets = pageTargets(removed.section, source.slice(removed.start, removed.end))
305
+ if (referencesTargets(doc.config, targets, doc.meta.entry)
306
+ || Object.values(doc.config.presmium).some(value => value && typeof value === 'object' && Object.keys(value).some(key => targets.has(key)))
307
+ || doc.sections.some(section => section.id !== id && referencesTargets(section.blocks, targets, doc.meta.entry))) throw new Error('page-referenced')
308
+ if (spans.length === 1) {
309
+ if (!emptyTitle.trim() || /[\r\n\0]/.test(emptyTitle)) throw new Error('page-title')
310
+ const depth = removed.depth || Math.min(sectionDepth(lines), 6)
311
+ const label = removed.labelled ? source.slice(removed.start, lines[removed.heading]!.start) : pageLabel(removed, newline, capabilities)
312
+ return { content: `${source.slice(0, removed.start)}${label}${'#'.repeat(depth)} ${escapedTitle(emptyTitle.trim())}${newline}${newline}`, selected: id, retainedEmpty: true }
313
+ }
314
+ const content = spans.map((span, at) => {
315
+ if (at === index) return ''
316
+ const raw = source.slice(span.start, span.end)
317
+ const offset = lines[span.heading]!.start - span.start
318
+ return `${raw.slice(0, offset)}${pageLabel(span, newline, capabilities)}${raw.slice(offset)}`
319
+ }).join('')
320
+ return { content: `${source.slice(0, spans[0]!.start)}${content}`, selected: (spans[index + 1] ?? spans[index - 1])!.section.id, retainedEmpty: false }
321
+ }
322
+
323
+ /** Adds one explicitly labelled page without rewriting any existing prose, cues or newlines. */
324
+ export function writePage(doc: IRDocument, after: IRSection, source: string, id: string, title: string): string {
325
+ const index = doc.sections.findIndex(section => section.id === after.id)
326
+ if (index < 0 || doc.sections[index] !== after) throw new Error('source-changed')
327
+ if (!/^[\p{L}\p{N}_.-]{1,200}$/u.test(id) || doc.sections.some(section => section.id === id || [...irBlocks(section.blocks)].some(block => block.id === id))) throw new Error('page-id')
328
+ if (!title.trim() || /[\r\n\0]/.test(title)) throw new Error('page-title')
329
+ // An include may have been sliced, or may end inside a parent scope. Its insertion boundary is not in IR.
330
+ const file = after.source.file
331
+ if (file !== doc.meta.entry || doc.sections.some(section => section.source.file !== file || [...irBlocks(section.blocks)].some(block => block.source.file !== file))) throw new Error('page-multiple-files')
332
+ const lines = sourceLines(source), roots = rootLines(lines), threshold = sectionDepth(lines)
333
+ const start = after.source.line - 1
334
+ const actual = heading(lines, roots, start)
335
+ if (after.depth > 0 && actual?.depth !== after.depth) throw new Error('source-changed')
336
+ if (after.depth === 0 && actual && actual.depth >= threshold) throw new Error('source-changed')
337
+ labelStart(lines, start, after.id)
338
+ const end = Math.max((after.source.endLine ?? after.source.line) - 1, actual?.end ?? start)
339
+ if (start < 0 || end < start || end >= lines.length || !roots[end + 1]) throw new Error('source-changed')
340
+ const next = doc.sections[index + 1]
341
+ const nextLine = next ? next.source.line - 1 : lines.length
342
+ if (next && heading(lines, roots, nextLine)?.depth !== next.depth) throw new Error('source-changed')
343
+ const boundary = next ? labelStart(lines, nextLine, next.id) : lines.length
344
+ if (boundary <= end || lines.slice(end + 1, boundary).some(line => line.text.trim())) throw new Error('source-changed')
345
+ // A newly inserted heading inside the old range also makes those offsets stale.
346
+ for (let at = (actual?.end ?? start) + 1; at <= end; at++) {
347
+ const found = heading(lines, roots, at)
348
+ if (found && found.depth <= threshold) throw new Error('source-changed')
349
+ if (found) at = found.end
350
+ }
351
+ const depth = after.depth || (actual ? Math.min(2, threshold) : threshold)
352
+ if (depth > threshold || depth > 6) throw new Error('source-changed')
353
+ const newline = /\r\n|\n|\r/.exec(source)?.[0] ?? '\n'
354
+ const offset = lines[end]!.end
355
+ const before = source.slice(0, offset), following = source.slice(offset)
356
+ const gap = before.endsWith(newline) ? newline : `${newline}${newline}`
357
+ const escaped = title.trim().replace(/[\\`*_[\]<>#]/g, char => `\\${char}`)
358
+ return `${before}${gap}(${id})=${newline}${'#'.repeat(depth)} ${escaped}${newline}${newline}${following}`
359
+ }
@@ -0,0 +1,81 @@
1
+ import { Validator } from '@cfworker/json-schema'
2
+ import type { Schema } from '@cfworker/json-schema'
3
+ import { compileSchema, draft2020 } from 'json-schema-library'
4
+ import type { JsonSchema } from './contracts/document.ts'
5
+
6
+ export interface DocumentParameterIssue {
7
+ /** RFC 6901 pointer into the parameter value; the empty string denotes its root. */
8
+ path: string
9
+ keyword: string
10
+ message: string
11
+ schemaPath?: string
12
+ }
13
+ export interface DocumentParameterValidation { available: boolean, valid: boolean, issues: DocumentParameterIssue[] }
14
+ export interface DocumentParameterSchemaReference { id: string, schema: JsonSchema }
15
+ export interface DocumentParameterAccess { available: boolean, public: boolean, readOnly: boolean, reason?: 'invalid-path' | 'schema-unavailable' | 'path-unavailable' | 'value-unresolved' }
16
+
17
+ /** A private annotation view: false cannot cancel another applicable true; author JSON is untouched. */
18
+ export function documentParameterSchemaForAccess(schema: JsonSchema): JsonSchema {
19
+ const result = structuredClone(schema)
20
+ const object = (value: unknown): value is Record<string, unknown> => Boolean(value && typeof value === 'object' && !Array.isArray(value))
21
+ let visited = 0
22
+ const visit = (node: unknown, depth: number) => {
23
+ if (!object(node)) return
24
+ if (depth > 64 || ++visited > 50_000) throw new Error('parameter-schema-limit')
25
+ if (node.readOnly === false) delete node.readOnly
26
+ for (const key of ['$defs', 'definitions', 'properties', 'patternProperties', 'dependentSchemas']) {
27
+ const children = node[key]
28
+ if (object(children)) for (const child of Object.values(children)) visit(child, depth + 1)
29
+ }
30
+ if (object(node.dependencies)) for (const child of Object.values(node.dependencies)) if (!Array.isArray(child)) visit(child, depth + 1)
31
+ for (const key of ['items', 'additionalItems', 'additionalProperties', 'unevaluatedItems', 'unevaluatedProperties', 'contains', 'not', 'if', 'then', 'else', 'propertyNames', 'contentSchema']) {
32
+ const child = node[key]
33
+ if (Array.isArray(child)) for (const item of child) visit(item, depth + 1)
34
+ else visit(child, depth + 1)
35
+ }
36
+ for (const key of ['allOf', 'anyOf', 'oneOf', 'prefixItems']) if (Array.isArray(node[key])) for (const child of node[key]) visit(child, depth + 1)
37
+ }
38
+ visit(result, 0)
39
+ return result
40
+ }
41
+
42
+ /** Schema-declared path and applicable read-only ancestors; never infers fields from data. */
43
+ export function documentParameterAccess(schema: JsonSchema, value: unknown, path: readonly (string | number)[]): DocumentParameterAccess {
44
+ if (path.length > 64 || path.some(key => typeof key !== 'string' && (typeof key !== 'number' || !Number.isSafeInteger(key) || key < 0))) return { available: false, public: false, readOnly: false, reason: 'invalid-path' }
45
+ let readOnly = false
46
+ try {
47
+ // Only the bundled 2020-12 draft and this local schema are registered. No remote loader.
48
+ const root = compileSchema(documentParameterSchemaForAccess(schema), { drafts: [draft2020], draft: 'draft-2020-12', throwOnInvalidSchema: true, throwOnInvalidRef: true })
49
+ const reason = (error: { code: unknown }): 'path-unavailable' | 'value-unresolved' => typeof error.code === 'string' && ['one-of-error', 'any-of-error'].includes(error.code) ? 'value-unresolved' : 'path-unavailable'
50
+ let pointer = '', current = value
51
+ for (let index = 0; index <= path.length; index++) {
52
+ const found = root.getNode(pointer, value, { createSchema: false })
53
+ if (found.error) return { available: false, public: false, readOnly, reason: reason(found.error) }
54
+ if (!found.node) return { available: true, public: false, readOnly }
55
+ if (typeof found.node.schema === 'object' && found.node.schema.readOnly === true) readOnly = true
56
+ const reduced = found.node.reduceNode(current)
57
+ if (reduced.error) return { available: false, public: false, readOnly, reason: reason(reduced.error) }
58
+ if (!reduced.node) return { available: false, public: false, readOnly, reason: 'path-unavailable' }
59
+ if (typeof reduced.node.schema === 'object' && reduced.node.schema.readOnly === true) readOnly = true
60
+ if (index === path.length) return { available: true, public: true, readOnly }
61
+ const key = path[index]!
62
+ pointer += `/${String(key).replace(/~/g, '~0').replace(/\//g, '~1')}`
63
+ current = current !== null && typeof current === 'object' && Object.hasOwn(current, key) ? (current as Record<string | number, unknown>)[key] : undefined
64
+ }
65
+ } catch { return { available: false, public: false, readOnly, reason: 'schema-unavailable' } }
66
+ return { available: false, public: false, readOnly, reason: 'path-unavailable' }
67
+ }
68
+
69
+ /** The canonical validator and public form adapter share the same dialect and reference resolver. */
70
+ export function validateDocumentParameters(schema: JsonSchema, value: unknown, references: readonly DocumentParameterSchemaReference[] = []): DocumentParameterValidation {
71
+ try {
72
+ const validator = new Validator(structuredClone(schema) as Schema, '2020-12', false)
73
+ for (const reference of references) validator.addSchema(structuredClone(typeof reference.schema === 'boolean' ? { allOf: [reference.schema] } : reference.schema) as Schema, reference.id)
74
+ const result = validator.validate(value)
75
+ return { available: true, valid: result.valid, issues: result.errors.slice(0, 10).map(error => ({
76
+ path: error.instanceLocation.replace(/^#/, ''), keyword: error.keyword, message: error.error, schemaPath: error.keywordLocation,
77
+ })) }
78
+ } catch (error) {
79
+ return { available: false, valid: false, issues: [{ path: '', keyword: 'schema-unavailable', message: error instanceof Error ? error.message : 'This parameter schema cannot be validated.' }] }
80
+ }
81
+ }
@@ -0,0 +1,18 @@
1
+ import { irBlocks } from './blocks.ts'
2
+ import { fnv1a64 } from './fingerprint.ts'
3
+ import type { IRDocument } from './ir.ts'
4
+ import type { PreviewAnchor } from './contracts/preview.ts'
5
+
6
+ /** A native text draft follows its IR target, independently from unrelated source-line shifts. */
7
+ export function previewEditBinding(doc: IRDocument, target: PreviewAnchor): string | undefined {
8
+ const section = doc.sections.find(section => section.id === target.section)
9
+ if (!section) return undefined
10
+ if (!target.block) return fnv1a64(JSON.stringify({
11
+ section: section.id, file: section.source.file, title: section.title,
12
+ depth: section.depth, audience: section.audience,
13
+ }))
14
+ const block = [...irBlocks(section.blocks)].find(block => block.id === target.block)
15
+ if (!block || block.node.type !== 'markdown') return undefined
16
+ const { source, ...meaning } = block
17
+ return fnv1a64(JSON.stringify([section.id, source.file, meaning], (key, value) => key === 'position' ? undefined : value))
18
+ }
@@ -0,0 +1,57 @@
1
+ import { PREVIEW_EVENTS_PARAM, previewEnvelope } from './contracts/preview.ts'
2
+ import type { PreviewCommand, PreviewEventSubscription, PreviewEventTarget, PreviewEventMessage } from './contracts/preview.ts'
3
+ import { previewNonce } from './preview-frame.ts'
4
+
5
+ /** Uses the existing preview-ready handshake, origin fence and nonce; never opens a network connection. */
6
+ export interface PreviewEvents {
7
+ connection: string
8
+ subscribe: PreviewEventSubscription
9
+ receive(command: Extract<PreviewCommand, { type: 'events-ready' | 'events-message' }>): void
10
+ stop(): void
11
+ }
12
+
13
+ export function createPreviewEvents(origins: readonly string[], document: string): PreviewEvents | undefined {
14
+ if (typeof window === 'undefined' || window.parent === window || !origins.length || new URL(location.href).searchParams.get(PREVIEW_EVENTS_PARAM) !== 'shared') return
15
+ const nonce = previewNonce()
16
+ if (!nonce) return
17
+ const connection = crypto.randomUUID()
18
+ const readers = new Map<string, { target: PreviewEventTarget, received(message: PreviewEventMessage): void }>()
19
+ let ready = false, stopped = false, parked = false
20
+ const post = (message: Parameters<typeof previewEnvelope>[1]) => {
21
+ if (!stopped) for (const origin of origins) window.parent.postMessage(previewEnvelope(nonce, message), origin)
22
+ }
23
+ const subscribe = (id: string, target: PreviewEventTarget) => post({ type: 'events-subscribe', connection, id, target })
24
+ const park = () => {
25
+ parked = true
26
+ for (const id of readers.keys()) post({ type: 'events-unsubscribe', connection, id })
27
+ }
28
+ const resume = () => {
29
+ parked = false
30
+ if (ready) for (const [id, reader] of readers) subscribe(id, reader.target)
31
+ }
32
+ window.addEventListener('pagehide', park)
33
+ window.addEventListener('pageshow', resume)
34
+ return {
35
+ connection,
36
+ subscribe(target, received) {
37
+ if (stopped || target.document !== document) return () => {}
38
+ const id = crypto.randomUUID()
39
+ readers.set(id, { target, received })
40
+ if (ready && !parked) subscribe(id, target)
41
+ return () => { if (readers.delete(id)) post({ type: 'events-unsubscribe', connection, id }) }
42
+ },
43
+ receive(command) {
44
+ if (stopped || command.connection !== connection) return
45
+ if (command.type === 'events-ready') {
46
+ if (ready) return
47
+ ready = true
48
+ if (!parked) for (const [id, reader] of readers) subscribe(id, reader.target)
49
+ } else if (!parked) readers.get(command.id)?.received(command.message)
50
+ },
51
+ stop() {
52
+ park(); readers.clear(); stopped = true
53
+ window.removeEventListener('pagehide', park)
54
+ window.removeEventListener('pageshow', resume)
55
+ },
56
+ }
57
+ }