@markii/host 0.17.0-next.4 → 0.17.0-next.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,4 +25,31 @@ Entries:
25
25
  - `@markii/host/conformance`: the host scenario corpus and its runner, to
26
26
  prove an adapter behaves like the reference apps.
27
27
 
28
- See `docs/integration.md` in the repository for how to write a host.
28
+ A minimal browser host is one adapter and one call:
29
+
30
+ ```ts
31
+ import { createMarkiiHost } from '@markii/host';
32
+ import {
33
+ createBrowserHostIsolate,
34
+ createFetchNetProvider,
35
+ } from '@markii/host/browser';
36
+
37
+ const host = createMarkiiHost({
38
+ id: 'my-app',
39
+ labels,
40
+ readFile,
41
+ exists,
42
+ writeFile,
43
+ listFolder,
44
+ prompt: (request) => showDialog(request),
45
+ memento,
46
+ diagnostics: (line) => console.info(line),
47
+ now: () => Date.now(),
48
+ isolate: createBrowserHostIsolate({
49
+ netProvider: createFetchNetProvider({ allowedOrigins }),
50
+ }),
51
+ });
52
+ ```
53
+
54
+ See `docs/hosts.md` in the repository for the adapter fields, the host
55
+ responsibilities and the reference hosts.
@@ -0,0 +1,17 @@
1
+ {
2
+ "outcomes": [
3
+ {
4
+ "kind": "exported",
5
+ "noteCount": 2,
6
+ "entries": ["index.html", "note.html", "second.html"]
7
+ }
8
+ ],
9
+ "diagnostics": [
10
+ "Cascade export wrote",
11
+ "Exported",
12
+ "Rendered with the static engine because no pack components are loaded",
13
+ "Exported",
14
+ "Rendered with the static engine because no pack components are loaded"
15
+ ],
16
+ "prompts": []
17
+ }
@@ -0,0 +1,3 @@
1
+ # Second note
2
+
3
+ A page that the root note links to.
@@ -0,0 +1,4 @@
1
+ # Linked set
2
+
3
+ This note links to [a second note](files/second.mk.md) and to
4
+ [a page outside the set](https://example.com/page).
@@ -0,0 +1,6 @@
1
+ {
2
+ "id": "export-linked-set",
3
+ "invariant": "a note and the notes it links to export as one archive with an index page, and a host that does not offer the export reports it unsupported",
4
+ "clock": 1700000000000,
5
+ "actions": [{ "type": "exportLinkedSet" }]
6
+ }
@@ -49,6 +49,7 @@ needs, is the runner's own `ACTION_CAPABILITY` map:
49
49
  | `run` | `isolate` |
50
50
  | `exportHtml` | `export-format` (`html`) |
51
51
  | `exportAnsi` | `export-format` (`ansi`) |
52
+ | `exportLinkedSet` | `linked-set-export` (a `resolveLinkedSetTarget`) |
52
53
  | `installPack` | `packs` (and an `installRoot`) |
53
54
  | `loadPacks` | `packs` |
54
55
  | `removePack` | `packs` (and an `installRoot` plus `removeFolder`) |
@@ -97,12 +98,12 @@ would have produced:
97
98
  and persists exactly what it always does. Only the isolate that would
98
99
  execute the script is faked.
99
100
  - `requiresManualTier`: the run reports `tier-blocked` under any trigger
100
- but `'manual'`, regardless of grants — the same enforcement the real
101
+ but `'manual'`, regardless of grants, the same enforcement the real
101
102
  sandbox applies to an effectful call under a read-only trigger.
102
103
 
103
104
  Faking the isolate keeps this corpus fast and deterministic. It proves the
104
- HOST-LEVEL plumbing — grant prompts, wording, capability declarations,
105
- trigger forwarding — is identical across hosts. Real sandbox enforcement
105
+ HOST-LEVEL plumbing, grant prompts, wording, capability declarations,
106
+ trigger forwarding, is identical across hosts. Real sandbox enforcement
106
107
  (the tier gate, the net allowlist, the marshal boundary) is
107
108
  `conformance/executor/`'s job and is not duplicated here.
108
109
 
@@ -137,9 +138,11 @@ coarse, matching each `kind`:
137
138
  taxonomy only, never a raw message or a timing; an optional `traceOk`
138
139
  compares the recorded run trace's `ok` flag.
139
140
  - `exported`: `contains`/`excludes` are substrings checked against the
140
- written file's content, never the whole document — the render corpora
141
+ written file's content, never the whole document, the render corpora
141
142
  already pin bytes. `valueCount` is how many last-run values were baked
142
143
  in, and `renderEngine` (`static` or `react`) is which engine drew the body.
144
+ A linked-set export is an `exported` outcome too: `noteCount` is how many
145
+ notes the archive holds, and `entries` is its sorted file names.
143
146
  - `installed`: `namespace` and `replaced`, and `installedDirName`, the name
144
147
  of the folder the pack was unzipped into.
145
148
  - `declined`: `step` (`'consent'`/`'replace'`, or absent for the
@@ -161,7 +164,7 @@ coarse, matching each `kind`:
161
164
 
162
165
  An action whose capability the adapter under test does not declare is
163
166
  never diffed against `expected.json` at all (see "Capability handling"
164
- below) — `expected.json` always describes the FULLY CAPABLE host's
167
+ below), `expected.json` always describes the FULLY CAPABLE host's
165
168
  correct behavior, even for a scenario where one real host today has
166
169
  nothing to compare it to.
167
170
 
@@ -173,11 +176,11 @@ difference) without the corpus having to spell out three wordings for
173
176
  one behavior. Diagnostics lines written by a capability decline itself
