@lincyaw/kernel 0.0.0-stage → 1.0.0-rc.0

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/src/ir.ts ADDED
@@ -0,0 +1,318 @@
1
+ import type {
2
+ AnchorLockFile,
3
+ AnchorResolverRegistry,
4
+ AnchorString,
5
+ ExecuteConfig,
6
+ Executor,
7
+ Finding,
8
+ ResolutionStatus,
9
+ RunResult,
10
+ ServiceToken,
11
+ SourceReader,
12
+ } from './contracts.ts'
13
+
14
+ export const IR_VERSION = 1
15
+
16
+ export type RendererId = string
17
+
18
+ export interface MdastNode {
19
+ type: string
20
+ children?: MdastNode[]
21
+ value?: string
22
+ [key: string]: unknown
23
+ }
24
+
25
+ export interface SourcePos {
26
+ file: string
27
+ line: number
28
+ endLine?: number
29
+ }
30
+
31
+ export interface IRDocument {
32
+ ir: typeof IR_VERSION
33
+ meta: {
34
+ title: string
35
+ entry: string
36
+ lang?: string
37
+ translationOf?: { entry: string, digest: string }
38
+ [key: string]: unknown
39
+ }
40
+ config: {
41
+ plugins: string[]
42
+ presmium: Record<string, unknown>
43
+ renderers: Record<RendererId, Record<string, unknown>>
44
+ execute?: ExecuteConfig
45
+ translations?: Record<string, string>
46
+ }
47
+ sections: IRSection[]
48
+ diagnostics: Finding[]
49
+ }
50
+
51
+ export interface IRSection {
52
+ id: string
53
+ index: number
54
+ title: string
55
+ depth: number
56
+ source: SourcePos
57
+ steps: number
58
+ stepMap?: number[]
59
+ hints: Record<RendererId, Record<string, unknown>>
60
+ config?: Record<string, Record<string, unknown>>
61
+ blocks: IRBlock[]
62
+ audience?: string[]
63
+ }
64
+
65
+ export type BlockRole = 'visual' | 'narration'
66
+
67
+ export interface IRBlock {
68
+ id: string
69
+ role: BlockRole
70
+ at: number
71
+ until?: number
72
+ span?: number
73
+ only?: RendererId[]
74
+ audience?: string[]
75
+ source: SourcePos
76
+ node: IRNode
77
+ }
78
+
79
+ export type IRNode = MarkdownNode | CodeNode | ComponentNode
80
+
81
+ export interface MarkdownNode {
82
+ type: 'markdown'
83
+ mdast: MdastNode
84
+ reveal?: 'items'
85
+ }
86
+
87
+ export interface CodeOrigin {
88
+ anchor: AnchorString
89
+ file: string
90
+ startLine: number
91
+ endLine: number
92
+ pin?: string
93
+ permalink?: string
94
+ status: ResolutionStatus
95
+ units?: Record<string, { startLine: number; endLine: number }>
96
+ drift?: 'synced' | 'drifted'
97
+ }
98
+
99
+ export interface RunSpec {
100
+ lang: string
101
+ args?: Record<string, string>
102
+ stdin?: string
103
+ timeoutMs?: number
104
+ executor?: string
105
+ when: 'compile' | 'live' | 'both'
106
+ session?: string
107
+ }
108
+
109
+ export interface RecordedRun extends RunResult {
110
+ cacheKey: string
111
+ }
112
+
113
+ export interface CodeVariant {
114
+ label: string
115
+ value: string
116
+ permalink?: string
117
+ startLine?: number
118
+ endLine?: number
119
+ }
120
+
121
+ export interface CodeNode {
122
+ type: 'code'
123
+ lang: string
124
+ value: string
125
+ title?: string
126
+ origin?: CodeOrigin
127
+ highlight?: number[][]
128
+ run?: RunSpec
129
+ outputs?: RecordedRun[]
130
+ variants?: CodeVariant[]
131
+ echo?: false
132
+ }
133
+
134
+ export interface ComponentNode {
135
+ type: 'component'
136
+ plugin: string
137
+ name: string
138
+ props: Record<string, unknown>
139
+ children?: IRBlock[]
140
+ fallback?: MdastNode
141
+ assets?: Record<string, string>
142
+ layer?: 'overlay'
143
+ }
144
+
145
+ export type DirectiveOptionType = 'string' | 'number' | 'boolean' | 'list'
146
+
147
+ export interface DirectiveInput {
148
+ name: string
149
+ arg?: string
150
+ options: Record<string, unknown>
151
+ body: string
152
+ }
153
+
154
+ export interface DirectiveContext {
155
+ file: string
156
+ deckDir: string
157
+ workspaceRoot: string
158
+ lang?: string
159
+ config: Record<string, unknown>
160
+ source: SourcePos
161
+ parse(markdown: string): MdastNode
162
+ report(finding: Finding): void
163
+ watch(...files: string[]): void
164
+ }
165
+
166
+ export interface DirectiveResult {
167
+ node: IRNode
168
+ role?: BlockRole
169
+ span?: number
170
+ }
171
+
172
+ export interface RoleInput {
173
+ name: string
174
+ value: string
175
+ options: Record<string, unknown>
176
+ }
177
+
178
+ export interface RoleSpec {
179
+ name: string
180
+ options?: Record<string, DirectiveOptionType>
181
+ children?(input: RoleInput, ctx: DirectiveContext): MdastNode[]
182
+ }
183
+
184
+ export interface PresmiumRoleNode extends MdastNode {
185
+ type: 'presmiumRole'
186
+ plugin: string
187
+ name: string
188
+ options: Record<string, unknown>
189
+ children: MdastNode[]
190
+ }
191
+
192
+ export interface DirectiveSpec {
193
+ name: string
194
+ arg?: boolean | 'anchor'
195
+ body?: boolean
196
+ options?: Record<string, DirectiveOptionType>
197
+ toIR(input: DirectiveInput, ctx: DirectiveContext): DirectiveResult | Promise<DirectiveResult>
198
+ }
199
+
200
+ export interface RunCache {
201
+ get(key: string): RecordedRun | undefined
202
+ set(key: string, run: RecordedRun): void
203
+ }
204
+
205
+ export interface PassContext {
206
+ deckDir: string
207
+ workspaceRoot: string
208
+ lang?: string
209
+ src: SourceReader
210
+ resolvers: AnchorResolverRegistry
211
+ lock: AnchorLockFile
212
+ executors: Executor[]
213
+ cache: RunCache
214
+ config: Record<string, unknown>
215
+ configFor(section: IRSection): Record<string, unknown>
216
+ report(finding: Finding): void
217
+ watch(...files: string[]): void
218
+ service?<T>(token: ServiceToken<T>): T | undefined
219
+ }
220
+
221
+ export interface IRPass {
222
+ name: string
223
+ order?: number
224
+ after?: 'execute'
225
+ run(doc: IRDocument, ctx: PassContext): void | Promise<void>
226
+ }
227
+
228
+ export type Support = 'full' | 'degraded' | 'none'
229
+
230
+ export interface RenderContext {
231
+ deckDir: string
232
+ workspaceRoot: string
233
+ outDir: string
234
+ workDir: string
235
+ config: Record<string, unknown>
236
+ extensions: RendererExtension[]
237
+ plugins: string[]
238
+ pluginDirs: Record<string, string>
239
+ lang?: string
240
+ renderers: RendererId[]
241
+ base: string
242
+ vitePlugins: unknown[]
243
+ extensionFor(node: ComponentNode): ((node: ComponentNode, block: IRBlock) => unknown) | undefined
244
+ portable(value: unknown, from: string): unknown
245
+ localAsset(source: SourcePos, url: string): LocalAsset | undefined
246
+ documentLink?(source: SourcePos, url: string): DocumentLink | undefined
247
+ report(finding: Finding): void
248
+ service?<T>(token: ServiceToken<T>): T | undefined
249
+ translations?: { lang: string, doc: IRDocument }[]
250
+ server?: { url: string }
251
+ preview?: { origins: string[] }
252
+ }
253
+
254
+ export interface LocalAsset {
255
+ file: string
256
+ name: string
257
+ directory?: boolean
258
+ }
259
+
260
+ export interface DocumentLink {
261
+ dir: string
262
+ fragment?: string
263
+ }
264
+
265
+ export interface RenderOutput {
266
+ outDir: string
267
+ skipped: { section: string; block: string; reason: string }[]
268
+ }
269
+
270
+ export interface RenderDevServer {
271
+ url: string
272
+ update(doc: IRDocument): Promise<void>
273
+ close(): Promise<void>
274
+ }
275
+
276
+ export interface Renderer {
277
+ id: RendererId
278
+ site?: boolean
279
+ support(node: IRNode, extensions: RendererExtension[]): Support
280
+ prepare?(doc: IRDocument, ctx: RenderContext): Promise<void>
281
+ build(doc: IRDocument, ctx: RenderContext): Promise<RenderOutput>
282
+ dev?(doc: IRDocument, ctx: RenderContext): Promise<RenderDevServer>
283
+ buildSite?(site: SiteSpec, ctx: RenderContext): Promise<RenderOutput>
284
+ devSite?(site: SiteSpec, ctx: RenderContext, compile: (dir: string) => Promise<IRDocument>): Promise<SiteDevServer>
285
+ }
286
+
287
+ export interface SiteDocument {
288
+ dir: string
289
+ title: string
290
+ lang?: string
291
+ tags?: string[]
292
+ doc?: IRDocument
293
+ translations?: { lang: string, doc: IRDocument }[]
294
+ }
295
+
296
+ export interface SiteSpec {
297
+ title: string
298
+ documents: SiteDocument[]
299
+ home?: string
300
+ config: Record<string, unknown>
301
+ }
302
+
303
+ export interface SiteDevServer {
304
+ url: string
305
+ update(dir: string, doc: IRDocument): Promise<void>
306
+ close(): Promise<void>
307
+ }
308
+
309
+ export type ComponentRenderer = (node: ComponentNode, block: IRBlock, ctx: RenderContext) => unknown
310
+
311
+ export interface RendererContribution {
312
+ components?: Record<string, ComponentRenderer>
313
+ }
314
+
315
+ export interface RendererExtension {
316
+ plugin: string
317
+ components?: Record<string, (node: ComponentNode, block: IRBlock) => unknown>
318
+ }
package/src/kernel.ts ADDED
@@ -0,0 +1,177 @@
1
+ import { KERNEL_VERSION } from './contracts.ts'
2
+ import type {
3
+ EnvironmentSpec,
4
+ EventBus,
5
+ KernelEvent,
6
+ Logger,
7
+ PluginContext,
8
+ PluginDefinition,
9
+ PluginReport,
10
+ PresentationMode,
11
+ ServiceToken,
12
+ StartReport,
13
+ } from './contracts.ts'
14
+ import { errorMessage } from './errors.ts'
15
+ import { createEventBus } from './events.ts'
16
+ import { prefixed } from './logging.ts'
17
+ import { ServiceRegistry } from './services.ts'
18
+
19
+ export interface KernelOptions {
20
+ mode: PresentationMode
21
+ config?: Record<string, unknown>
22
+ log?: Logger
23
+ events?: EventBus
24
+ environments?: Record<string, EnvironmentSpec>
25
+ }
26
+
27
+ export class Kernel {
28
+ readonly version = KERNEL_VERSION
29
+ readonly mode: PresentationMode
30
+ readonly services = new ServiceRegistry()
31
+ readonly events: EventBus
32
+ #log: Logger
33
+ #config: Record<string, unknown>
34
+ #environments?: Readonly<Record<string, EnvironmentSpec>>
35
+ #plugins: PluginDefinition[] = []
36
+ #state: 'idle' | 'starting' | 'started' | 'disposed' = 'idle'
37
+ #report?: StartReport
38
+
39
+ constructor(options: KernelOptions) {
40
+ this.mode = options.mode
41
+ this.#config = options.config ?? {}
42
+ this.#environments = options.environments && Object.freeze({ ...options.environments })
43
+ this.#log = options.log ?? console
44
+ this.events = options.events ?? createEventBus((error, event) => {
45
+ this.#log.error(`[presmium] handler for "${event.type}" threw`, error)
46
+ })
47
+ }
48
+
49
+ register(plugin: PluginDefinition): void {
50
+ if (this.#state !== 'idle') throw new Error(`cannot register ${plugin.manifest.id}: kernel already started`)
51
+ if (this.#plugins.some(p => p.manifest.id === plugin.manifest.id)) {
52
+ throw new Error(`plugin ${plugin.manifest.id} is already registered`)
53
+ }
54
+ this.#plugins.push(plugin)
55
+ }
56
+
57
+ provide<T>(token: ServiceToken<T>, impl: T): void {
58
+ this.services.provide(token, impl)
59
+ }
60
+
61
+ getService<T>(token: ServiceToken<T>): T | undefined {
62
+ return this.services.tryGet(token)
63
+ }
64
+
65
+ report(): StartReport | undefined {
66
+ return this.#report
67
+ }
68
+
69
+ async start(): Promise<StartReport> {
70
+ if (this.#state !== 'idle') throw new Error('kernel already started')
71
+ this.#state = 'starting'
72
+ const plan = this.services.plan(this.#plugins.map(p => p.manifest), this.version)
73
+ const reports = new Map<string, PluginReport>()
74
+ for (const [id, reason] of plan.excluded) reports.set(id, this.#disable(id, reason))
75
+
76
+ const order: string[] = []
77
+ for (const id of plan.order) {
78
+ const plugin = this.#plugins.find(p => p.manifest.id === id)!
79
+ const unavailable = (plugin.manifest.requires ?? []).filter(r => !this.services.has(r.token))
80
+ const missing = unavailable.map(r => r.token.id)
81
+ if (unavailable.some(r => !r.optional)) {
82
+ const required = unavailable.filter(r => !r.optional).map(r => r.token.id)
83
+ reports.set(id, this.#disable(id, `missing required services: ${required.join(', ')}`, missing))
84
+ continue
85
+ }
86
+ order.push(id)
87
+ reports.set(id, await this.#setup(plugin, missing))
88
+ }
89
+
90
+ this.#report = { order, plugins: this.#plugins.map(p => reports.get(p.manifest.id)!), cycles: plan.cycles }
91
+ this.#state = 'started'
92
+ return this.#report
93
+ }
94
+
95
+ async dispose(): Promise<void> {
96
+ if (this.#state === 'disposed') return
97
+ this.#state = 'disposed'
98
+ const running = new Set(
99
+ this.#report?.plugins.filter(r => r.status === 'started' || r.status === 'degraded').map(r => r.id),
100
+ )
101
+ for (const id of [...(this.#report?.order ?? [])].reverse()) {
102
+ if (!running.has(id)) continue
103
+ const plugin = this.#plugins.find(p => p.manifest.id === id)!
104
+ try {
105
+ await plugin.dispose?.()
106
+ } catch (error) {
107
+ this.#log.error(`[presmium] ${id} dispose failed`, error)
108
+ }
109
+ this.services.revoke(id)
110
+ }
111
+ }
112
+
113
+ async #setup(plugin: PluginDefinition, missing: string[]): Promise<PluginReport> {
114
+ const { id, provides = [] } = plugin.manifest
115
+ try {
116
+ await plugin.setup(this.#context(plugin))
117
+ const unprovided = provides.filter(t => this.services.owner(t) !== id).map(t => t.id)
118
+ if (unprovided.length > 0) throw new Error(`declared but did not provide: ${unprovided.join(', ')}`)
119
+ } catch (error) {
120
+ this.services.revoke(id)
121
+ const reason = errorMessage(error)
122
+ this.#log.error(`[presmium] ${id} failed to start`, error)
123
+ this.#emit({ type: 'kernel.plugin.failed', payload: { id, error: reason } })
124
+ return { id, status: 'failed', reason }
125
+ }
126
+ if (missing.length > 0) {
127
+ this.#log.warn(`[presmium] ${id} started without optional services: ${missing.join(', ')}`)
128
+ this.#emit({ type: 'kernel.plugin.degraded', payload: { id, missing } })
129
+ return { id, status: 'degraded', reason: `optional services unavailable: ${missing.join(', ')}`, missing }
130
+ }
131
+ this.#emit({ type: 'kernel.plugin.started', payload: { id } })
132
+ return { id, status: 'started' }
133
+ }
134
+
135
+ #disable(id: string, reason: string, missing?: string[]): PluginReport {
136
+ this.#log.warn(`[presmium] ${id} disabled: ${reason}`)
137
+ this.#emit({ type: 'kernel.plugin.disabled', payload: { id, reason } })
138
+ return missing ? { id, status: 'disabled', reason, missing } : { id, status: 'disabled', reason }
139
+ }
140
+
141
+ #emit(event: KernelEvent): void {
142
+ this.events.emit(event)
143
+ }
144
+
145
+ #context(plugin: PluginDefinition): PluginContext {
146
+ const { id, provides = [], requires = [] } = plugin.manifest
147
+ const declared = new Set([...requires.map(r => r.token.id), ...provides.map(t => t.id)])
148
+ const assertDeclared = (token: ServiceToken<unknown>) => {
149
+ if (!declared.has(token.id)) throw new Error(`${id} did not declare service ${token.id} in its manifest`)
150
+ }
151
+ return {
152
+ config: pluginConfig(this.#config[id]),
153
+ mode: this.mode,
154
+ provide: (token, impl) => {
155
+ if (!provides.some(t => t.id === token.id)) {
156
+ throw new Error(`${id} provides ${token.id} without declaring it in manifest.provides`)
157
+ }
158
+ this.services.provide(token, impl, id)
159
+ },
160
+ get: token => {
161
+ assertDeclared(token)
162
+ return this.services.get(token)
163
+ },
164
+ tryGet: token => {
165
+ assertDeclared(token)
166
+ return this.services.tryGet(token)
167
+ },
168
+ events: this.events,
169
+ log: prefixed(this.#log, id),
170
+ ...this.#environments ? { environments: this.#environments } : {},
171
+ }
172
+ }
173
+ }
174
+
175
+ function pluginConfig(value: unknown): Record<string, unknown> {
176
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {}
177
+ }
package/src/lock.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { isRenderable } from './contracts.ts'
2
+ import type { AnchorLockFile, RecordedAnchor, Resolution } from './contracts.ts'
3
+ import { errorMessage } from './errors.ts'
4
+
5
+ export const ANCHOR_LOCK_PATH = '.presmium/anchors.lock.json'
6
+
7
+ export class AnchorLock {
8
+ #anchors = new Map<string, RecordedAnchor>()
9
+
10
+ private constructor(anchors: Iterable<[string, RecordedAnchor]>) {
11
+ for (const [anchor, recorded] of anchors) this.set(anchor, recorded)
12
+ }
13
+
14
+ static empty(): AnchorLock {
15
+ return new AnchorLock([])
16
+ }
17
+
18
+ static from(file: AnchorLockFile): AnchorLock {
19
+ return new AnchorLock(Object.entries(file.anchors))
20
+ }
21
+
22
+ static parse(text: string | undefined): AnchorLock {
23
+ if (text === undefined || text.trim() === '') return AnchorLock.empty()
24
+ let data: unknown
25
+ try {
26
+ data = JSON.parse(text)
27
+ } catch (error) {
28
+ throw new Error(`invalid anchor lock: ${errorMessage(error)}`)
29
+ }
30
+ return AnchorLock.from(validate(data))
31
+ }
32
+
33
+ get size(): number {
34
+ return this.#anchors.size
35
+ }
36
+
37
+ get(anchor: string): RecordedAnchor | undefined {
38
+ return this.#anchors.get(anchor)
39
+ }
40
+
41
+ set(anchor: string, recorded: RecordedAnchor): void {
42
+ this.#anchors.set(anchor, { fingerprint: recorded.fingerprint, file: recorded.file, startLine: recorded.startLine })
43
+ }
44
+
45
+ record(anchor: string, resolution: Resolution): boolean {
46
+ const { location, fingerprint } = resolution
47
+ if (!isRenderable(resolution) || !location || !fingerprint) return false
48
+ this.set(anchor, { fingerprint, file: location.file, startLine: location.startLine })
49
+ return true
50
+ }
51
+
52
+ retain(anchors: Iterable<string>): void {
53
+ const keep = new Set(anchors)
54
+ for (const anchor of [...this.#anchors.keys()]) {
55
+ if (!keep.has(anchor)) this.#anchors.delete(anchor)
56
+ }
57
+ }
58
+
59
+ toJSON(): AnchorLockFile {
60
+ const keys = [...this.#anchors.keys()].sort()
61
+ return { version: 1, anchors: Object.fromEntries(keys.map(k => [k, this.#anchors.get(k)!])) }
62
+ }
63
+
64
+ serialize(): string {
65
+ return `${JSON.stringify(this.toJSON(), null, 2)}\n`
66
+ }
67
+ }
68
+
69
+ function validate(data: unknown): AnchorLockFile {
70
+ if (!isObject(data) || data.version !== 1 || !isObject(data.anchors)) {
71
+ throw new Error('invalid anchor lock: expected { "version": 1, "anchors": { ... } }')
72
+ }
73
+ for (const [anchor, record] of Object.entries(data.anchors)) {
74
+ const valid = isObject(record)
75
+ && typeof record.fingerprint === 'string' && record.fingerprint !== ''
76
+ && typeof record.file === 'string'
77
+ && Number.isInteger(record.startLine) && (record.startLine as number) >= 1
78
+ if (!valid) throw new Error(`invalid anchor lock: bad record for "${anchor}"`)
79
+ }
80
+ return data as unknown as AnchorLockFile
81
+ }
82
+
83
+ function isObject(value: unknown): value is Record<string, unknown> {
84
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
85
+ }
package/src/logging.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { Logger } from './contracts.ts'
2
+
3
+ export function prefixed(log: Logger, id: string): Logger {
4
+ const tag = `[${id}]`
5
+ return {
6
+ debug: (...args) => log.debug(tag, ...args),
7
+ info: (...args) => log.info(tag, ...args),
8
+ warn: (...args) => log.warn(tag, ...args),
9
+ error: (...args) => log.error(tag, ...args),
10
+ }
11
+ }