@lincyaw/kernel 0.0.0-stage → 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 (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +32 -2
  3. package/package.json +41 -3
  4. package/src/access.ts +18 -0
  5. package/src/anchors.ts +58 -0
  6. package/src/authoring.ts +56 -0
  7. package/src/blocks.ts +19 -0
  8. package/src/checks.ts +35 -0
  9. package/src/component-code.ts +6 -0
  10. package/src/contracts/agent.ts +76 -0
  11. package/src/contracts/anchors.ts +46 -0
  12. package/src/contracts/authoring-actions.ts +60 -0
  13. package/src/contracts/authoring-clients.ts +213 -0
  14. package/src/contracts/authoring.ts +180 -0
  15. package/src/contracts/checks.ts +29 -0
  16. package/src/contracts/classroom.ts +49 -0
  17. package/src/contracts/diagnostics.ts +37 -0
  18. package/src/contracts/document-audio.ts +150 -0
  19. package/src/contracts/document-comments.ts +31 -0
  20. package/src/contracts/document-history.ts +63 -0
  21. package/src/contracts/document-image.ts +32 -0
  22. package/src/contracts/document-reads.ts +57 -0
  23. package/src/contracts/document-voice.ts +44 -0
  24. package/src/contracts/document.ts +240 -0
  25. package/src/contracts/execution.ts +118 -0
  26. package/src/contracts/locale.ts +3 -0
  27. package/src/contracts/offline.ts +2 -0
  28. package/src/contracts/operations.ts +8 -0
  29. package/src/contracts/plugin.ts +183 -0
  30. package/src/contracts/presenter-panels.ts +17 -0
  31. package/src/contracts/preview.ts +386 -0
  32. package/src/contracts/production-assets.ts +61 -0
  33. package/src/contracts/production.ts +109 -0
  34. package/src/contracts/projects.ts +59 -0
  35. package/src/contracts/server.ts +108 -0
  36. package/src/contracts/service.ts +8 -0
  37. package/src/contracts/speech-settings.ts +16 -0
  38. package/src/contracts/speech.ts +149 -0
  39. package/src/contracts/steps.ts +15 -0
  40. package/src/contracts/transcription-adoption.ts +79 -0
  41. package/src/contracts/transcription.ts +112 -0
  42. package/src/contracts/voice.ts +97 -0
  43. package/src/contracts/voiceover-execution.ts +68 -0
  44. package/src/contracts/workspace.ts +458 -0
  45. package/src/contracts.ts +35 -0
  46. package/src/diagnostics.ts +7 -0
  47. package/src/document-authoring-actions.ts +47 -0
  48. package/src/document-caption-timings.ts +64 -0
  49. package/src/document-colors.ts +25 -0
  50. package/src/document-comments.ts +118 -0
  51. package/src/document-components.ts +251 -0
  52. package/src/document-operations.ts +132 -0
  53. package/src/document-pages.ts +215 -0
  54. package/src/document-reads.ts +81 -0
  55. package/src/document-schema.ts +85 -0
  56. package/src/document-voices.ts +153 -0
  57. package/src/document.ts +252 -0
  58. package/src/durations.ts +7 -0
  59. package/src/errors.ts +15 -0
  60. package/src/events.ts +32 -0
  61. package/src/execution.ts +25 -0
  62. package/src/fingerprint.ts +31 -0
  63. package/src/follow.ts +33 -0
  64. package/src/git.ts +12 -0
  65. package/src/html.ts +41 -0
  66. package/src/index.ts +82 -0
  67. package/src/ir.ts +367 -0
  68. package/src/jupyter-kernel.ts +9 -0
  69. package/src/kernel.ts +190 -0
  70. package/src/keyboard.ts +43 -0
  71. package/src/locale.ts +13 -0
  72. package/src/lock.ts +81 -0
  73. package/src/logging.ts +11 -0
  74. package/src/lower-myst.ts +312 -0
  75. package/src/node/assets.ts +23 -0
  76. package/src/node/authoring-actions.ts +50 -0
  77. package/src/node/authoring-clients.ts +14 -0
  78. package/src/node/document-audio.ts +22 -0
  79. package/src/node/document-history.ts +11 -0
  80. package/src/node/document-image.ts +8 -0
  81. package/src/node/document-reads.ts +10 -0
  82. package/src/node/document-voice.ts +7 -0
  83. package/src/node/ffmpeg.ts +29 -0
  84. package/src/node/file-version.ts +6 -0
  85. package/src/node/fonts.ts +22 -0
  86. package/src/node/index.ts +29 -0
  87. package/src/node/installed-packages.ts +11 -0
  88. package/src/node/lines.ts +24 -0
  89. package/src/node/locale.ts +7 -0
  90. package/src/node/network.ts +11 -0
  91. package/src/node/operator-speech.ts +91 -0
  92. package/src/node/packages.ts +74 -0
  93. package/src/node/paths.ts +7 -0
  94. package/src/node/projects.ts +7 -0
  95. package/src/node/resources.ts +86 -0
  96. package/src/node/revision.ts +6 -0
  97. package/src/node/run-directory.ts +70 -0
  98. package/src/node/strip-types.mjs +15 -0
  99. package/src/node/transcription-adoption.ts +9 -0
  100. package/src/node/transcription.ts +10 -0
  101. package/src/node/voiceover-routes.ts +73 -0
  102. package/src/node/voiceover.ts +199 -0
  103. package/src/page-source.ts +359 -0
  104. package/src/parameters.ts +81 -0
  105. package/src/preview-edit.ts +18 -0
  106. package/src/preview-events.ts +57 -0
  107. package/src/preview-frame.ts +1220 -0
  108. package/src/props.ts +17 -0
  109. package/src/published.ts +11 -0
  110. package/src/push-stream.ts +67 -0
  111. package/src/reading.ts +96 -0
  112. package/src/recording.ts +38 -0
  113. package/src/renderer-components.ts +30 -0
  114. package/src/requests.ts +28 -0
  115. package/src/responses.ts +49 -0
  116. package/src/run-outputs.ts +15 -0
  117. package/src/runner.py +454 -0
  118. package/src/services.ts +146 -0
  119. package/src/slides.ts +36 -0
  120. package/src/speech-words.ts +76 -0
  121. package/src/speech.ts +13 -0
  122. package/src/step-keys.ts +38 -0
  123. package/src/steps.ts +7 -0
  124. package/src/transcription-adoption.ts +105 -0
  125. package/src/uri-decoding.ts +8 -0
  126. package/src/urls.ts +13 -0
  127. package/src/views.ts +47 -0
  128. package/src/voice.ts +86 -0
@@ -0,0 +1,199 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { Validator } from '@cfworker/json-schema'
3
+ import type { Schema } from '@cfworker/json-schema'
4
+ import { VOICEOVER_JOB_QUERY_SCHEMA, VOICEOVER_START_SCHEMA, VOICEOVER_STATUS_SCHEMA, VOICE_PREVIEW_CHECK_SCHEMA, VOICE_PREVIEW_START_SCHEMA } from '../contracts/voiceover-execution.ts'
5
+ import type { VoiceoverExecution, VoiceoverJobQuery, VoiceoverObservation, VoiceoverStart, VoiceoverStatusQuery, VoicePreviewCheck, VoicePreviewResource, VoicePreviewResourceData, VoicePreviewStart } from '../contracts/voiceover-execution.ts'
6
+ import type { CanonicalSource } from '../ir.ts'
7
+ import type { VoiceoverCancellation, VoiceoverJob, VoicePreviewQuote } from '../contracts/voice.ts'
8
+
9
+ const validator = <T>(schema: unknown) => { const checked = new Validator(structuredClone(schema) as Schema, '2020-12', false); return (value: unknown): value is T => checked.validate(value).valid }
10
+ export const validateVoiceoverStart = validator<VoiceoverStart>(VOICEOVER_START_SCHEMA)
11
+ export const validateVoiceoverStatus = validator<VoiceoverStatusQuery>(VOICEOVER_STATUS_SCHEMA)
12
+ export const validateVoiceoverJobQuery = validator<VoiceoverJobQuery>(VOICEOVER_JOB_QUERY_SCHEMA)
13
+ export const validateVoicePreviewCheck = validator<VoicePreviewCheck>(VOICE_PREVIEW_CHECK_SCHEMA)
14
+ export const validateVoicePreviewStart = validator<VoicePreviewStart>(VOICE_PREVIEW_START_SCHEMA)
15
+ export class VoiceoverOperationError extends Error {
16
+ readonly status: number
17
+ constructor(status: number, message: string) { super(message); this.status = status }
18
+ }
19
+ type Load = (source: CanonicalSource | undefined, signal: AbortSignal) => Promise<VoiceoverExecution>
20
+ interface ActiveJob { job: VoiceoverJob, controller: AbortController }
21
+ interface Intent { payload?: string, controller: AbortController, job?: ActiveJob, state: 'preparing' | 'cancelled' | 'failed', error?: string, finished: Promise<void>, finish(): void }
22
+ const MAX_INTENTS = 1024, RECENT_JOBS = 20, MAX_OPERATIONS = 4, MAX_QUOTES = 64, QUOTE_MS = 120_000, MAX_PREVIEWS = 8, MAX_MEDIA_BYTES = 16 << 20
23
+ const digest = (value: unknown) => createHash('sha256').update(JSON.stringify(value)).digest('hex')
24
+ const targetKey = (target: { section: string, step: number, binding: string }) => [target.section, target.step, target.binding]
25
+ const sourceKey = (source?: CanonicalSource) => source && [source.documentId, source.version, source.fingerprint]
26
+ const startPayload = (request: VoiceoverStart) => digest([request.revision, sourceKey(request.source), request.scope.kind, request.scope.kind === 'page' ? request.scope.section : request.scope.kind === 'step' ? targetKey(request.scope.target) : request.scope.kind === 'steps' ? [...new Set(request.scope.targets.map(target => JSON.stringify(targetKey(target))))].sort() : []])
27
+ const previewPayload = (request: VoicePreviewCheck) => digest([sourceKey(request.source), request.revision, request.voice.engine, request.voice.voice, request.voice.rate, request.voice.style, request.mode, request.target && targetKey(request.target)])
28
+
29
+ /** The existing in-process intent/job coordinator, shared by Workspace and standalone lecture servers. */
30
+ export function createVoiceoverOwner() {
31
+ const jobs = new Map<string, ActiveJob>(), intents = new Map<string, Intent>()
32
+ const operations = new Set<{ controller: AbortController, finished: Promise<unknown> }>()
33
+ const quotes = new Map<string, { value: VoicePreviewQuote, underlying: string, execution: VoiceoverExecution, expires: number }>()
34
+ const media = new Map<string, VoicePreviewResourceData>()
35
+ let closed = false
36
+ const available = () => { if (closed) throw new VoiceoverOperationError(503, 'voice-service-closing') }
37
+ const work = <T>(signal: AbortSignal | undefined, run: (signal: AbortSignal) => Promise<T>): Promise<T> => {
38
+ available(); signal?.throwIfAborted()
39
+ if (operations.size >= MAX_OPERATIONS) throw new VoiceoverOperationError(503, 'voice-authoring-busy')
40
+ const controller = new AbortController(), combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal
41
+ const operation = { controller, finished: Promise.resolve() as Promise<unknown> }
42
+ operations.add(operation)
43
+ const finished = Promise.resolve().then(() => { available(); combined.throwIfAborted(); return run(combined) }).catch(error => { if (closed) throw new VoiceoverOperationError(503, 'voice-service-closing'); throw error }).finally(() => operations.delete(operation))
44
+ operation.finished = finished
45
+ return finished
46
+ }
47
+ const patch = (job: VoiceoverJob, value: Partial<VoiceoverJob>) => {
48
+ if (Object.entries(value).some(([key, value]) => job[key as keyof VoiceoverJob] !== value)) { Object.assign(job, value); job.sequence++ }
49
+ }
50
+ const newIntent = (id: string, payload?: string): Intent => {
51
+ if (intents.size >= MAX_INTENTS) throw new VoiceoverOperationError(503, 'voice-intent-capacity')
52
+ let finish!: () => void
53
+ const finished = new Promise<void>(resolve => { finish = resolve })
54
+ const intent: Intent = { payload, controller: new AbortController(), state: 'preparing', finished, finish }
55
+ intents.set(id, intent)
56
+ return intent
57
+ }
58
+ const cancelJob = (entry: ActiveJob) => {
59
+ if (entry.job.state === 'queued' || entry.job.state === 'running') {
60
+ const queued = entry.job.state === 'queued'
61
+ patch(entry.job, { state: queued ? 'cancelled' : 'cancelling', ...queued ? { finishedAt: new Date().toISOString() } : {} })
62
+ entry.controller.abort(new Error('voice-cancelled'))
63
+ }
64
+ }
65
+ const find = (query: VoiceoverJobQuery): Intent | undefined => 'intent' in query ? intents.get(query.intent) : [...intents.values()].find(intent => intent.job?.job.id === query.id)
66
+ const observation = (id: string, intent: Intent): VoiceoverObservation => ({ intent: id, state: intent.job?.job.state ?? (intent.state === 'preparing' ? 'queued' : intent.state), ...intent.job ? { job: structuredClone(intent.job.job) } : {}, ...intent.error ? { error: intent.error } : {} })
67
+ return {
68
+ limits: { intents: MAX_INTENTS, recentJobs: RECENT_JOBS, concurrent: MAX_OPERATIONS, quotes: MAX_QUOTES, quoteLifetimeMs: QUOTE_MS, previewBytes: 8 << 20, previewResources: MAX_PREVIEWS, retainedPreviewBytes: MAX_MEDIA_BYTES },
69
+ snapshot(query: VoiceoverStatusQuery, load: Load, signal?: AbortSignal) {
70
+ if (!validateVoiceoverStatus(query)) throw new VoiceoverOperationError(400, 'voice-status-invalid')
71
+ return work(signal, async signal => { const execution = await load(query.source, signal), status = await execution.status(query, signal); signal.throwIfAborted(); return { ...status, jobs: [...jobs.values()].map(entry => structuredClone(entry.job)) } })
72
+ },
73
+ list() { available(); return [...jobs.values()].map(entry => structuredClone(entry.job)) },
74
+ get(query: VoiceoverJobQuery): VoiceoverObservation {
75
+ available()
76
+ if (!validateVoiceoverJobQuery(query)) throw new VoiceoverOperationError(400, 'voice-job-query-invalid')
77
+ const intent = find(query)
78
+ if (!intent) throw new VoiceoverOperationError(404, 'voice-job-unknown')
79
+ return observation('intent' in query ? query.intent : intent.job!.job.intent, intent)
80
+ },
81
+ cancel(query: VoiceoverJobQuery): VoiceoverCancellation {
82
+ available()
83
+ if (!validateVoiceoverJobQuery(query)) throw new VoiceoverOperationError(400, 'voice-cancel-invalid')
84
+ const intent = 'intent' in query ? intents.get(query.intent) ?? newIntent(query.intent) : find(query)
85
+ if (!intent) throw new VoiceoverOperationError(404, 'voice-job-unknown')
86
+ if (intent.job) cancelJob(intent.job)
87
+ else { intent.state = 'cancelled'; intent.controller.abort(new Error('voice-cancelled')); if (!intent.payload) intent.finish() }
88
+ return observation('intent' in query ? query.intent : intent.job!.job.intent, intent)
89
+ },
90
+ /** Cancellation that would change nothing (the job or intent already ended) is answered here, without asking. */
91
+ checkCancel(query: VoiceoverJobQuery): VoiceoverCancellation | undefined {
92
+ available()
93
+ if (!validateVoiceoverJobQuery(query)) throw new VoiceoverOperationError(400, 'voice-cancel-invalid')
94
+ const intent = find(query)
95
+ if (!intent) { if ('intent' in query) return undefined; throw new VoiceoverOperationError(404, 'voice-job-unknown') }
96
+ const state = intent.job?.job.state ?? intent.state
97
+ return state === 'queued' || state === 'running' || state === 'preparing' ? undefined : observation('intent' in query ? query.intent : intent.job!.job.intent, intent)
98
+ },
99
+ /** The same admission as start, released at once: a request that passes would be admitted unless the document changes. */
100
+ checkStart(request: VoiceoverStart, load: Load, signal?: AbortSignal): Promise<void> {
101
+ if (!validateVoiceoverStart(request)) throw new VoiceoverOperationError(400, 'voice-generation-invalid')
102
+ return work(signal, async signal => {
103
+ const known = intents.get(request.intent)
104
+ if (known?.payload && known.payload !== startPayload(request)) throw new VoiceoverOperationError(409, 'voice-intent-changed')
105
+ if (known?.job) return
106
+ if (known?.state === 'cancelled') throw new VoiceoverOperationError(409, 'voice-cancelled')
107
+ if (known?.state === 'failed') throw new VoiceoverOperationError(409, 'voice-intent-failed')
108
+ if (known) throw new VoiceoverOperationError(409, 'voice-job-busy')
109
+ const execution = await load(request.source, signal)
110
+ ;(await execution.prepare(structuredClone(request), signal)).dispose()
111
+ })
112
+ },
113
+ checkPreview(request: VoicePreviewStart, signal?: AbortSignal): Promise<void> {
114
+ if (!validateVoicePreviewStart(request)) throw new VoiceoverOperationError(400, 'voice-preview-invalid')
115
+ return work(signal, async signal => {
116
+ const quoted = quotes.get(request.quote)
117
+ if (!quoted || quoted.expires < Date.now() || previewPayload(quoted.value) !== previewPayload(request)) throw new VoiceoverOperationError(409, 'voice-preview-changed')
118
+ const { quote: _, ...check } = request
119
+ if ((await quoted.execution.quote(check, signal)).quote !== quoted.underlying) throw new VoiceoverOperationError(409, 'voice-preview-changed')
120
+ })
121
+ },
122
+ start(request: VoiceoverStart, load: Load, signal?: AbortSignal): Promise<VoiceoverJob> {
123
+ if (!validateVoiceoverStart(request)) throw new VoiceoverOperationError(400, 'voice-generation-invalid')
124
+ request = structuredClone(request)
125
+ return work(signal, async signal => {
126
+ const payload = startPayload(request), known = intents.get(request.intent)
127
+ if (known?.payload && known.payload !== payload) throw new VoiceoverOperationError(409, 'voice-intent-changed')
128
+ if (known?.job) return structuredClone(known.job.job)
129
+ if (known?.state === 'cancelled') throw new VoiceoverOperationError(409, 'voice-cancelled')
130
+ if (known?.state === 'failed') throw new VoiceoverOperationError(409, 'voice-intent-failed')
131
+ if (known) throw new VoiceoverOperationError(409, 'voice-job-busy')
132
+ const intent = newIntent(request.intent, payload), combined = AbortSignal.any([signal, intent.controller.signal])
133
+ let prepared: Awaited<ReturnType<VoiceoverExecution['prepare']>> | undefined, admitted = false
134
+ try {
135
+ combined.throwIfAborted()
136
+ const execution = await load(request.source, combined)
137
+ combined.throwIfAborted(); prepared = await execution.prepare(request, combined); combined.throwIfAborted(); available()
138
+ const job: VoiceoverJob = { id: randomUUID(), sequence: 1, intent: request.intent, source: prepared.source, revision: prepared.revision, scope: prepared.scope, state: 'queued', total: prepared.total, completed: 0, failed: 0, createdAt: new Date().toISOString() }
139
+ const entry = { job, controller: intent.controller }, task = prepared
140
+ intent.job = entry; jobs.set(job.id, entry); admitted = true
141
+ void Promise.resolve().then(async () => {
142
+ if (job.state !== 'queued') return
143
+ patch(job, { state: 'running', startedAt: new Date().toISOString() })
144
+ try { const result = await task.run(intent.controller.signal, progress => patch(job, progress)); patch(job, { ...result, finishedAt: new Date().toISOString() }) }
145
+ catch (error) { patch(job, { state: intent.controller.signal.aborted ? 'cancelled' : 'failed', error: error instanceof Error ? error.message : String(error), finishedAt: new Date().toISOString() }) }
146
+ }).finally(() => { task.dispose(); intent.finish() })
147
+ for (const [id, old] of jobs) if (jobs.size > RECENT_JOBS && !['queued', 'running', 'cancelling'].includes(old.job.state)) jobs.delete(id)
148
+ return structuredClone(job)
149
+ } catch (error) {
150
+ const intentCancelled = intent.controller.signal.aborted
151
+ intent.state = combined.aborted ? 'cancelled' : 'failed'; intent.error = error instanceof Error ? error.message : String(error)
152
+ intent.controller.abort(error)
153
+ if (closed) throw new VoiceoverOperationError(503, 'voice-service-closing')
154
+ if (intentCancelled) throw new VoiceoverOperationError(409, 'voice-cancelled')
155
+ throw error
156
+ } finally { if (!admitted) { prepared?.dispose(); intent.finish() } }
157
+ })
158
+ },
159
+ quote(query: VoicePreviewCheck, load: Load, signal?: AbortSignal): Promise<VoicePreviewQuote> {
160
+ if (!validateVoicePreviewCheck(query)) throw new VoiceoverOperationError(400, 'voice-preview-invalid')
161
+ return work(signal, async signal => {
162
+ const execution = await load(query.source, signal), quoted = await execution.quote(query, signal)
163
+ signal.throwIfAborted(); available()
164
+ const token = digest([randomUUID(), quoted.quote]), value = { ...quoted, quote: token }
165
+ quotes.set(token, { value, underlying: quoted.quote, execution, expires: Date.now() + QUOTE_MS })
166
+ while (quotes.size > MAX_QUOTES) quotes.delete(quotes.keys().next().value!)
167
+ return structuredClone(value)
168
+ })
169
+ },
170
+ preview(request: VoicePreviewStart, signal?: AbortSignal): Promise<VoicePreviewResource> {
171
+ if (!validateVoicePreviewStart(request)) throw new VoiceoverOperationError(400, 'voice-preview-invalid')
172
+ return work(signal, async signal => {
173
+ const quoted = quotes.get(request.quote)
174
+ if (!quoted || quoted.expires < Date.now() || previewPayload(quoted.value) !== previewPayload(request)) throw new VoiceoverOperationError(409, 'voice-preview-changed')
175
+ const value = await quoted.execution.preview({ ...request, quote: quoted.underlying }, signal)
176
+ signal.throwIfAborted(); available()
177
+ if (value.bytes.byteLength > 8 << 20) throw new VoiceoverOperationError(413, 'voice-preview-too-large')
178
+ const { bytes, ...answer } = value, resource: VoicePreviewResource = { ...answer, id: randomUUID(), byteLength: bytes.byteLength, sha256: createHash('sha256').update(bytes).digest('hex') }
179
+ media.set(resource.id, { resource, bytes: new Uint8Array(bytes) })
180
+ while (media.size > MAX_PREVIEWS || [...media.values()].reduce((sum, value) => sum + value.bytes.byteLength, 0) > MAX_MEDIA_BYTES) media.delete(media.keys().next().value!)
181
+ return structuredClone(resource)
182
+ })
183
+ },
184
+ readPreview(id: string): VoicePreviewResourceData {
185
+ available()
186
+ const value = media.get(id)
187
+ if (!value) throw new VoiceoverOperationError(404, 'voice-preview-resource-unknown')
188
+ return { resource: structuredClone(value.resource), bytes: new Uint8Array(value.bytes) }
189
+ },
190
+ async close() {
191
+ closed = true
192
+ for (const operation of operations) operation.controller.abort(new Error('voice-service-closing'))
193
+ for (const intent of intents.values()) { if (intent.job) cancelJob(intent.job); else intent.controller.abort(new Error('voice-service-closing')) }
194
+ await Promise.allSettled([...operations].map(operation => operation.finished))
195
+ await Promise.allSettled([...intents.values()].map(intent => intent.finished))
196
+ quotes.clear(); media.clear()
197
+ },
198
+ }
199
+ }
@@ -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
+ }