@markii/runtime 0.9.0 → 0.11.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/dist/doc.d.ts ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The note-scoped, read-only `doc` view a script sees (GitHub issue #33).
3
+ *
4
+ * A script block already knows its own source. It knows nothing about the
5
+ * note it lives in, so a note that wants to collect what its author wrote
6
+ * — every `:::prep_q` block, say, and turn them into one quiz — has no way
7
+ * to say so. This module is the whole of that capability, and it is
8
+ * deliberately small: a listing of the note's directives, and a read of a
9
+ * value some EARLIER script in the same note already produced.
10
+ *
11
+ * Two properties make this safe enough to be tier-free (available to an
12
+ * `'auto'`/`'scheduled'` run exactly as to a manual one):
13
+ *
14
+ * 1. It grants no authority. Everything reachable through it is content
15
+ * the note already contains and values the same run already computed.
16
+ * There is no host, no file, no clock and no store beyond this note.
17
+ * 2. It is deterministic. `runDocumentScripts` runs a note's scripts
18
+ * sequentially in document order, so "the scripts above me have
19
+ * finished and the ones below me have not" is a fact, not a race.
20
+ *
21
+ * Everything here is pure: no I/O, no engine, no knowledge of Lua. A
22
+ * concrete `ScriptExecutor` (`@markii/lua`) receives a `DocView` per
23
+ * script and decides how to present it in its own language.
24
+ */
25
+ /** Which of the three directive forms (docs/format.md) a listed directive was written in. */
26
+ export type DirectiveForm = 'leaf' | 'container' | 'inline';
27
+ /**
28
+ * One directive as a script sees it. Every field is plain, already-capped
29
+ * data — a script can copy it, return it, or ignore it, and nothing here
30
+ * is a live handle back into the note.
31
+ *
32
+ * `text` is the directive's own inner text with markdown stripped: the
33
+ * text nodes of its subtree, blocks separated by a newline. A container
34
+ * that holds another directive therefore includes that inner directive's
35
+ * text too, because that text is genuinely inside it; the inner directive
36
+ * still gets its own entry in the listing.
37
+ */
38
+ export interface DirectiveEntry {
39
+ readonly name: string;
40
+ readonly form: DirectiveForm;
41
+ /** The directive's attributes, values as written. A bare attribute reads as an empty string. */
42
+ readonly attributes: Readonly<Record<string, string>>;
43
+ readonly text: string;
44
+ }
45
+ /**
46
+ * A note's directives in document order, plus whether anything was left
47
+ * out to stay inside the caps. `truncated` covers every kind of shortfall
48
+ * — a dropped directive, a shortened text, dropped attributes — because a
49
+ * script that cares only ever wants the one answer: is this the whole
50
+ * note, or not?
51
+ */
52
+ export interface DirectiveListing {
53
+ readonly directives: readonly DirectiveEntry[];
54
+ readonly truncated: boolean;
55
+ }
56
+ /**
57
+ * The size budget for one note's listing. These are not security
58
+ * boundaries in the sandbox's sense (the content is the user's own note,
59
+ * not a remote response); they exist so a pathological note cannot turn
60
+ * one `doc.directives()` call into a multi-megabyte string crossing the
61
+ * isolate boundary, and so the cost of the listing is knowable in advance.
62
+ */
63
+ export interface DocListingLimits {
64
+ /** Total budget for the serialized listing. Directives past it are dropped. */
65
+ readonly maxTotalBytes: number;
66
+ /** Most directives listed, whatever their size. */
67
+ readonly maxDirectives: number;
68
+ /** Longest `text` per directive; a longer one is cut, never dropped. */
69
+ readonly maxTextBytes: number;
70
+ /** Most attributes kept per directive, in written order. */
71
+ readonly maxAttributes: number;
72
+ /** Longest attribute name kept. A longer one is dropped, since a cut name is a name nobody asked for. */
73
+ readonly maxAttributeNameBytes: number;
74
+ /** Longest attribute value kept; a longer one is cut. */
75
+ readonly maxAttributeValueBytes: number;
76
+ /** How deep the tree walk goes. Content nested deeper than this is not listed. */
77
+ readonly maxDepth: number;
78
+ }
79
+ /**
80
+ * The documented defaults. 512 KiB is the headline number: comfortably
81
+ * more than any hand-written note holds, and small enough that the worst
82
+ * case is a string, not a memory problem. The rest follow from it — a
83
+ * note with two thousand directives or an eight-kilobyte question is
84
+ * already past what the feature is for.
85
+ */
86
+ export declare const DEFAULT_DOC_LISTING_LIMITS: DocListingLimits;
87
+ /** An empty listing, for a run whose host built none. Frozen: it is shared by every such run. */
88
+ export declare const EMPTY_DIRECTIVE_LISTING: DirectiveListing;
89
+ /**
90
+ * The shape `buildDirectiveListing` walks. Deliberately structural rather
91
+ * than an mdast import: this package parses nothing, and an mdast `Root`
92
+ * satisfies this as-is. Every read below is guarded anyway, so a foreign
93
+ * or hand-built tree degrades to a shorter listing instead of throwing.
94
+ */
95
+ export interface DocumentTreeNode {
96
+ readonly type: string;
97
+ readonly children?: readonly DocumentTreeNode[];
98
+ /** `unknown` on purpose: mdast spells `value`, `name` and `attributes` differently across node types (an MDX element's `name` is `string | null`, its `attributes` an array), and this walk narrows every one of them at runtime anyway. */
99
+ readonly value?: unknown;
100
+ readonly name?: unknown;
101
+ readonly attributes?: unknown;
102
+ }
103
+ /** Bytes `text` occupies as UTF-8, without allocating an encoder or a buffer. */
104
+ export declare function utf8ByteLength(text: string): number;
105
+ /**
106
+ * Removes what must never reach a script's string: C0 control characters
107
+ * other than tab and newline, DEL, and unpaired surrogates.
108
+ *
109
+ * The NUL byte is the one with teeth. A Lua string is byte-clean, but the
110
+ * JS-to-Lua marshaling in the reference engine truncates a string at its
111
+ * first NUL (documented in `@markii/lua`'s `bytesToLuaString`), so a
112
+ * single NUL pasted into a note would silently cut the rest of the
113
+ * listing off. Dropping it here means one directive loses one invisible
114
+ * character instead.
115
+ *
116
+ * An unpaired surrogate becomes U+FFFD for the same class of reason: it
117
+ * cannot be encoded as UTF-8, so every layer below would have to invent
118
+ * its own repair.
119
+ */
120
+ export declare function sanitizeText(text: string): string;
121
+ /**
122
+ * Walks a parsed note and returns every directive in it, in document
123
+ * order, as plain capped data.
124
+ *
125
+ * Nesting is flattened deliberately: a directive written inside another
126
+ * appears in the list right after its parent, at the position it occupies
127
+ * in the note. A script filtering by name therefore finds every match
128
+ * regardless of what wraps it, which is what a note author means by "all
129
+ * my questions".
130
+ *
131
+ * Never throws. A tree that is not a tree, a directive with no name, an
132
+ * `attributes` that is an array — each degrades to less listing, never to
133
+ * an exception, because this runs inside a run whose failure surface
134
+ * belongs to the script, not to the walk.
135
+ */
136
+ export declare function buildDirectiveListing(tree: DocumentTreeNode | null | undefined, overrides?: Partial<DocListingLimits>): DirectiveListing;
137
+ /** A successful `doc.value` read. `value` is `undefined` for a name nothing produced. */
138
+ export interface DocValueSuccess {
139
+ readonly ok: true;
140
+ readonly value: unknown;
141
+ }
142
+ /** A refused `doc.value` read: the name belongs to a script that has not run yet. */
143
+ export interface DocValueRejection {
144
+ readonly ok: false;
145
+ readonly message: string;
146
+ }
147
+ export type DocValueRead = DocValueSuccess | DocValueRejection;
148
+ /**
149
+ * What one script sees of its note. Handed to the executor per script;
150
+ * never shared between two of them.
151
+ */
152
+ export interface DocView {
153
+ readonly directives: DirectiveListing;
154
+ /** Reads the value of the script named `name`. Never throws — a refusal is a returned rejection. */
155
+ value(name: string): DocValueRead;
156
+ }
157
+ /** The listing half of `runDocumentScripts`' `doc` option: what the host built from the parsed note. */
158
+ export interface DocumentContext {
159
+ readonly directives: DirectiveListing;
160
+ }
161
+ /**
162
+ * The ONE wording for reading a value from a script that runs later in
163
+ * the note. It is a script-authoring mistake, not a permission problem
164
+ * and not a resource problem, so it classifies as an ordinary script
165
+ * error and the marker a host shows reads, in full: "script error: reads
166
+ * "quiz", which runs later in the note".
167
+ *
168
+ * Keeping the sentence here means the presentation layers keep their one
169
+ * job (naming the KIND of failure) and this package keeps its own (saying
170
+ * what happened), instead of a phrase being invented once per host.
171
+ */
172
+ export declare function laterScriptReadMessage(name: string): string;
173
+ /** Per-script `DocView`s for one run, plus the recorder that advances "what has finished". */
174
+ export interface DocViewSource {
175
+ /** The view the script at `index` (document order) runs with. */
176
+ viewFor(index: number): DocView;
177
+ /** Records that the script at that index finished, with the value it stored (`undefined` when it failed). */
178
+ recordCompleted(name: string, value: unknown): void;
179
+ }
180
+ /**
181
+ * Builds the per-script views for one run.
182
+ *
183
+ * The rule `doc.value` enforces is "above me, already finished":
184
+ *
185
+ * - a name some earlier script in this run already produced reads back as
186
+ * that value (a script that FAILED produced nothing, so its name reads
187
+ * as nil — the failure is already reported against that script, and
188
+ * repeating it here would blame the reader);
189
+ * - a name belonging to a script at or after the caller's own position is
190
+ * refused, because the answer would otherwise depend on where the
191
+ * reader happened to be written;
192
+ * - anything else is simply nil, the same as reading an undefined name.
193
+ *
194
+ * A name written twice in one note is decided by what has already run: if
195
+ * an earlier block of that name finished, its value is what the reader
196
+ * sees, even when a later block will overwrite it afterwards.
197
+ */
198
+ export declare function createDocViewSource(options: {
199
+ directives: DirectiveListing;
200
+ scriptNames: readonly string[];
201
+ }): DocViewSource;
package/dist/doc.js ADDED
@@ -0,0 +1,389 @@
1
+ /**
2
+ * The note-scoped, read-only `doc` view a script sees (GitHub issue #33).
3
+ *
4
+ * A script block already knows its own source. It knows nothing about the
5
+ * note it lives in, so a note that wants to collect what its author wrote
6
+ * — every `:::prep_q` block, say, and turn them into one quiz — has no way
7
+ * to say so. This module is the whole of that capability, and it is
8
+ * deliberately small: a listing of the note's directives, and a read of a
9
+ * value some EARLIER script in the same note already produced.
10
+ *
11
+ * Two properties make this safe enough to be tier-free (available to an
12
+ * `'auto'`/`'scheduled'` run exactly as to a manual one):
13
+ *
14
+ * 1. It grants no authority. Everything reachable through it is content
15
+ * the note already contains and values the same run already computed.
16
+ * There is no host, no file, no clock and no store beyond this note.
17
+ * 2. It is deterministic. `runDocumentScripts` runs a note's scripts
18
+ * sequentially in document order, so "the scripts above me have
19
+ * finished and the ones below me have not" is a fact, not a race.
20
+ *
21
+ * Everything here is pure: no I/O, no engine, no knowledge of Lua. A
22
+ * concrete `ScriptExecutor` (`@markii/lua`) receives a `DocView` per
23
+ * script and decides how to present it in its own language.
24
+ */
25
+ /**
26
+ * The documented defaults. 512 KiB is the headline number: comfortably
27
+ * more than any hand-written note holds, and small enough that the worst
28
+ * case is a string, not a memory problem. The rest follow from it — a
29
+ * note with two thousand directives or an eight-kilobyte question is
30
+ * already past what the feature is for.
31
+ */
32
+ export const DEFAULT_DOC_LISTING_LIMITS = {
33
+ maxTotalBytes: 512 * 1024,
34
+ maxDirectives: 2_000,
35
+ maxTextBytes: 8 * 1024,
36
+ maxAttributes: 32,
37
+ maxAttributeNameBytes: 128,
38
+ maxAttributeValueBytes: 1024,
39
+ maxDepth: 200,
40
+ };
41
+ /** An empty listing, for a run whose host built none. Frozen: it is shared by every such run. */
42
+ export const EMPTY_DIRECTIVE_LISTING = Object.freeze({
43
+ directives: Object.freeze([]),
44
+ truncated: false,
45
+ });
46
+ const FORM_BY_TYPE = new Map([
47
+ ['leafDirective', 'leaf'],
48
+ ['containerDirective', 'container'],
49
+ ['textDirective', 'inline'],
50
+ ]);
51
+ /**
52
+ * Node types that start a new line in the extracted text. Everything else
53
+ * is phrasing content and runs on, so `**bold** words` reads back as
54
+ * `bold words` rather than as two lines.
55
+ */
56
+ const BLOCK_TYPES = new Set([
57
+ 'paragraph',
58
+ 'heading',
59
+ 'blockquote',
60
+ 'list',
61
+ 'listItem',
62
+ 'code',
63
+ 'table',
64
+ 'tableRow',
65
+ 'thematicBreak',
66
+ 'definition',
67
+ 'footnoteDefinition',
68
+ 'html',
69
+ 'yaml',
70
+ 'toml',
71
+ 'leafDirective',
72
+ 'containerDirective',
73
+ ]);
74
+ /** Node types whose `value` IS their text. A `code` fence contributes its body; an `html` node contributes nothing, since raw markup is not text the author wrote to be read. */
75
+ const VALUE_TYPES = new Set([
76
+ 'text',
77
+ 'inlineCode',
78
+ 'code',
79
+ ]);
80
+ /** Bytes `text` occupies as UTF-8, without allocating an encoder or a buffer. */
81
+ export function utf8ByteLength(text) {
82
+ let bytes = 0;
83
+ for (let i = 0; i < text.length; i++) {
84
+ const code = text.charCodeAt(i);
85
+ if (code < 0x80)
86
+ bytes += 1;
87
+ else if (code < 0x800)
88
+ bytes += 2;
89
+ else if (code >= 0xd800 && code <= 0xdbff) {
90
+ // A well-formed surrogate pair is one 4-byte character; a lone high
91
+ // surrogate is replaced (see `sanitizeText`) and so costs 3.
92
+ const next = text.charCodeAt(i + 1);
93
+ if (next >= 0xdc00 && next <= 0xdfff) {
94
+ bytes += 4;
95
+ i++;
96
+ }
97
+ else
98
+ bytes += 3;
99
+ }
100
+ else
101
+ bytes += 3;
102
+ }
103
+ return bytes;
104
+ }
105
+ /**
106
+ * Removes what must never reach a script's string: C0 control characters
107
+ * other than tab and newline, DEL, and unpaired surrogates.
108
+ *
109
+ * The NUL byte is the one with teeth. A Lua string is byte-clean, but the
110
+ * JS-to-Lua marshaling in the reference engine truncates a string at its
111
+ * first NUL (documented in `@markii/lua`'s `bytesToLuaString`), so a
112
+ * single NUL pasted into a note would silently cut the rest of the
113
+ * listing off. Dropping it here means one directive loses one invisible
114
+ * character instead.
115
+ *
116
+ * An unpaired surrogate becomes U+FFFD for the same class of reason: it
117
+ * cannot be encoded as UTF-8, so every layer below would have to invent
118
+ * its own repair.
119
+ */
120
+ export function sanitizeText(text) {
121
+ let out = '';
122
+ for (let i = 0; i < text.length; i++) {
123
+ const code = text.charCodeAt(i);
124
+ if (code >= 0xd800 && code <= 0xdbff) {
125
+ const next = text.charCodeAt(i + 1);
126
+ if (next >= 0xdc00 && next <= 0xdfff) {
127
+ out += text[i] + text[i + 1];
128
+ i++;
129
+ }
130
+ else {
131
+ out += '\uFFFD';
132
+ }
133
+ continue;
134
+ }
135
+ if (code >= 0xdc00 && code <= 0xdfff) {
136
+ out += '\uFFFD';
137
+ continue;
138
+ }
139
+ if (code === 0x09 || code === 0x0a) {
140
+ out += text[i];
141
+ continue;
142
+ }
143
+ if (code < 0x20 || code === 0x7f)
144
+ continue;
145
+ out += text[i];
146
+ }
147
+ return out;
148
+ }
149
+ /** Cuts `text` to at most `maxBytes` UTF-8 bytes, never mid-character. */
150
+ function truncateToBytes(text, maxBytes) {
151
+ if (utf8ByteLength(text) <= maxBytes)
152
+ return { text, truncated: false };
153
+ let bytes = 0;
154
+ let end = 0;
155
+ for (const char of text) {
156
+ const size = utf8ByteLength(char);
157
+ if (bytes + size > maxBytes)
158
+ break;
159
+ bytes += size;
160
+ end += char.length;
161
+ }
162
+ return { text: text.slice(0, end), truncated: true };
163
+ }
164
+ /** Whether `value` is a walkable node — a plain object carrying a string `type`. */
165
+ function isNode(value) {
166
+ return (typeof value === 'object' &&
167
+ value !== null &&
168
+ typeof value.type === 'string');
169
+ }
170
+ /** Children of `node` that are themselves nodes, or an empty array. */
171
+ function childrenOf(node) {
172
+ const children = node.children;
173
+ return Array.isArray(children) ? children.filter(isNode) : [];
174
+ }
175
+ /**
176
+ * The plain text of `node`'s subtree: text and inline code as written,
177
+ * fenced code as its body, blocks separated by a newline. Emphasis,
178
+ * links and images contribute their text and nothing else, which is the
179
+ * point — a script asking for a question's answer wants the answer, not
180
+ * its markup.
181
+ */
182
+ function collectText(node, depth, max) {
183
+ if (depth > max)
184
+ return '';
185
+ if (VALUE_TYPES.has(node.type) && typeof node.value === 'string') {
186
+ return node.value;
187
+ }
188
+ if (node.type === 'break')
189
+ return '\n';
190
+ let out = '';
191
+ for (const child of childrenOf(node)) {
192
+ const part = collectText(child, depth + 1, max);
193
+ if (part === '')
194
+ continue;
195
+ if (out !== '' && BLOCK_TYPES.has(child.type))
196
+ out += '\n';
197
+ out += part;
198
+ }
199
+ return out;
200
+ }
201
+ /**
202
+ * Reads a directive node's attributes into plain strings. A bare
203
+ * attribute (`{open}`) arrives as `null` from the parser and reads as an
204
+ * empty string, matching how the renderers already treat it.
205
+ *
206
+ * The result has a null prototype and is built with own-key assignment
207
+ * only, so an attribute literally named `__proto__` or `constructor` is
208
+ * an ordinary entry with no inherited meaning.
209
+ */
210
+ function readAttributes(node, limits) {
211
+ const attributes = Object.create(null);
212
+ const raw = node.attributes;
213
+ // A plain object only: an mdast-flavored node whose `attributes` is an
214
+ // array (MDX) has no attributes in this format's sense, and reading one
215
+ // as a record would list `length` as if the author had written it.
216
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
217
+ return { attributes, truncated: false };
218
+ }
219
+ const record = raw;
220
+ let truncated = false;
221
+ let kept = 0;
222
+ for (const key of Object.keys(record)) {
223
+ if (kept >= limits.maxAttributes) {
224
+ truncated = true;
225
+ break;
226
+ }
227
+ const name = sanitizeText(key);
228
+ if (name === '' || utf8ByteLength(name) > limits.maxAttributeNameBytes) {
229
+ truncated = true;
230
+ continue;
231
+ }
232
+ const rawValue = record[key];
233
+ const value = typeof rawValue === 'string' ? sanitizeText(rawValue) : '';
234
+ const capped = truncateToBytes(value, limits.maxAttributeValueBytes);
235
+ if (capped.truncated)
236
+ truncated = true;
237
+ // Plain own-key assignment onto a null-prototype object: there is no
238
+ // inherited `__proto__` setter to trip over, so a directive written
239
+ // `{__proto__=x}` lands as an ordinary key with no side effect.
240
+ attributes[name] = capped.text;
241
+ kept++;
242
+ }
243
+ return { attributes, truncated };
244
+ }
245
+ /**
246
+ * Walks a parsed note and returns every directive in it, in document
247
+ * order, as plain capped data.
248
+ *
249
+ * Nesting is flattened deliberately: a directive written inside another
250
+ * appears in the list right after its parent, at the position it occupies
251
+ * in the note. A script filtering by name therefore finds every match
252
+ * regardless of what wraps it, which is what a note author means by "all
253
+ * my questions".
254
+ *
255
+ * Never throws. A tree that is not a tree, a directive with no name, an
256
+ * `attributes` that is an array — each degrades to less listing, never to
257
+ * an exception, because this runs inside a run whose failure surface
258
+ * belongs to the script, not to the walk.
259
+ */
260
+ export function buildDirectiveListing(tree, overrides = {}) {
261
+ const limits = {
262
+ ...DEFAULT_DOC_LISTING_LIMITS,
263
+ ...overrides,
264
+ };
265
+ const directives = [];
266
+ let truncated = false;
267
+ let bytes = 0;
268
+ if (!isNode(tree))
269
+ return { directives, truncated };
270
+ const walk = (node, depth) => {
271
+ if (depth > limits.maxDepth) {
272
+ truncated = true;
273
+ return;
274
+ }
275
+ const form = FORM_BY_TYPE.get(node.type);
276
+ if (form !== undefined) {
277
+ const name = typeof node.name === 'string' ? sanitizeText(node.name) : '';
278
+ if (name !== '') {
279
+ if (directives.length >= limits.maxDirectives) {
280
+ truncated = true;
281
+ }
282
+ else {
283
+ const attrs = readAttributes(node, limits);
284
+ if (attrs.truncated)
285
+ truncated = true;
286
+ const rawText = sanitizeText(collectText(node, 0, limits.maxDepth));
287
+ const text = truncateToBytes(rawText, limits.maxTextBytes);
288
+ if (text.truncated)
289
+ truncated = true;
290
+ const entry = {
291
+ name,
292
+ form,
293
+ attributes: attrs.attributes,
294
+ text: text.text,
295
+ };
296
+ // Charged against the total budget by what the entry actually
297
+ // costs on the wire, so one enormous directive cannot crowd out
298
+ // the rest by accident and a note of small ones is not cut
299
+ // early by a pessimistic estimate.
300
+ const cost = entryCost(entry);
301
+ if (bytes + cost > limits.maxTotalBytes) {
302
+ truncated = true;
303
+ }
304
+ else {
305
+ bytes += cost;
306
+ directives.push(entry);
307
+ }
308
+ }
309
+ }
310
+ }
311
+ for (const child of childrenOf(node))
312
+ walk(child, depth + 1);
313
+ };
314
+ walk(tree, 0);
315
+ return { directives, truncated };
316
+ }
317
+ /** What one entry costs against `maxTotalBytes`: its text, its attribute names and values, and its own name. */
318
+ function entryCost(entry) {
319
+ let cost = utf8ByteLength(entry.name) + utf8ByteLength(entry.text) + 32;
320
+ for (const [key, value] of Object.entries(entry.attributes)) {
321
+ cost += utf8ByteLength(key) + utf8ByteLength(value) + 8;
322
+ }
323
+ return cost;
324
+ }
325
+ /**
326
+ * The ONE wording for reading a value from a script that runs later in
327
+ * the note. It is a script-authoring mistake, not a permission problem
328
+ * and not a resource problem, so it classifies as an ordinary script
329
+ * error and the marker a host shows reads, in full: "script error: reads
330
+ * "quiz", which runs later in the note".
331
+ *
332
+ * Keeping the sentence here means the presentation layers keep their one
333
+ * job (naming the KIND of failure) and this package keeps its own (saying
334
+ * what happened), instead of a phrase being invented once per host.
335
+ */
336
+ export function laterScriptReadMessage(name) {
337
+ return `reads "${name}", which runs later in the note`;
338
+ }
339
+ /**
340
+ * Builds the per-script views for one run.
341
+ *
342
+ * The rule `doc.value` enforces is "above me, already finished":
343
+ *
344
+ * - a name some earlier script in this run already produced reads back as
345
+ * that value (a script that FAILED produced nothing, so its name reads
346
+ * as nil — the failure is already reported against that script, and
347
+ * repeating it here would blame the reader);
348
+ * - a name belonging to a script at or after the caller's own position is
349
+ * refused, because the answer would otherwise depend on where the
350
+ * reader happened to be written;
351
+ * - anything else is simply nil, the same as reading an undefined name.
352
+ *
353
+ * A name written twice in one note is decided by what has already run: if
354
+ * an earlier block of that name finished, its value is what the reader
355
+ * sees, even when a later block will overwrite it afterwards.
356
+ */
357
+ export function createDocViewSource(options) {
358
+ const { directives, scriptNames } = options;
359
+ // Maps, not objects: a script named `__proto__` or `constructor` is a
360
+ // legal name (`@markii/core`'s charset allows it) and must be an
361
+ // ordinary key here, never a reach into a prototype.
362
+ const completed = new Map();
363
+ const lastIndexByName = new Map();
364
+ scriptNames.forEach((name, index) => {
365
+ lastIndexByName.set(name, index);
366
+ });
367
+ return {
368
+ viewFor(index) {
369
+ return {
370
+ directives,
371
+ value(name) {
372
+ if (typeof name !== 'string')
373
+ return { ok: true, value: undefined };
374
+ if (completed.has(name)) {
375
+ return { ok: true, value: completed.get(name) };
376
+ }
377
+ const last = lastIndexByName.get(name);
378
+ if (last !== undefined && last >= index) {
379
+ return { ok: false, message: laterScriptReadMessage(name) };
380
+ }
381
+ return { ok: true, value: undefined };
382
+ },
383
+ };
384
+ },
385
+ recordCompleted(name, value) {
386
+ completed.set(name, value);
387
+ },
388
+ };
389
+ }
package/dist/index.d.ts CHANGED
@@ -2,4 +2,5 @@ export { createValueStore, type StoredValue, type ValueStatus, type ValueStore,
2
2
  export { FAILURE_KINDS, normalizeFailureKind, type FailureKind, } from './failure.js';
3
3
  export { computeGrantKey, type GrantClosure, type GrantClosurePack, type GrantClosureScript, } from './grant-key.js';
4
4
  export { createVaultStore, type CreateVaultStoreOptions, type VaultPublishFailure, type VaultPublishResult, type VaultPublishSuccess, type VaultStore, type VaultStoreHandle, type VaultWriter, } from './vault.js';
5
+ export { DEFAULT_DOC_LISTING_LIMITS, EMPTY_DIRECTIVE_LISTING, buildDirectiveListing, createDocViewSource, laterScriptReadMessage, sanitizeText, utf8ByteLength, type DirectiveEntry, type DirectiveForm, type DirectiveListing, type DocListingLimits, type DocValueRead, type DocValueRejection, type DocValueSuccess, type DocView, type DocViewSource, type DocumentContext, type DocumentTreeNode, } from './doc.js';
5
6
  export { runDocumentScripts, tierForTrigger, type ExecuteFailure, type ExecuteResult, type ExecuteSuccess, type ExecutionTier, type RunDocumentScriptsOptions, type RunSummary, type RunSummaryEntry, type RunTrigger, type ScriptExecutor, } from './run.js';
package/dist/index.js CHANGED
@@ -2,4 +2,5 @@ export { createValueStore, } from './store.js';
2
2
  export { FAILURE_KINDS, normalizeFailureKind, } from './failure.js';
3
3
  export { computeGrantKey, } from './grant-key.js';
4
4
  export { createVaultStore, } from './vault.js';
5
+ export { DEFAULT_DOC_LISTING_LIMITS, EMPTY_DIRECTIVE_LISTING, buildDirectiveListing, createDocViewSource, laterScriptReadMessage, sanitizeText, utf8ByteLength, } from './doc.js';
5
6
  export { runDocumentScripts, tierForTrigger, } from './run.js';
package/dist/run.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ScriptBlock } from '@markii/core';
2
+ import { type DocumentContext, type DocView } from './doc.js';
2
3
  import { type FailureKind } from './failure.js';