174
177
  (`declined (<capability> unsupported on <host>): …`) are filtered out of
175
178
  the actual list before this comparison, since that line exists only
176
- because the capability was ABSENT — it is not part of what
179
+ because the capability was ABSENT, it is not part of what
177
180
  `expected.json` describes.
178
181
 
179
182
  `prompts` is load-bearing: it proves all three hosts ask the same
180
- questions, worded the same way, in the same order — the single most
183
+ questions, worded the same way, in the same order, the single most
181
184
  security-relevant parity property in this batch.
182
185
 
183
186
  ## Capability handling (the crux)
@@ -217,7 +220,7 @@ rather than a skipped test or a faked empty answer.
217
220
  | `14-remove-pack` | removing an installed pack deletes it; removing it again reports it is not installed |
218
221
  | `15-load-packs-engine-views` | loading packs reports every installed component and, as `unavailable`, the ones this host's engine has no view for |
219
222
  | `16-pack-trust` | a folder that arrives without consent is listed as present and never loaded; enable, disable, re-enable and remove move the device trust list; a host without the list reports the whole scenario `unsupported` |
223
+ | `17-export-linked-set` | a note and the notes it links to export as one archive with an index page; a host with no linked-set target reports `unsupported` |
220
224
 
221
- See `tmp/W11-phase3.md` for which capabilities each of the three real
222
- adapters currently declares, and which scenarios are consequently
223
- `unsupported` on every host today (a reported gap, not a corpus defect).
225
+ The capabilities an adapter declares decide which scenarios report
226
+ `unsupported` on that host. That is a reported gap and not a corpus defect.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The one place a render-time diagnostic event becomes a line on a host's
3
- * diagnostics surface (docs/integration.md's host checklist, item 9).
3
+ * diagnostics surface (docs/hosts.md's host checklist, item 9).
4
4
  *
5
5
  * Both renderers report a quiet in-page marker through their `onDiagnostic`
6
6
  * render option (`@markii/stdlib`'s `DiagnosticEvent`). The marker alone
@@ -161,6 +161,14 @@ export interface HostExportCapabilities {
161
161
  resolveTarget(suggestedFileName: string, format: HostExportFormat): Promise<string | undefined>;
162
162
  /** Only present on a host declaring `'pdf'`. The one Electron-touching seam. */
163
163
  htmlToPdf?(html: string): Promise<Uint8Array>;
164
+ /**
165
+ * Names the zip archive of a linked-set export, or resolves `undefined`
166
+ * when the user cancels. Present only on a host that offers the
167
+ * linked-set export; a host can offer single-note export without it
168
+ * (the command line tool does), so it is a member of its own rather than
169
+ * a `HostExportFormat`.
170
+ */
171
+ resolveLinkedSetTarget?(suggestedFileName: string): Promise<string | undefined>;
164
172
  }
165
173
  /**
166
174
  * The wording nouns every shared behavior's message templates interpolate
@@ -210,7 +218,7 @@ export interface HostAdapter {
210
218
  * through with no adapter wrapper at all.
211
219
  */
212
220
  readonly memento: GrantMemento;
213
- /** One diagnostics line on this host's designated surface (docs/integration.md). */
221
+ /** One diagnostics line on this host's designated surface (docs/hosts.md). */
214
222
  diagnostics(line: string): void;
215
223
  now(): number;
216
224
  readonly isolate?: HostIsolate;
@@ -5,11 +5,12 @@
5
5
  * produces. Written once so the hosts cannot drift on what a cascade is
6
6
  * or what it says.
7
7
  *
8
- * Deliberately outside `createMarkiiHost`: choosing where the archive goes
9
- * and writing it are each host's own picker and file-system business, so
10
- * this module builds the bytes and words the outcome, and the host writes
11
- * and displays. The per-host nouns (which surface holds the details) come
12
- * from `HostLabels`, never from a literal here.
8
+ * `createMarkiiHost`'s `exportLinkedSet` drives it: the host's picker
9
+ * names the archive's path (`adapter.exports.resolveLinkedSetTarget`), the
10
+ * notes are read through `adapter.readFile`, the archive is written through
11
+ * `adapter.writeFile`, and the diagnostics lines go to `adapter.diagnostics`.
12
+ * The per-host nouns (which surface holds the details) come from
13
+ * `HostLabels`, never from a literal here.
13
14
  *
14
15
  * BOUNDS ARE FIXED. The walk always uses `DEFAULT_CASCADE_MAX_DEPTH` and
15
16
  * `DEFAULT_CASCADE_MAX_NOTES`; nothing here lets a caller override them.
@@ -19,7 +20,8 @@ import type { OnDiagnostic, UnavailableComponent } from '@markii/stdlib';
19
20
  import type { CascadeLinkResolver, CascadeNoteReader, CascadeTruncation } from '../export/cascade.js';
20
21
  import type { HtmlExportEngine, ExportBodyRenderer, ExportPackStylesheet, ExportRenderInfo, StaticExportReason } from '../export/note-export.js';
21
22
  import type { EmbeddedImageReport, ExportImageReader } from '../export/image-embed.js';
22
- import type { HostLabels } from './adapter.js';
23
+ import type { HostAdapter, HostLabels } from './adapter.js';
24
+ import type { ExportLinkedSetOutcome } from './create-host.js';
23
25
  /** One note the cascade exported, for the diagnostics surface. */
24
26
  export interface CascadeExportedNote {
25
27
  /** The note's own path, in the same form the reader and resolver use. */
@@ -37,7 +39,7 @@ export interface UnreadableCascadeNote {
37
39
  }
38
40
  /** What one cascade export attempt did, for both the message and the diagnostics lines. */
39
41
  export type CascadeExportOutcome = {
40
- readonly kind: 'written';
42
+ readonly kind: 'exported';
41
43
  /** The written archive's display path. */
42
44
  readonly path: string;
43
45
  readonly bytes: number;
@@ -109,7 +111,7 @@ export declare function buildCascadeArchive(request: CascadeArchiveRequest): Pro
109
111
  * failure says what failed and points at the same surface, never at a
110
112
  * stack trace.
111
113
  */
112
- export declare function cascadeResultMessage(outcome: CascadeExportOutcome, labels: HostLabels, where?: string): string;
114
+ export declare function cascadeResultMessage(outcome: ExportLinkedSetOutcome, labels: HostLabels, where?: string): string;
113
115
  /**
114
116
  * The lines for the host's diagnostics surface for one cascade export:
115
117
  * every note reached named with the file it became, every note skipped
@@ -118,3 +120,17 @@ export declare function cascadeResultMessage(outcome: CascadeExportOutcome, labe
118
120
  * deliberately omits.
119
121
  */
120
122
  export declare function cascadeDiagnosticLines(outcome: CascadeExportOutcome): string[];
123
+ /** What `exportLinkedSetViaAdapter` needs beyond the adapter: the static engine and the request's own seams. */
124
+ export interface LinkedSetExportInputs extends Omit<CascadeArchiveRequest, 'readNote'> {
125
+ /** The suggested archive file name handed to the host's picker. */
126
+ readonly suggestedFileName: string;
127
+ }
128
+ /**
129
+ * Exports a note and the notes it links to as one archive: resolves the
130
+ * target through the adapter's picker, builds the archive reading every
131
+ * note through `adapter.readFile`, writes it through `adapter.writeFile`,
132
+ * and sends the diagnostics lines to `adapter.diagnostics`. Resolves
133
+ * `undefined` when the host has no linked-set target or its picker was
134
+ * cancelled. Never throws.
135
+ */
136
+ export declare function exportLinkedSetViaAdapter(adapter: HostAdapter, inputs: LinkedSetExportInputs): Promise<CascadeExportOutcome | undefined>;
@@ -109,7 +109,7 @@ export async function buildCascadeArchive(request) {
109
109
  * stack trace.
110
110
  */
111
111
  export function cascadeResultMessage(outcome, labels, where) {
112
- if (outcome.kind === 'failed') {
112
+ if (outcome.kind !== 'exported') {
113
113
  return `Markii: could not export this cascade. Open ${labels.diagnosticsSurface} for details.`;
114
114
  }
115
115
  const name = fileNameOf(outcome.path);
@@ -149,3 +149,63 @@ export function cascadeDiagnosticLines(outcome) {
149
149
  }
150
150
  return lines;
151
151
  }
152
+ /**
153
+ * Exports a note and the notes it links to as one archive: resolves the
154
+ * target through the adapter's picker, builds the archive reading every
155
+ * note through `adapter.readFile`, writes it through `adapter.writeFile`,
156
+ * and sends the diagnostics lines to `adapter.diagnostics`. Resolves
157
+ * `undefined` when the host has no linked-set target or its picker was
158
+ * cancelled. Never throws.
159
+ */
160
+ export async function exportLinkedSetViaAdapter(adapter, inputs) {
161
+ const resolveTarget = adapter.exports?.resolveLinkedSetTarget;
162
+ if (resolveTarget === undefined)
163
+ return undefined;
164
+ const { suggestedFileName, ...archiveRequest } = inputs;
165
+ let path;
166
+ let outcome;
167
+ try {
168
+ path = await resolveTarget.call(adapter.exports, suggestedFileName);
169
+ if (path === undefined)
170
+ return undefined;
171
+ const target = path;
172
+ const archive = await buildCascadeArchive({
173
+ ...archiveRequest,
174
+ readNote: async (notePath) => {
175
+ try {
176
+ return new TextDecoder().decode(await adapter.readFile(notePath));
177
+ }
178
+ catch {
179
+ return undefined;
180
+ }
181
+ },
182
+ });
183
+ if (archive.kind === 'failed') {
184
+ outcome = { kind: 'failed', path: target, reason: archive.reason };
185
+ }
186
+ else {
187
+ await adapter.writeFile(target, archive.bytes);
188
+ outcome = {
189
+ kind: 'exported',
190
+ path: target,
191
+ bytes: archive.bytes.byteLength,
192
+ notes: archive.notes,
193
+ unreadable: archive.unreadable,
194
+ ...(archive.truncated !== undefined
195
+ ? { truncated: archive.truncated }
196
+ : {}),
197
+ };
198
+ }
199
+ }
200
+ catch (error) {
201
+ outcome = {
202
+ kind: 'failed',
203
+ ...(path !== undefined ? { path } : {}),
204
+ reason: cascadeReasonOf(error),
205
+ };
206
+ }
207
+ for (const line of cascadeDiagnosticLines(outcome)) {
208
+ adapter.diagnostics(line);
209
+ }
210
+ return outcome;
211
+ }
@@ -35,6 +35,8 @@ export type HostScenarioAction = {
35
35
  readonly type: 'exportHtml';
36
36
  } | {
37
37
  readonly type: 'exportAnsi';
38
+ } | {
39
+ readonly type: 'exportLinkedSet';
38
40
  } | {
39
41
  readonly type: 'installPack';
40
42
  /** A directory under the scenario's own folder (e.g. `"files/ana-1.0.0"`), zipped into `.mkp` bytes by this module — never a committed binary. */
@@ -98,7 +100,7 @@ export interface LoadedHostScenario {
98
100
  export declare function loadHostScenario(name: string, baseDir?: string): LoadedHostScenario;
99
101
  /** Zips a scenario's `files/<name>` directory into `.mkp` archive bytes on the fly — this is how the corpus ships pack fixtures as plain files instead of a committed binary. */
100
102
  export declare function zipArchiveDirectory(archiveDir: string): Uint8Array;
101
- export type HostScenarioCapability = 'isolate' | 'editor' | 'packs' | 'pack-trust' | 'export-format';
103
+ export type HostScenarioCapability = 'isolate' | 'editor' | 'packs' | 'pack-trust' | 'export-format' | 'linked-set-export';
102
104
  /** The one static map from an action's type to the capability it needs. `undefined` means every host supports it (there is nothing to decline). */
103
105
  export declare const ACTION_CAPABILITY: Readonly<Record<HostScenarioAction['type'], HostScenarioCapability | undefined>>;
104
106
  /** Whether `adapter` declares the capability `action` needs. Never consults the scenario or `expected.json` — only the adapter under test. */
@@ -37,12 +37,12 @@
37
37
  * Every OTHER action runs through the real, unmocked shared behavior code.
38
38
  */
39
39
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
40
- import { dirname } from 'node:path';
40
+ import { dirname, resolve } from 'node:path';
41
41
  import { fileURLToPath } from 'node:url';
42
42
  import { createHtmlExportEngine } from '../html-engine.js';
43
43
  import { usesPackTrust } from './pack-trust-behavior.js';
44
44
  import { join, relative, sep } from 'node:path';
45
- import { zipSync } from 'fflate';
45
+ import { unzipSync, zipSync } from 'fflate';
46
46
  import { conformanceDir } from '@markii/core/corpus';
47
47
  import { createMarkiiHost, } from './create-host.js';
48
48
  import { scriptsDisabledDiagnosticLine, scriptsDisabledNotice, } from './script-execution.js';
@@ -101,6 +101,7 @@ export const ACTION_CAPABILITY = {
101
101
  run: 'isolate',
102
102
  exportHtml: 'export-format',
103
103
  exportAnsi: 'export-format',
104
+ exportLinkedSet: 'linked-set-export',
104
105
  installPack: 'packs',
105
106
  loadPacks: 'packs',
106
107
  removePack: 'packs',
@@ -136,6 +137,9 @@ export function hostDeclaresCapability(adapter, action) {
136
137
  return adapter.isolate !== undefined;
137
138
  if (capability === 'editor')
138
139
  return adapter.editor !== undefined;
140
+ if (capability === 'linked-set-export') {
141
+ return adapter.exports?.resolveLinkedSetTarget !== undefined;
142
+ }
139
143
  if (capability === 'packs') {
140
144
  if (adapter.packs === undefined)
141
145
  return false;
@@ -329,6 +333,24 @@ async function runOneAction(host, adapter, action, ctx) {
329
333
  });
330
334
  return augmentExportOutcome(outcome, ctx.writtenFiles);
331
335
  }
336
+ case 'exportLinkedSet': {
337
+ const outcome = await host.exportLinkedSet({
338
+ rootPath: ctx.notePath,
339
+ // A markdown link to a note file that exists beside the linking
340
+ // note: the same rule for every host, so the archive's contents
341
+ // do not depend on which host runs the scenario.
342
+ resolveLink: (link, fromNotePath) => {
343
+ if (link.kind !== 'markdown')
344
+ return undefined;
345
+ const target = resolve(dirname(fromNotePath), link.path);
346
+ return target.endsWith('.md') && existsSync(target)
347
+ ? target
348
+ : undefined;
349
+ },
350
+ readValues: () => ({}),
351
+ });
352
+ return augmentLinkedSetOutcome(outcome, ctx.writtenFiles);
353
+ }
332
354
  case 'installPack': {
333
355
  const archiveBytes = zipArchiveDirectory(join(ctx.scenarioDir, action.archive));
334
356
  return host.installPack({ archiveBytes, archivePath: action.archive });
@@ -361,6 +383,13 @@ async function runOneAction(host, adapter, action, ctx) {
361
383
  return host.insertComponent({ name: action.name });
362
384
  }
363
385
  }
386
+ function augmentLinkedSetOutcome(outcome, writtenFiles) {
387
+ if (outcome.kind !== 'exported')
388
+ return outcome;
389
+ const bytes = writtenFiles.get(outcome.path);
390
+ const entries = bytes === undefined ? undefined : Object.keys(unzipSync(bytes)).sort();
391
+ return { ...outcome, noteCount: outcome.notes.length, entries };
392
+ }
364
393
  function augmentExportOutcome(outcome, writtenFiles) {
365
394
  if (outcome.kind !== 'exported')
366
395
  return outcome;
@@ -457,6 +486,15 @@ export function outcomeMismatches(actual, expected) {
457
486
  mismatches.push(`render engine: expected "${String(expected['renderEngine'])}", got "${String(render?.engine)}"`);
458
487
  }
459
488
  }
489
+ if (expected['noteCount'] !== undefined &&
490
+ actualRecord['noteCount'] !== expected['noteCount']) {
491
+ mismatches.push(`noteCount: expected ${String(expected['noteCount'])}, got ${String(actualRecord['noteCount'])}`);
492
+ }
493
+ if (expected['entries'] !== undefined &&
494
+ JSON.stringify(actualRecord['entries']) !==
495
+ JSON.stringify(expected['entries'])) {
496
+ mismatches.push(`archive entries: expected ${JSON.stringify(expected['entries'])}, got ${JSON.stringify(actualRecord['entries'])}`);
497
+ }
460
498
  const content = String(actualRecord['content'] ?? '');
461
499
  for (const needle of expected['contains'] ?? []) {
462
500
  if (!content.includes(needle)) {
@@ -21,6 +21,8 @@ import { type RunViaAdapterRequest, type RunViaAdapterResult } from './run-behav
21
21
  import { type PackDiscoveryIo } from './pack-load.js';
22
22
  import type { ExportBodyRenderer, ExportPackStylesheet, ExportRenderInfo, HtmlExportEngine, StaticExportReason } from '../export/note-export.js';
23
23
  import type { EmbeddedImageReport, ExportImageReader } from '../export/image-embed.js';
24
+ import type { CascadeLinkResolver } from '../export/cascade.js';
25
+ import { type CascadeExportOutcome } from './cascade-export-behavior.js';
24
26
  import type { DiscoveredPack, SkippedPackFolder } from '../packs/discover.js';
25
27
  import { type TerminalPackScript } from './terminal-packs.js';
26
28
  import { type RemovePackOutcome } from './pack-remove.js';
@@ -28,7 +30,7 @@ import type { DisablePackOutcome, EnablePackOutcome, InstalledPacksListing } fro
28
30
  /** The shared decline shape every behavior returns when the adapter lacks the capability it needs. */
29
31
  export interface Unsupported {
30
32
  readonly kind: 'unsupported';
31
- readonly capability: 'isolate' | 'editor' | 'packs' | 'pack-trust' | 'export-format';
33
+ readonly capability: 'isolate' | 'editor' | 'packs' | 'pack-trust' | 'export-format' | 'linked-set-export';
32
34
  readonly detail?: string;
33
35
  }
34
36
  export interface OpenRequest {
@@ -94,6 +96,41 @@ export type ExportOutcome = {
94
96
  | {
95
97
  readonly kind: 'cancelled';
96
98
  } | Unsupported;
99
+ export interface ExportLinkedSetRequest {
100
+ /** The root note's path, in whatever form `adapter.readFile` and `resolveLink` understand. */
101
+ readonly rootPath: string;
102
+ /** Resolves one link in a note to the path of a note this host can export, or `undefined` to leave the link as written. */
103
+ readonly resolveLink: CascadeLinkResolver;
104
+ /** Reads one note's persisted last-run values, keyed by that note's own path. An empty object exports that note's standard empty states. */
105
+ readonly readValues: (notePath: string) => Record<string, StoredValue>;
106
+ /** The host's own render of each note's body. Omitted renders every note with the static engine, and `staticReason` says why. */
107
+ readonly renderBody?: ExportBodyRenderer;
108
+ /** Why the static engine renders when `renderBody` is omitted. Defaults to `no-packs`. */
109
+ readonly staticReason?: StaticExportReason;
110
+ /** The loaded packs' stylesheets, embedded only when `renderBody` ran. */
111
+ readonly packStylesheets?: readonly ExportPackStylesheet[];
112
+ /** How many loaded packs contributed to the render. Diagnostics only. */
113
+ readonly packCount?: number;
114
+ /** Installed pack components the static engine cannot draw. */
115
+ readonly unavailableComponents?: readonly UnavailableComponent[];
116
+ /** Receives the static engine's render diagnostics for every note. */
117
+ readonly onDiagnostic?: OnDiagnostic;
118
+ /** Builds the image reader for one note, bound to that note's own folder. Omitted embeds no images. */
119
+ readonly embedImagesFor?: (notePath: string) => ExportImageReader;
120
+ /** Hides the collapsed script marker in every exported note. Defaults to `false`. */
121
+ readonly hideScriptBlocks?: boolean;
122
+ }
123
+ export type ExportLinkedSetOutcome = Extract<CascadeExportOutcome, {
124
+ kind: 'exported';
125
+ }> | {
126
+ readonly kind: 'failed';
127
+ readonly path?: string;
128
+ readonly reason: string;
129
+ }
130
+ /** The host's picker was cancelled, so nothing was written. */
131
+ | {
132
+ readonly kind: 'cancelled';
133
+ } | Unsupported;
97
134
  export interface InstallPackRequest {
98
135
  readonly archiveBytes: Uint8Array;
99
136
  /** The archive's own path or display name, used only in wording. Defaults to a generic phrase when omitted. */
@@ -218,6 +255,8 @@ export interface MarkiiHost {
218
255
  open(request: OpenRequest): Promise<OpenOutcome>;
219
256
  run(request: RunRequest): Promise<RunOutcome>;
220
257
  exportNote(request: ExportRequest): Promise<ExportOutcome>;
258
+ /** Exports a note and the notes it links to as one zip archive of HTML files. */
259
+ exportLinkedSet(request: ExportLinkedSetRequest): Promise<ExportLinkedSetOutcome>;
221
260
  installPack(request: InstallPackRequest): Promise<InstallPackOutcome>;
222
261
  loadPacks(): Promise<LoadPacksOutcome>;
223
262
  /**
@@ -265,7 +304,5 @@ export interface MarkiiHostOptions {
265
304
  readonly packDiscoveryIo?: PackDiscoveryIo;
266
305
  /** `@markii/ansi`'s renderer, injected because this package never depends on `@markii/ansi` (a platform renderer, never a `@markii/host` dependency). A host declaring `'ansi'` in `adapter.exports.formats` without supplying this gets `Unsupported` at export time. */
267
306
  readonly renderAnsi?: (text: string) => Promise<string>;
268
- /** Composes a pack-aware render registry (webview registration, in-process evaluation, ...). Reserved for a future slice that renders through `exportNote`'s `html` path with packs; unused today, since `buildExportDocument`'s default (no `renderBody`) already produces a correct, pack-free static export. */
269
- readonly buildPackRegistration?: unknown;
270
307
  }
271
308
  export declare function createMarkiiHost(adapter: HostAdapter, options?: MarkiiHostOptions): MarkiiHost;
@@ -1,6 +1,8 @@
1
1
  import { runViaAdapter, } from './run-behavior.js';
2
2
  import { installPackDiagnosticLines, installPackFromArchive, } from './pack-install.js';
3
3
  import { loadPacksViaAdapter, packDiscoveryIoFromAdapter, } from './pack-load.js';
4
+ import { exportBaseName } from '../export/note-export.js';
5
+ import { exportLinkedSetViaAdapter, } from './cascade-export-behavior.js';
4
6
  import { buildComponentCatalog } from '../insert/component-catalog.js';
5
7
  import { formatPackDiagnosticLines, skippedPackFolderLine, } from '../packs/pack-diagnostics.js';
6
8
  import { readTerminalPackScripts, } from './terminal-packs.js';
@@ -136,6 +138,24 @@ export function createMarkiiHost(adapter, options = {}) {
136
138
  images: outcome.images,
137
139
  };
138
140
  },
141
+ async exportLinkedSet(request) {
142
+ if (adapter.exports?.resolveLinkedSetTarget === undefined) {
143
+ return unsupported(adapter, 'linked-set-export', `the linked-set export is not available on ${adapter.id}.`);
144
+ }
145
+ if (options.htmlEngine === undefined) {
146
+ return unsupported(adapter, 'linked-set-export', 'no HTML export engine was supplied, so the linked-set export cannot render.');
147
+ }
148
+ const outcome = await exportLinkedSetViaAdapter(adapter, {
149
+ ...request,
150
+ engine: options.htmlEngine,
151
+ suggestedFileName: `${exportBaseName(request.rootPath)}.zip`,
152
+ });
153
+ if (outcome === undefined) {
154
+ adapter.diagnostics(`linked-set export declined: no archive target was chosen on ${adapter.id}.`);
155
+ return { kind: 'cancelled' };
156
+ }
157
+ return outcome;
158
+ },
139
159
  async installPack(request) {
140
160
  if (!adapter.packs?.installRoot) {
141
161
  return unsupported(adapter, 'packs', 'this host has no pack-install destination configured.');
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The shared pack-trust behavior (docs/packs.md, docs/integration.md's host
2
+ * The shared pack-trust behavior (docs/packs.md, docs/hosts.md's host
3
3
  * checklist). A host that declares `HostPackSource.trust: 'device-list'`
4
4
  * loads an installed pack only when this device's trust list names its
5
5
  * namespace. A folder that sits in the install root without being named
@@ -74,6 +74,10 @@ export interface RunViaAdapterRequest {
74
74
  /** A run's result plus the `RunTrace` it just recorded, so a host can show its in-note marker from the same value the shared path persisted. */
75
75
  export type RunViaAdapterResult = RunOnceResult & {
76
76
  readonly trace: RunTrace;
77
+ /** How many of this run's values came back with the value store's `fresh` status. */
78
+ readonly freshCount: number;
79
+ /** How many of this run's values came back with the value store's `error` status. */
80
+ readonly errorCount: number;
77
81
  };
78
82
  /**
79
83
  * Runs one manual, auto, or scheduled pass of `request.text`'s scripts,
@@ -185,5 +185,13 @@ export async function runViaAdapter(adapter, request, overrideSpawnRun) {
185
185
  // must never turn a successful (or already-reported) run into a thrown
186
186
  // error.
187
187
  await writeLastRunTrace(adapter.memento, request.documentKey, trace).then(undefined, () => { });
188
- return { ...result, trace };
188
+ let freshCount = 0;
189
+ let errorCount = 0;
190
+ for (const value of Object.values(result.values)) {
191
+ if (value.status === 'fresh')
192
+ freshCount += 1;
193
+ else if (value.status === 'error')
194
+ errorCount += 1;
195
+ }
196
+ return { ...result, trace, freshCount, errorCount };
189
197
  }
@@ -6,7 +6,7 @@
6
6
  * discovers the authorized packs and reads each one's `terminal.js` text
7
7
  * through this module; the host then hands the text to the engine's own
8
8
  * `loadTerminalPackScript`. Everything here is the one home of the wording
9
- * for the failures in between (docs/integration.md's "clean is not silent":
9
+ * for the failures in between (docs/hosts.md's "clean is not silent":
10
10
  * a failure reaches the diagnostics surface and the note shows the pack's
11
11
  * components as a failed pack).
12
12
  *
package/dist/index.d.ts CHANGED
@@ -25,6 +25,9 @@ export type { DisablePackOutcome, EnabledPack, EnablePackOutcome, InstalledPacks
25
25
  export { disablePackMessage, enablePackMessage, } from './host/pack-trust-behavior.js';
26
26
  export { removePackMessage } from './host/pack-remove.js';
27
27
  export { exportResultMessage } from './host/export-behavior.js';
28
+ export { cascadeResultMessage } from './host/cascade-export-behavior.js';
29
+ export type { CascadeLinkResolver, CascadeTruncation, } from './export/cascade.js';
30
+ export type { CascadeExportedNote, UnreadableCascadeNote, } from './host/cascade-export-behavior.js';
28
31
  export { createMarkiiHost } from './host/create-host.js';
29
- export type { CompleteOutcome, EditorPositionRequest, ExportOutcome, ExportRequest, HoverOutcome, InsertComponentRequest, InstallPackOutcome, InstallPackRequest, LoadPacksOutcome, LoadTerminalPacksOutcome, MarkiiHost, MarkiiHostOptions, OpenOutcome, OpenRequest, RemovePackResult, DisablePackResult, EnablePackResult, InstalledPacksOutcome, RunOutcome, RunRequest, Unsupported, } from './host/create-host.js';
32
+ export type { CompleteOutcome, EditorPositionRequest, ExportLinkedSetOutcome, ExportLinkedSetRequest, ExportOutcome, ExportRequest, HoverOutcome, InsertComponentRequest, InstallPackOutcome, InstallPackRequest, LoadPacksOutcome, LoadTerminalPacksOutcome, MarkiiHost, MarkiiHostOptions, OpenOutcome, OpenRequest, RemovePackResult, DisablePackResult, EnablePackResult, InstalledPacksOutcome, RunOutcome, RunRequest, Unsupported, } from './host/create-host.js';
30
33
  export { netRefusal } from './run/net-refusal.js';
package/dist/index.js CHANGED
@@ -21,5 +21,6 @@ export { installPackMessage } from './host/pack-install.js';
21
21
  export { disablePackMessage, enablePackMessage, } from './host/pack-trust-behavior.js';
22
22
  export { removePackMessage } from './host/pack-remove.js';
23
23
  export { exportResultMessage } from './host/export-behavior.js';
24
+ export { cascadeResultMessage } from './host/cascade-export-behavior.js';
24
25
  export { createMarkiiHost } from './host/create-host.js';
25
26
  export { netRefusal } from './run/net-refusal.js';
@@ -10,9 +10,7 @@ export { readLastRunTrace } from './run/run-trace.js';
10
10
  export { staleValuesForRehydration } from './run/stale-values.js';
11
11
  export { MARK_EXTENSION, buildNoteHtmlExport, composeNoteHtmlExport, noteHasScripts, exportBaseName, exportedSiblingPath, } from './export/note-export.js';
12
12
  export { EMPTY_IMAGE_REPORT, MAX_EMBEDDED_IMAGE_BYTES, embedImagesInHtml, } from './export/image-embed.js';
13
- export type { CascadeLinkResolver, CascadeNoteReader, } from './export/cascade.js';
14
- export type { CascadeArchiveRequest, CascadeExportOutcome, } from './host/cascade-export-behavior.js';
15
- export { buildCascadeArchive, cascadeDiagnosticLines, cascadeReasonOf, cascadeResultMessage, } from './host/cascade-export-behavior.js';
13
+ export { cascadeDiagnosticLines } from './host/cascade-export-behavior.js';
16
14
  export { componentsWithoutEngineView, declaredPackComponents, installedNamespaces, } from './packs/discover.js';
17
15
  export { loadPackModules } from './packs/pack-scripts.js';
18
16
  export { relativePackEntries, resolvePackPaths } from './packs/pack-paths.js';
package/dist/internal.js CHANGED
@@ -14,7 +14,7 @@ export { readLastRunTrace } from './run/run-trace.js';
14
14
  export { staleValuesForRehydration } from './run/stale-values.js';
15
15
  export { MARK_EXTENSION, buildNoteHtmlExport, composeNoteHtmlExport, noteHasScripts, exportBaseName, exportedSiblingPath, } from './export/note-export.js';
16
16
  export { EMPTY_IMAGE_REPORT, MAX_EMBEDDED_IMAGE_BYTES, embedImagesInHtml, } from './export/image-embed.js';
17
- export { buildCascadeArchive, cascadeDiagnosticLines, cascadeReasonOf, cascadeResultMessage, } from './host/cascade-export-behavior.js';
17
+ export { cascadeDiagnosticLines } from './host/cascade-export-behavior.js';
18
18
  export { componentsWithoutEngineView, declaredPackComponents, installedNamespaces, } from './packs/discover.js';
19
19
  export { loadPackModules } from './packs/pack-scripts.js';
20
20
  export { relativePackEntries, resolvePackPaths } from './packs/pack-paths.js';
@@ -141,7 +141,7 @@ export declare function manifestNetHosts(manifest: BundleManifest): string[];
141
141
  *
142
142
  * A mismatch between the two is not silently absorbed: this pure function
143
143
  * returns one diagnostics line per mismatched host, in both directions, for
144
- * the host's diagnostics surface (docs/integration.md) — never the rendered
144
+ * the host's diagnostics surface (docs/hosts.md) — never the rendered
145
145
  * page. Wording lives here, the one place both apps' adapters read it from,
146
146
  * so it can never drift between VS Code's output channel and Obsidian's
147
147
  * console/notice surface.
@@ -142,7 +142,7 @@ export function manifestNetHosts(manifest) {
142
142
  *
143
143
  * A mismatch between the two is not silently absorbed: this pure function
144
144
  * returns one diagnostics line per mismatched host, in both directions, for
145
- * the host's diagnostics surface (docs/integration.md) — never the rendered
145
+ * the host's diagnostics surface (docs/hosts.md) — never the rendered
146
146
  * page. Wording lives here, the one place both apps' adapters read it from,
147
147
  * so it can never drift between VS Code's output channel and Obsidian's
148
148
  * console/notice surface.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markii/host",
3
- "version": "0.17.0-next.4",
3
+ "version": "0.17.0-next.5",
4
4
  "description": "Shared, host-neutral script-running layer for Markii hosts: tier gating, the grant model, address pinning, and the terminatable-isolate watchdog, plus the HostAdapter contract and createMarkiiHost. Entries: the neutral main entry, /internal (no semver promise), /node, /html-engine, /browser, /browser-worker-classic, /conformance.",
5
5
  "keywords": [
6
6
  "markdown",
@@ -71,20 +71,20 @@
71
71
  "build:dist": "rm -rf dist && tsc -p tsconfig.build.json && node scripts/build-dist-assets.mjs"
72
72
  },
73
73
  "dependencies": {
74
- "@markii/bundle": "^0.17.0-next.4",
75
- "@markii/core": "^0.17.0-next.4",
76
- "@markii/lua": "^0.17.0-next.4",
77
- "@markii/pack": "^0.17.0-next.4",
78
- "@markii/runtime": "^0.17.0-next.4",
79
- "@markii/stdlib": "^0.17.0-next.4",
74
+ "@markii/bundle": "^0.17.0-next.5",
75
+ "@markii/core": "^0.17.0-next.5",
76
+ "@markii/lua": "^0.17.0-next.5",
77
+ "@markii/pack": "^0.17.0-next.5",
78
+ "@markii/runtime": "^0.17.0-next.5",
79
+ "@markii/stdlib": "^0.17.0-next.5",
80
80
  "fflate": "^0.8.3"
81
81
  },
82
82
  "files": [
83
83
  "dist"
84
84
  ],
85
85
  "peerDependencies": {
86
- "@markii/html": "^0.17.0-next.4",
87
- "@markii/react": "^0.17.0-next.4"
86
+ "@markii/html": "^0.17.0-next.5",
87
+ "@markii/react": "^0.17.0-next.5"
88
88
  },
89
89
  "peerDependenciesMeta": {
90
90
  "@markii/html": {
@@ -95,8 +95,8 @@
95
95
  }
96
96
  },
97
97
  "devDependencies": {
98
- "@markii/html": "^0.17.0-next.4",
99
- "@markii/react": "^0.17.0-next.4",
98
+ "@markii/html": "^0.17.0-next.5",
99
+ "@markii/react": "^0.17.0-next.5",
100
100
  "esbuild": "^0.28.2"
101
101
  },
102
102
  "publishConfig": {