@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.
- package/README.md +7 -7
- package/package.json +6 -2
- package/src/access.ts +18 -0
- package/src/anchors.ts +5 -1
- package/src/authoring.ts +56 -0
- package/src/blocks.ts +0 -8
- package/src/component-code.ts +6 -0
- package/src/contracts/agent.ts +5 -82
- package/src/contracts/anchors.ts +46 -0
- package/src/contracts/authoring-actions.ts +60 -0
- package/src/contracts/authoring-clients.ts +213 -0
- package/src/contracts/authoring.ts +180 -0
- package/src/contracts/checks.ts +29 -0
- package/src/contracts/classroom.ts +49 -0
- package/src/contracts/diagnostics.ts +37 -0
- package/src/contracts/document-audio.ts +150 -0
- package/src/contracts/document-comments.ts +31 -0
- package/src/contracts/document-history.ts +63 -0
- package/src/contracts/document-image.ts +32 -0
- package/src/contracts/document-reads.ts +57 -0
- package/src/contracts/document-voice.ts +44 -0
- package/src/contracts/document.ts +240 -0
- package/src/contracts/execution.ts +4 -5
- package/src/contracts/locale.ts +3 -0
- package/src/contracts/offline.ts +2 -0
- package/src/contracts/plugin.ts +183 -0
- package/src/contracts/presenter-panels.ts +17 -0
- package/src/contracts/preview.ts +305 -310
- package/src/contracts/production-assets.ts +61 -0
- package/src/contracts/production.ts +109 -0
- package/src/contracts/projects.ts +59 -0
- package/src/contracts/server.ts +1 -51
- package/src/contracts/speech-settings.ts +16 -0
- package/src/contracts/speech.ts +82 -70
- package/src/contracts/steps.ts +15 -0
- package/src/contracts/transcription-adoption.ts +79 -0
- package/src/contracts/transcription.ts +112 -0
- package/src/contracts/voice.ts +97 -0
- package/src/contracts/voiceover-execution.ts +68 -0
- package/src/contracts/workspace.ts +458 -0
- package/src/contracts.ts +28 -278
- package/src/diagnostics.ts +7 -0
- package/src/document-authoring-actions.ts +47 -0
- package/src/document-caption-timings.ts +64 -0
- package/src/document-colors.ts +25 -0
- package/src/document-comments.ts +118 -0
- package/src/document-components.ts +251 -0
- package/src/document-operations.ts +132 -0
- package/src/document-pages.ts +215 -0
- package/src/document-reads.ts +81 -0
- package/src/document-schema.ts +85 -0
- package/src/document-voices.ts +153 -0
- package/src/document.ts +252 -0
- package/src/durations.ts +7 -0
- package/src/errors.ts +8 -0
- package/src/execution.ts +5 -1
- package/src/fingerprint.ts +1 -1
- package/src/follow.ts +26 -30
- package/src/git.ts +1 -1
- package/src/index.ts +66 -13
- package/src/ir.ts +54 -5
- package/src/jupyter-kernel.ts +9 -0
- package/src/kernel.ts +38 -25
- package/src/keyboard.ts +43 -0
- package/src/locale.ts +13 -0
- package/src/lock.ts +4 -8
- package/src/logging.ts +1 -1
- package/src/{markdown.ts → lower-myst.ts} +2 -1
- package/src/node/assets.ts +23 -0
- package/src/node/authoring-actions.ts +50 -0
- package/src/node/authoring-clients.ts +14 -0
- package/src/node/document-audio.ts +22 -0
- package/src/node/document-history.ts +11 -0
- package/src/node/document-image.ts +8 -0
- package/src/node/document-reads.ts +10 -0
- package/src/node/document-voice.ts +7 -0
- package/src/node/ffmpeg.ts +29 -0
- package/src/node/file-version.ts +6 -0
- package/src/node/fonts.ts +22 -0
- package/src/node/index.ts +29 -133
- package/src/node/installed-packages.ts +11 -0
- package/src/node/lines.ts +24 -0
- package/src/node/locale.ts +7 -0
- package/src/node/network.ts +11 -0
- package/src/node/operator-speech.ts +91 -0
- package/src/node/packages.ts +74 -0
- package/src/node/paths.ts +7 -0
- package/src/node/projects.ts +7 -0
- package/src/node/resources.ts +86 -0
- package/src/node/revision.ts +6 -0
- package/src/node/run-directory.ts +70 -0
- package/src/node/transcription-adoption.ts +9 -0
- package/src/node/transcription.ts +10 -0
- package/src/node/voiceover-routes.ts +73 -0
- package/src/node/voiceover.ts +199 -0
- package/src/page-source.ts +359 -0
- package/src/parameters.ts +81 -0
- package/src/preview-edit.ts +18 -0
- package/src/preview-events.ts +57 -0
- package/src/preview-frame.ts +1220 -0
- package/src/props.ts +17 -0
- package/src/push-stream.ts +67 -0
- package/src/reading.ts +12 -7
- package/src/recording.ts +38 -0
- package/src/renderer-components.ts +30 -0
- package/src/responses.ts +49 -0
- package/src/run-outputs.ts +15 -0
- package/src/runner.py +3 -3
- package/src/services.ts +6 -6
- package/src/slides.ts +36 -0
- package/src/speech-words.ts +76 -0
- package/src/speech.ts +13 -0
- package/src/step-keys.ts +38 -0
- package/src/steps.ts +7 -0
- package/src/transcription-adoption.ts +105 -0
- package/src/uri-decoding.ts +8 -0
- package/src/urls.ts +13 -0
- package/src/views.ts +47 -0
- 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, '|')
|
|
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
|
+
}
|