3
- import type { ValueStore } from './store.js';
4
+ import type { StoredValue, ValueStore } from './store.js';
4
5
  import type { VaultWriter } from './vault.js';
5
6
  /**
6
7
  * Slice 2 of the scripting-usability layer (docs/scripting.md): the run
@@ -67,6 +68,19 @@ export type ExecuteResult = ExecuteSuccess | ExecuteFailure;
67
68
  export type ScriptExecutor = (input: {
68
69
  code: string;
69
70
  tier: ExecutionTier;
71
+ /**
72
+ * This script's read-only view of the note it lives in (`./doc.ts`,
73
+ * GitHub issue #33): the note's directives as plain data, and a read of
74
+ * what the scripts ABOVE this one already produced. Optional so an
75
+ * executor written before this existed still satisfies the type; every
76
+ * executor that does expose it must expose it as read-only data and must
77
+ * turn a rejected `value()` read into an ordinary failed execution.
78
+ *
79
+ * It carries no authority and is therefore NOT tier-gated: the same view
80
+ * is handed to a script under `'auto'` as under `'manual'`, because
81
+ * everything in it is the note's own content and this run's own values.
82
+ */
83
+ doc?: DocView;
70
84
  }) => Promise<ExecuteResult>;
71
85
  /** One script's outcome from a `runDocumentScripts` batch, in document order. */
72
86
  export interface RunSummaryEntry {
@@ -152,6 +166,39 @@ export interface RunDocumentScriptsOptions {
152
166
  * (successfully) produced.
153
167
  */
154
168
  vault?: VaultWriter;
169
+ /**
170
+ * Called once per script, immediately after its `StoredValue` was written
171
+ * to `store` and in the same document order the scripts ran in (GitHub
172
+ * issue #35). This is the hook a host uses to show a value the moment it
173
+ * lands rather than at the end of the batch: `runDocumentScripts` runs
174
+ * scripts sequentially, so by the time the batch resolves, the first
175
+ * script's number has often been known for seconds.
176
+ *
177
+ * Purely observational. It cannot change what is stored, what is
178
+ * reported, or whether the batch continues: `entry` is a SHALLOW COPY of
179
+ * the summary entry (so a callback cannot mutate the run's own
180
+ * bookkeeping, and so it never later grows the publish fields, which are
181
+ * decided after this point), and anything this callback throws is
182
+ * swallowed — `runDocumentScripts` never throws, and a host's progress
183
+ * reporting failing must not cost the user the rest of the run.
184
+ *
185
+ * Called for a FAILED script too, with the error `StoredValue` that was
186
+ * stored for it: a failure is as much a result as a number, and a host
187
+ * that only heard about successes would leave that script's component
188
+ * looking like it was still running.
189
+ */
190
+ onValue?: (name: string, value: StoredValue, entry: RunSummaryEntry) => void;
191
+ /**
192
+ * The note itself, for the `doc` view a script sees (`./doc.ts`). The
193
+ * caller builds this once from the tree it already parsed to find
194
+ * `scripts`; this package parses nothing.
195
+ *
196
+ * Absent, scripts still get a `doc` view, with an empty listing — the
197
+ * ORDERING rules `doc.value` enforces do not depend on it, so a host
198
+ * that never builds a listing still cannot let one script read another
199
+ * that has not run.
200
+ */
201
+ doc?: DocumentContext;
155
202
  }
156
203
  /**
157
204
  * Runs every script block in `scripts`, in document order, against
package/dist/run.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createDocViewSource, EMPTY_DIRECTIVE_LISTING, } from './doc.js';
1
2
  import { normalizeFailureKind } from './failure.js';
2
3
  /**
3
4
  * docs/scripting.md's trigger x capability table, expressed as a pure lookup —
@@ -56,7 +57,7 @@ function describeThrown(err) {
56
57
  * all, so rewriting every capability-kind auto-tier failure that way was
57
58
  * simply wrong).
58
59
  */
59
- async function runOne(script, executor, tier, loadSource) {
60
+ async function runOne(script, executor, tier, loadSource, doc) {
60
61
  let code;
61
62
  try {
62
63
  if (script.src !== undefined) {
@@ -90,7 +91,7 @@ async function runOne(script, executor, tier, loadSource) {
90
91
  }
91
92
  let result;
92
93
  try {
93
- result = await executor({ code, tier });
94
+ result = await executor({ code, tier, doc });
94
95
  }
95
96
  catch (err) {
96
97
  const message = describeThrown(err);
@@ -161,18 +162,43 @@ async function runOne(script, executor, tier, loadSource) {
161
162
  * `RunSummary.duplicateNames`.
162
163
  */
163
164
  export async function runDocumentScripts(options) {
164
- const { scripts, executor, trigger, store, loadSource, vault } = options;
165
+ const { scripts, executor, trigger, store, loadSource, vault, onValue } = options;
165
166
  const tier = tierForTrigger(trigger);
167
+ // GitHub issue #33: one source of `doc` views for the whole batch. Built
168
+ // unconditionally, even with no `options.doc`, because it also owns the
169
+ // "a script may only read what ran above it" rule, which is a property
170
+ // of this loop's ordering rather than of the listing.
171
+ const docViews = createDocViewSource({
172
+ directives: options.doc?.directives ?? EMPTY_DIRECTIVE_LISTING,
173
+ scriptNames: scripts.map((script) => script.name),
174
+ });
166
175
  const results = [];
167
176
  const seenNames = new Set();
168
177
  const duplicateNames = new Set();
169
- for (const script of scripts) {
178
+ for (const [index, script] of scripts.entries()) {
170
179
  if (seenNames.has(script.name)) {
171
180
  duplicateNames.add(script.name);
172
181
  }
173
182
  seenNames.add(script.name);
174
- const outcome = await runOne(script, executor, tier, loadSource);
183
+ const outcome = await runOne(script, executor, tier, loadSource, docViews.viewFor(index));
175
184
  store.set(script.name, outcome.storedValue);
185
+ // GitHub issue #35: the value is announced the instant it is stored,
186
+ // before publishing and before the batch moves on to the next script,
187
+ // so a host can render it while the rest of the run is still going.
188
+ // Guarded because this function's never-throws contract covers
189
+ // everything a caller supplies, this callback included.
190
+ if (onValue) {
191
+ try {
192
+ onValue(script.name, outcome.storedValue, { ...outcome.entry });
193
+ }
194
+ catch {
195
+ // Deliberately ignored: see `onValue`'s doc comment.
196
+ }
197
+ }
198
+ // Recorded AFTER the run, so a script can never see its own name, and
199
+ // recorded for a failed run too (as `undefined`), so a later reader
200
+ // gets nil rather than a refusal blaming it for someone else's error.
201
+ docViews.recordCompleted(script.name, outcome.storedValue.value);
176
202
  // Publishing (docs/scripting.md): only for a block that both asked to
177
203
  // publish (bare `publish` on its fence — see `ScriptBlock.publish`) and
178
204
  // actually succeeded. A failed run has nothing to publish; `runOne`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markii/runtime",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Host-side scripting glue for Mark (.mk.md): a null-proto value store and document-script execution with trigger-tier gating (auto/scheduled stay read-only). Framework-agnostic; the script executor is injected by the host (e.g. @markii/lua).",
5
5
  "keywords": [
6
6
  "markdown",
@@ -42,6 +42,6 @@
42
42
  "lint": "eslint ."
43
43
  },
44
44
  "dependencies": {
45
- "@markii/core": "0.9.0"
45
+ "@markii/core": "0.11.0"
46
46
  }
47
47
  }