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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +10 -2
  2. package/dist/browser-worker.classic.js +640 -0
  3. package/dist/browser-worker.js +40 -40
  4. package/dist/browser.d.ts +7 -3
  5. package/dist/browser.js +16 -4
  6. package/dist/conformance/host/05-export-html-pack-directive/expected.json +2 -0
  7. package/dist/conformance/host/06-install-pack-then-render/expected.json +6 -1
  8. package/dist/conformance/host/README.md +4 -2
  9. package/dist/export/note-export.d.ts +1 -1
  10. package/dist/host/adapter.d.ts +22 -25
  11. package/dist/host/conformance-runner.d.ts +1 -1
  12. package/dist/host/conformance-runner.js +21 -11
  13. package/dist/host/create-host.d.ts +29 -11
  14. package/dist/host/create-host.js +34 -23
  15. package/dist/host/editor-behavior.d.ts +2 -6
  16. package/dist/host/editor-behavior.js +5 -9
  17. package/dist/host/export-behavior.d.ts +6 -7
  18. package/dist/host/export-behavior.js +1 -1
  19. package/dist/host/labels.d.ts +5 -15
  20. package/dist/host/labels.js +8 -22
  21. package/dist/host/pack-archive.d.ts +4 -10
  22. package/dist/host/pack-archive.js +12 -21
  23. package/dist/host/pack-install.d.ts +11 -23
  24. package/dist/host/pack-install.js +26 -23
  25. package/dist/host/pack-load.d.ts +1 -2
  26. package/dist/host/pack-trust-behavior.js +1 -1
  27. package/dist/host/refresh-interval.d.ts +1 -3
  28. package/dist/host/refresh-interval.js +1 -3
  29. package/dist/host/run-behavior.d.ts +3 -4
  30. package/dist/host/run-behavior.js +23 -2
  31. package/dist/host/script-execution.d.ts +1 -1
  32. package/dist/html-engine.js +4 -4
  33. package/dist/index.d.ts +15 -80
  34. package/dist/index.js +18 -63
  35. package/dist/internal.d.ts +30 -0
  36. package/dist/internal.js +31 -0
  37. package/dist/node-fs.d.ts +0 -3
  38. package/dist/node-fs.js +1 -21
  39. package/dist/node-worker.d.ts +8 -0
  40. package/dist/node-worker.js +20678 -0
  41. package/dist/node.d.ts +3 -3
  42. package/dist/node.js +5 -4
  43. package/dist/packs/discover.d.ts +2 -0
  44. package/dist/packs/discover.js +2 -1
  45. package/dist/packs/pack-diagnostics.d.ts +6 -8
  46. package/dist/packs/pack-diagnostics.js +6 -8
  47. package/dist/packs/pack-paths.js +1 -2
  48. package/dist/packs/pack-render-registry.d.ts +9 -10
  49. package/dist/run/browser-isolate.d.ts +5 -5
  50. package/dist/run/browser-isolate.js +5 -1
  51. package/dist/run/isolate.d.ts +3 -1
  52. package/dist/run/net-bridge.d.ts +5 -5
  53. package/dist/run/net-bridge.js +7 -5
  54. package/dist/run/net-pinning.d.ts +2 -0
  55. package/dist/run/net-pinning.js +17 -3
  56. package/dist/run/net-provider.d.ts +3 -1
  57. package/dist/run/net-provider.js +40 -15
  58. package/dist/run/net-refusal.d.ts +4 -0
  59. package/dist/run/net-refusal.js +24 -0
  60. package/dist/run/node-isolate.d.ts +13 -21
  61. package/dist/run/node-isolate.js +31 -80
  62. package/dist/run/run-flow.d.ts +1 -2
  63. package/dist/run/run-host.d.ts +3 -3
  64. package/dist/run/worker-entry-browser.d.ts +24 -4
  65. package/dist/run/worker-entry-browser.js +99 -4
  66. package/dist/run/worker-entry.js +2 -0
  67. package/package.json +31 -12
package/dist/browser.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { NetProvider } from '@markii/lua';
2
2
  import type { HostIsolate } from './host/adapter.js';
3
3
  /** Builds the provider for one run from the job's host allowlist, byte cap and pin policy (the policy is meaningless in a browser and ignored). */
4
- export type BrowserNetProviderFactory = (netAllowlist: string[], maxFetchBytes: number, netPolicy: unknown) => NetProvider;
4
+ export type BrowserNetProviderFactory = (netAllowlist: string[], maxFetchBytes: number, netPolicy: unknown,
5
+ /** Aborted when the isolate is killed or disposed. A provider cancels its in-flight requests on it. */
6
+ signal?: AbortSignal) => NetProvider;
5
7
  export interface FetchNetProviderOptions {
6
8
  /**
7
9
  * The origins (`https://api.example.com`, `http://127.0.0.1:8080`) a
@@ -9,7 +11,7 @@ export interface FetchNetProviderOptions {
9
11
  * every request. Entries that do not parse as an http(s) URL are ignored.
10
12
  */
11
13
  allowedOrigins?: readonly string[];
12
- /** How a policy refusal is built. Defaults to a plain `Error`; the isolate bridge marks every refusal as a denial on the wire either way. */
14
+ /** How a policy refusal is built. Defaults to a plain `Error`. The provider marks every refusal it throws, so the isolate bridge sends it as a denial. A transport failure is never marked. */
13
15
  denial?: (message: string) => Error;
14
16
  /** The `fetch` to use. Defaults to the global one; injectable for tests. */
15
17
  fetch?: typeof fetch;
@@ -33,7 +35,9 @@ export interface FetchNetProviderOptions {
33
35
  * manual redirect's target, so it cannot be checked against the allowlist,
34
36
  * and a redirect is therefore a denial.
35
37
  *
36
- * Returns a factory because the byte cap arrives per job.
38
+ * Returns a factory because the byte cap arrives per job. Every request
39
+ * carries the run's `AbortSignal`, so killing the isolate cancels requests
40
+ * still in flight.
37
41
  */
38
42
  export declare function createFetchNetProvider(options?: FetchNetProviderOptions): BrowserNetProviderFactory;
39
43
  export interface BrowserHostIsolateOptions {
package/dist/browser.js CHANGED
@@ -14,6 +14,7 @@
14
14
  // `new URL(..., import.meta.url)` references below resolve in a consumer's
15
15
  // bundler without configuration where that bundler treats them as assets.
16
16
  import { createBrowserIsolate } from './run/browser-isolate.js';
17
+ import { netRefusal } from './run/net-refusal.js';
17
18
  function originOf(entry) {
18
19
  try {
19
20
  const url = new URL(entry);
@@ -74,14 +75,19 @@ async function readCapped(response, maxBytes, denial) {
74
75
  * manual redirect's target, so it cannot be checked against the allowlist,
75
76
  * and a redirect is therefore a denial.
76
77
  *
77
- * Returns a factory because the byte cap arrives per job.
78
+ * Returns a factory because the byte cap arrives per job. Every request
79
+ * carries the run's `AbortSignal`, so killing the isolate cancels requests
80
+ * still in flight.
78
81
  */
79
82
  export function createFetchNetProvider(options = {}) {
80
83
  const allowed = new Set((options.allowedOrigins ?? [])
81
84
  .map(originOf)
82
85
  .filter((origin) => origin !== undefined));
83
- const denial = options.denial ?? ((message) => new Error(message));
84
- return (netAllowlist, maxFetchBytes) => {
86
+ const build = options.denial ?? ((message) => new Error(message));
87
+ // Every policy refusal is marked so the net bridge can tell it from a
88
+ // transport failure (a plain `Error`).
89
+ const denial = (message) => netRefusal(build(message));
90
+ return (netAllowlist, maxFetchBytes, _policy, signal) => {
85
91
  // The run's granted hosts, re-checked here on the page side: the worker
86
92
  // checks them too, but the worker is the isolate a hostile script runs
87
93
  // in, so the page is the boundary (the same rule the Node provider
@@ -107,6 +113,8 @@ export function createFetchNetProvider(options = {}) {
107
113
  if (!allowed.has(url.origin)) {
108
114
  throw denial(`origin "${url.origin}" is not on the host's allowlist`);
109
115
  }
116
+ if (signal?.aborted)
117
+ throw new Error('the run has ended');
110
118
  const doFetch = options.fetch ?? fetch;
111
119
  let response;
112
120
  try {
@@ -118,10 +126,14 @@ export function createFetchNetProvider(options = {}) {
118
126
  redirect: 'manual',
119
127
  credentials: 'omit',
120
128
  referrerPolicy: 'no-referrer',
129
+ ...(signal !== undefined ? { signal } : {}),
121
130
  });
122
131
  }
123
132
  catch (error) {
124
- throw denial(`request failed: ${error instanceof Error ? error.message : String(error)}`);
133
+ // A transport failure (network down, connection refused, CORS,
134
+ // abort): an ordinary runtime failure with the reason, not a policy
135
+ // refusal.
136
+ throw new Error(`request failed: ${error instanceof Error ? error.message : String(error)}`);
125
137
  }
126
138
  if (response.type === 'opaqueredirect') {
127
139
  throw denial('redirects are not followed in a browser');
@@ -2,6 +2,8 @@
2
2
  "outcomes": [
3
3
  {
4
4
  "kind": "exported",
5
+ "valueCount": 0,
6
+ "renderEngine": "static",
5
7
  "contains": ["mk-unknown", "ana_timeline"],
6
8
  "excludes": ["<script"]
7
9
  }
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "outcomes": [
3
3
  { "kind": "opened" },
4
- { "kind": "installed", "namespace": "ana", "replaced": false },
4
+ {
5
+ "kind": "installed",
6
+ "namespace": "ana",
7
+ "replaced": false,
8
+ "installedDirName": "ana"
9
+ },
5
10
  { "kind": "loaded", "namespaces": ["ana"] },
6
11
  { "kind": "installed", "namespace": "ana", "replaced": true }
7
12
  ],
@@ -138,8 +138,10 @@ coarse, matching each `kind`:
138
138
  compares the recorded run trace's `ok` flag.
139
139
  - `exported`: `contains`/`excludes` are substrings checked against the
140
140
  written file's content, never the whole document — the render corpora
141
- already pin bytes.
142
- - `installed`: `namespace` and `replaced`.
141
+ already pin bytes. `valueCount` is how many last-run values were baked
142
+ in, and `renderEngine` (`static` or `react`) is which engine drew the body.
143
+ - `installed`: `namespace` and `replaced`, and `installedDirName`, the name
144
+ of the folder the pack was unzipped into.
143
145
  - `declined`: `step` (`'consent'`/`'replace'`, or absent for the
144
146
  scripts-disabled synthetic outcome) and `reasonContains`, a substring of
145
147
  the user-facing reason.
@@ -71,7 +71,7 @@ export declare const EXPORT_HIDE_SCRIPT_BLOCKS_CLASS = "mk-export--hide-scripts"
71
71
  export declare const EXPORT_PAGE_CSS = "\nbody {\n margin: 0;\n background: #fff;\n}\n.doc {\n box-sizing: border-box;\n max-width: 46rem;\n margin: 0 auto;\n padding: 2.5rem 1.5rem 4rem;\n}\n.doc.mk-export--hide-scripts .mk-script {\n display: none;\n}\n@page {\n margin: 2cm;\n}\n@media print {\n .doc {\n max-width: none;\n margin: 0;\n padding: 0;\n }\n .doc .mk-card,\n .doc .mk-callout,\n .doc .mk-stat,\n .doc .mk-figure,\n .doc .mk-unknown,\n .doc table {\n break-inside: avoid;\n }\n /* A collapsed `details` prints as its summary alone, which loses the\n content the author wrote. On paper there is nothing to expand, so\n every disclosure prints open. */\n .doc details > *:not(summary) {\n display: block;\n }\n .doc .mk-script,\n .doc .mk-preview__run-marker {\n display: none;\n }\n}\n";
72
72
  /**
73
73
  * The static HTML engine, injected so this module imports no renderer.
74
- * A host passes `createHtmlExportEngine()` from `@markii/host/node`, which
74
+ * A host passes `createHtmlExportEngine()` from `@markii/host/html-engine`, which
75
75
  * wraps `@markii/html`; a test may pass a stub.
76
76
  */
77
77
  export interface HtmlExportEngine {
@@ -3,8 +3,7 @@
3
3
  * the CLI, or a future one) provides so `createMarkiiHost` can run every
4
4
  * shared behavior once. Nothing in this file names a `vscode`, `obsidian`,
5
5
  * Electron or TTY type — an adapter is built from injected dependencies,
6
- * so a test can construct the REAL adapter over fakes (batch 11's
7
- * `tmp/W11-adapter-design.md`, section 2).
6
+ * so a test can construct the REAL adapter over fakes.
8
7
  *
9
8
  * A capability group (`isolate`, `editor`, `packs`, `exports`) is absent
10
9
  * when a host does not offer it. `createMarkiiHost` never assumes one is
@@ -23,9 +22,7 @@ export interface HostDirEntry {
23
22
  }
24
23
  /**
25
24
  * One yes/no question. Every grant, consent, and replace prompt in the
26
- * whole app surface routes through this single method (survey finding A1:
27
- * nine near-identical prompt adapters across three apps collapse to one
28
- * call each). `message`, `allowLabel`, and `denyLabel` are already fully
25
+ * whole app surface routes through this single method. `message`, `allowLabel`, and `denyLabel` are already fully
29
26
  * built by `@markii/host`; the adapter only has to show them.
30
27
  */
31
28
  export interface HostPromptRequest {
@@ -43,7 +40,7 @@ export interface HostPromptRequest {
43
40
  }
44
41
  /**
45
42
  * A discriminated union: `workerPath` and `workerBytes` are mutually
46
- * exclusive per host (survey section C). A Node host (VS Code, the CLI)
43
+ * exclusive per host. A Node host (VS Code, the CLI)
47
44
  * gives a synchronous path lookup; a host whose runtime cannot create
48
45
  * worker threads (an Electron renderer, which is what the Obsidian plugin
49
46
  * runs in) gives an async, disposable Web Worker spawner instead.
@@ -51,15 +48,14 @@ export interface HostPromptRequest {
51
48
  export type HostIsolate = {
52
49
  readonly kind: 'node';
53
50
  /**
54
- * The worker entry on disk. A host with a packaged worker resolves it
55
- * here; a development or test run falls back to
56
- * `defaultWorkerPath()` from `@markii/host/node`, in the host's own
57
- * adapter (the shared run path has no Node dependency to do it).
51
+ * The worker entry on disk. A host with its own bundled worker
52
+ * resolves it here. `createNodeHostIsolate()` from
53
+ * `@markii/host/node` supplies the packaged one.
58
54
  */
59
55
  workerPath(): string;
60
56
  /**
61
- * Creates the worker-thread isolate. Always `workerThreadIsolate`
62
- * from `@markii/host/node`; injected so the shared run path imports
57
+ * Creates the worker-thread isolate: `workerThreadIsolate` from
58
+ * `@markii/host/node`. Injected so the shared run path imports
63
59
  * nothing from `node:*`.
64
60
  */
65
61
  readonly spawnIsolate: IsolateSpawner;
@@ -106,7 +102,7 @@ export interface HostEditor {
106
102
  }
107
103
  /**
108
104
  * Pack POLICY (which folders are even candidates) is irreducibly
109
- * host-specific (survey section D); everything after the list exists is
105
+ * host-specific; everything after the list exists is
110
106
  * already shared in `../packs/discover.ts` and friends. The adapter
111
107
  * supplies only the list and the install destination.
112
108
  */
@@ -124,12 +120,13 @@ export interface HostPackSource {
124
120
  * `'device-list'`: a folder in `installRoot` loads only when the trust
125
121
  * list in the adapter's `memento` names its namespace, and the host
126
122
  * gains `installedPacks`, `enablePack` and `disablePack`. An install
127
- * trusts the pack it installs. Requires `installRoot`. `'none'` (or
128
- * absent): every folder this host's own policy authorizes loads, which
129
- * is right for a host that trusts its own install store (VS Code, the
130
- * CLI). A host states its choice explicitly.
123
+ * trusts the pack it installs. Requires `installRoot`. `'none'`: every
124
+ * folder this host's own policy authorizes loads, which is right for a
125
+ * host that trusts its own install store (VS Code, the CLI). A web host
126
+ * that installs packs from users picks `'device-list'`. Required: a
127
+ * host states its choice explicitly.
131
128
  */
132
- readonly trust?: 'device-list' | 'none';
129
+ readonly trust: 'device-list' | 'none';
133
130
  /**
134
131
  * Folders this host currently AUTHORIZES. Under `trust: 'device-list'`
135
132
  * these are the folders beyond the install root (the host adds the
@@ -153,7 +150,7 @@ export interface HostPackSource {
153
150
  * `authorizedFolders` names.
154
151
  */
155
152
  bundledPacks?(): readonly DiscoveredPack[];
156
- /** Removes an installed pack folder. Required when `installRoot` is present. */
153
+ /** Removes an installed pack folder and everything under it. Required when `installRoot` is present: a replacing install clears the old folder through it, and removing a pack uses it. */
157
154
  removeFolder?(path: string): Promise<void>;
158
155
  }
159
156
  /** Every export format any host offers today. */
@@ -167,10 +164,8 @@ export interface HostExportCapabilities {
167
164
  }
168
165
  /**
169
166
  * The wording nouns every shared behavior's message templates interpolate
170
- * (`./labels.ts`). This is the one answer to the survey's wording-drift
171
- * risk: near-identical copy across the three apps collapses into one
172
- * template plus these per-host nouns, instead of a per-host
173
- * reimplementation of the sentence itself.
167
+ * (`./labels.ts`). One template plus these per-host nouns
168
+ * replaces a per-host copy of each sentence.
174
169
  */
175
170
  export interface HostLabels {
176
171
  /** "extension" | "plugin" | "command line tool" */
@@ -196,13 +191,15 @@ export interface HostLabels {
196
191
  * Everything an adapter provides. An adapter is constructible from
197
192
  * injected dependencies (no module-scope global reads), which is what
198
193
  * lets a conformance test build the REAL adapter over a fake editor
199
- * (batch 11 Phase 3).
194
+ *.
200
195
  */
201
196
  export interface HostAdapter {
202
- readonly id: 'vscode' | 'obsidian' | 'cli' | (string & {});
197
+ /** A short name for the host, used in diagnostics wording. */
198
+ readonly id: string;
203
199
  readonly labels: HostLabels;
204
200
  readFile(path: string): Promise<Uint8Array>;
205
201
  exists(path: string): Promise<boolean>;
202
+ /** Writes `bytes` to `path` and creates any missing parent folder first. Exports and pack installs both write through it. */
206
203
  writeFile(path: string, bytes: Uint8Array): Promise<void>;
207
204
  /** A missing folder resolves to an empty list — never throws, matching the existing `PackDirectoryLister` contract. */
208
205
  listFolder(path: string): Promise<readonly HostDirEntry[]>;
@@ -110,7 +110,7 @@ export declare function hostDeclaresCapability(adapter: HostAdapter, action: Hos
110
110
  * `next()` THROWS once exhausted rather than returning a default: an
111
111
  * unanswered prompt means the scenario and the implementation disagree
112
112
  * about how many questions get asked, which must never be silently read
113
- * as a deny (`tmp/W11-corpus-design.md`, "The prompt policy").
113
+ * as a deny.
114
114
  */
115
115
  export declare function createAnswerQueue(scenario: HostScenario): {
116
116
  next(): boolean;
@@ -1,6 +1,5 @@
1
1
  /**
2
- * Executes one `conformance/host/<scenario>` case (batch 11 Phase 3,
3
- * `tmp/W11-corpus-design.md`) against any real `HostAdapter`, so a single
2
+ * Executes one `conformance/host/<scenario>` case against any real `HostAdapter`, so a single
4
3
  * plain-data corpus proves the three apps' adapters behave the same.
5
4
  *
6
5
  * `conformance/host/` itself stays plain data (no TypeScript, matching the
@@ -26,8 +25,7 @@
26
25
  * pre-check here, over the same shared functions and the SAME
27
26
  * `adapter.labels` a real call site would use, so the scenario proves
28
27
  * wording parity across hosts without inventing a capability the contract
29
- * does not have. See `tmp/W11-phase3.md` for why this is a reported
30
- * finding, not a silent workaround.
28
+ * does not have. The scenario `10-scripts-disabled` holds the behavior.
31
29
  *
32
30
  * SIMULATED EXECUTION. A `run` action's `simulate` field (plain data) tells
33
31
  * this module what the isolate would have produced, instead of spawning a
@@ -42,7 +40,6 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
42
40
  import { dirname } from 'node:path';
43
41
  import { fileURLToPath } from 'node:url';
44
42
  import { createHtmlExportEngine } from '../html-engine.js';
45
- import { createNodeArchiveExtractFs } from '../node-fs.js';
46
43
  import { usesPackTrust } from './pack-trust-behavior.js';
47
44
  import { join, relative, sep } from 'node:path';
48
45
  import { zipSync } from 'fflate';
@@ -142,10 +139,7 @@ export function hostDeclaresCapability(adapter, action) {
142
139
  if (capability === 'packs') {
143
140
  if (adapter.packs === undefined)
144
141
  return false;
145
- if (action.type === 'installPack') {
146
- return adapter.packs.installRoot !== undefined;
147
- }
148
- if (action.type === 'removePack') {
142
+ if (action.type === 'installPack' || action.type === 'removePack') {
149
143
  return (adapter.packs.installRoot !== undefined &&
150
144
  adapter.packs.removeFolder !== undefined);
151
145
  }
@@ -162,7 +156,7 @@ export function hostDeclaresCapability(adapter, action) {
162
156
  * `next()` THROWS once exhausted rather than returning a default: an
163
157
  * unanswered prompt means the scenario and the implementation disagree
164
158
  * about how many questions get asked, which must never be silently read
165
- * as a deny (`tmp/W11-corpus-design.md`, "The prompt policy").
159
+ * as a deny.
166
160
  */
167
161
  export function createAnswerQueue(scenario) {
168
162
  const answers = [];
@@ -264,7 +258,6 @@ export async function runHostScenario(adapter, scenario, noteText, scenarioDir,
264
258
  // The runner is Node-only test tooling, so it supplies the real Node
265
259
  // implementations of the seams the host layer takes by injection.
266
260
  htmlEngine: createHtmlExportEngine(),
267
- archiveExtractFs: createNodeArchiveExtractFs(),
268
261
  spawnRun: (spawnOptions) => Promise.resolve(simulatedRunResult(currentSimulation, spawnOptions)),
269
262
  ...(options.renderAnsi ? { renderAnsi: options.renderAnsi } : {}),
270
263
  });
@@ -454,6 +447,16 @@ export function outcomeMismatches(actual, expected) {
454
447
  break;
455
448
  }
456
449
  case 'exported': {
450
+ if (expected['valueCount'] !== undefined &&
451
+ actualRecord['valueCount'] !== expected['valueCount']) {
452
+ mismatches.push(`valueCount: expected ${String(expected['valueCount'])}, got ${String(actualRecord['valueCount'])}`);
453
+ }
454
+ if (expected['renderEngine'] !== undefined) {
455
+ const render = actualRecord['render'];
456
+ if (render?.engine !== expected['renderEngine']) {
457
+ mismatches.push(`render engine: expected "${String(expected['renderEngine'])}", got "${String(render?.engine)}"`);
458
+ }
459
+ }
457
460
  const content = String(actualRecord['content'] ?? '');
458
461
  for (const needle of expected['contains'] ?? []) {
459
462
  if (!content.includes(needle)) {
@@ -468,6 +471,13 @@ export function outcomeMismatches(actual, expected) {
468
471
  break;
469
472
  }
470
473
  case 'installed': {
474
+ if (expected['installedDirName'] !== undefined) {
475
+ const dir = String(actualRecord['installedDir'] ?? '');
476
+ const name = dir.split(/[/\\]/).pop();
477
+ if (name !== expected['installedDirName']) {
478
+ mismatches.push(`installedDir: expected a folder named "${String(expected['installedDirName'])}", got "${dir}"`);
479
+ }
480
+ }
471
481
  if (expected['namespace'] !== undefined &&
472
482
  actualRecord['namespace'] !== expected['namespace']) {
473
483
  mismatches.push(`namespace: expected "${String(expected['namespace'])}", got "${String(actualRecord['namespace'])}"`);
@@ -3,13 +3,11 @@
3
3
  * its own run/export/pack/editor wiring. Everything it touches (files, prompts,
4
4
  * the isolate) arrives through the adapter, so the main entry stays
5
5
  * environment-neutral. VS Code's webview imports only the pure pieces and
6
- * never calls this function (`tmp/W11-adapter-design.md`, section 1).
6
+ * never calls this function.
7
7
  *
8
- * Phase 1a implemented `run`, `diagnostics`, and `open`. This phase
9
- * (1b) fills in `exportNote`, `installPack`, `loadPacks`, `completeAt`,
10
- * `hoverAt`, and `insertComponent`, over the behavior modules built in
11
- * this same pass (`./export-behavior.js`, `./pack-install.js`,
12
- * `./pack-load.js`, `./editor-behavior.js`). Every one of them still
8
+ * It provides `run`, `diagnostics`, `open`, `exportNote`, `installPack`, `loadPacks`, `completeAt`,
9
+ * `hoverAt`, and `insertComponent`, over the behavior modules (`./export-behavior.js`, `./pack-install.js`,
10
+ * `./pack-load.js`, `./editor-behavior.js`). Every one of them
13
11
  * returns the shared `Unsupported` outcome, with one diagnostics line,
14
12
  * when the adapter lacks the capability it needs — AGENTS.md's "clean is
15
13
  * not silent": a decline is never a silent no-op.
@@ -20,9 +18,9 @@ import type { StoredValue } from '@markii/runtime';
20
18
  import type { OnDiagnostic, UnavailableComponent } from '@markii/stdlib';
21
19
  import type { CompletionItem, ComponentDocumentation, LineColumn } from '@markii/stdlib/editor';
22
20
  import { type RunViaAdapterRequest, type RunViaAdapterResult } from './run-behavior.js';
23
- import { type ArchiveExtractFs } from './pack-archive.js';
24
21
  import { type PackDiscoveryIo } from './pack-load.js';
25
- import type { HtmlExportEngine } from '../export/note-export.js';
22
+ import type { ExportBodyRenderer, ExportPackStylesheet, ExportRenderInfo, HtmlExportEngine, StaticExportReason } from '../export/note-export.js';
23
+ import type { EmbeddedImageReport, ExportImageReader } from '../export/image-embed.js';
26
24
  import type { DiscoveredPack, SkippedPackFolder } from '../packs/discover.js';
27
25
  import { type TerminalPackScript } from './terminal-packs.js';
28
26
  import { type RemovePackOutcome } from './pack-remove.js';
@@ -63,11 +61,31 @@ export interface ExportRequest {
63
61
  readonly unavailableComponents?: readonly UnavailableComponent[];
64
62
  /** Receives the static engine's render diagnostics, such as the `no-engine-view` event. */
65
63
  readonly onDiagnostic?: OnDiagnostic;
64
+ /** The host's own render of the body (a React pass bound to its loaded packs). Omitted means the static engine renders, and `staticReason` says why. Used by the `html` and `pdf` formats. */
65
+ readonly renderBody?: ExportBodyRenderer;
66
+ /** Why the static engine renders when `renderBody` is omitted. Defaults to `no-packs`. */
67
+ readonly staticReason?: StaticExportReason;
68
+ /** The loaded packs' stylesheets, embedded only when `renderBody` ran. */
69
+ readonly packStylesheets?: readonly ExportPackStylesheet[];
70
+ /** How many loaded packs contributed to the render. Diagnostics only. */
71
+ readonly packCount?: number;
72
+ /** Reads one of the note's local images so the export embeds it as a `data:` URI. Omitted leaves every image source as written. */
73
+ readonly embedImages?: ExportImageReader;
74
+ /** Hides the collapsed script marker in the exported file. Defaults to `false`. */
75
+ readonly hideScriptBlocks?: boolean;
66
76
  }
67
77
  export type ExportOutcome = {
68
78
  readonly kind: 'exported';
69
79
  readonly path: string;
70
80
  readonly bytes: number;
81
+ /** How many last-run values were baked into the file. */
82
+ readonly valueCount: number;
83
+ /** `true` when the note contains a script fence. */
84
+ readonly hasScripts?: boolean;
85
+ /** Which engine rendered the body, and why when it was the static one. */
86
+ readonly render: ExportRenderInfo;
87
+ /** What image embedding did. */
88
+ readonly images: EmbeddedImageReport;
71
89
  } | {
72
90
  readonly kind: 'failed';
73
91
  readonly reason: string;
@@ -84,6 +102,8 @@ export interface InstallPackRequest {
84
102
  export type InstallPackOutcome = {
85
103
  readonly kind: 'installed';
86
104
  readonly namespace: string;
105
+ /** The folder the pack now lives in, under the adapter's install root. */
106
+ readonly installedDir: string;
87
107
  readonly replaced: boolean;
88
108
  } | {
89
109
  readonly kind: 'declined';
@@ -239,9 +259,7 @@ export interface MarkiiHost {
239
259
  */
240
260
  export interface MarkiiHostOptions {
241
261
  readonly spawnRun?: (options: SpawnRunRequest) => Promise<RunResult>;
242
- /** The `.mkp` extraction filesystem `installPack` writes through. Required for a host that installs packs (`createNodeArchiveExtractFs()` from `@markii/host/node`); without it `installPack` is `Unsupported`. */
243
- readonly archiveExtractFs?: ArchiveExtractFs;
244
- /** The static HTML engine behind the `html` and `pdf` exports (`createHtmlExportEngine()` from `@markii/host/node`). Without it those formats decline, like `renderAnsi` for `ansi`. */
262
+ /** The static HTML engine behind the `html` and `pdf` exports (`createHtmlExportEngine()` from `@markii/host/html-engine`). Without it those formats decline, like `renderAnsi` for `ansi`. */
245
263
  readonly htmlEngine?: HtmlExportEngine;
246
264
  /** Pack discovery's file reads. Defaults to reads through the adapter's own `readFile` and `listFolder`. */
247
265
  readonly packDiscoveryIo?: PackDiscoveryIo;
@@ -95,6 +95,24 @@ export function createMarkiiHost(adapter, options = {}) {
95
95
  ...(request.onDiagnostic !== undefined
96
96
  ? { onDiagnostic: request.onDiagnostic }
97
97
  : {}),
98
+ ...(request.renderBody !== undefined
99
+ ? { renderBody: request.renderBody }
100
+ : {}),
101
+ ...(request.staticReason !== undefined
102
+ ? { staticReason: request.staticReason }
103
+ : {}),
104
+ ...(request.packStylesheets !== undefined
105
+ ? { packStylesheets: request.packStylesheets }
106
+ : {}),
107
+ ...(request.packCount !== undefined
108
+ ? { packCount: request.packCount }
109
+ : {}),
110
+ ...(request.embedImages !== undefined
111
+ ? { embedImages: request.embedImages }
112
+ : {}),
113
+ ...(request.hideScriptBlocks !== undefined
114
+ ? { hideScriptBlocks: request.hideScriptBlocks }
115
+ : {}),
98
116
  }, options.renderAnsi);
99
117
  if (outcome === undefined) {
100
118
  adapter.diagnostics(`export declined: format "${request.format}" has no working export path on ${adapter.id} (a cancelled picker, or a missing renderer/PDF seam).`);
@@ -106,47 +124,40 @@ export function createMarkiiHost(adapter, options = {}) {
106
124
  if (outcome.kind === 'failed') {
107
125
  return { kind: 'failed', reason: outcome.reason };
108
126
  }
109
- return { kind: 'exported', path: outcome.path, bytes: outcome.bytes };
127
+ return {
128
+ kind: 'exported',
129
+ path: outcome.path,
130
+ bytes: outcome.bytes,
131
+ valueCount: outcome.valueCount,
132
+ ...(outcome.hasScripts !== undefined
133
+ ? { hasScripts: outcome.hasScripts }
134
+ : {}),
135
+ render: outcome.render,
136
+ images: outcome.images,
137
+ };
110
138
  },
111
139
  async installPack(request) {
112
140
  if (!adapter.packs?.installRoot) {
113
141
  return unsupported(adapter, 'packs', 'this host has no pack-install destination configured.');
114
142
  }
115
143
  const archivePath = request.archivePath ?? 'the selected archive';
116
- const extractFs = options.archiveExtractFs;
117
- if (extractFs === undefined) {
118
- return unsupported(adapter, 'packs', 'this host was given no archive extraction filesystem.');
144
+ const removeFolder = adapter.packs.removeFolder;
145
+ if (removeFolder === undefined) {
146
+ return unsupported(adapter, 'packs', 'this host has no way to remove an installed pack folder.');
119
147
  }
120
148
  const outcome = await installPackFromArchive(adapter, {
121
149
  archiveBytes: request.archiveBytes,
122
150
  archivePath,
123
151
  installRoot: adapter.packs.installRoot(),
124
152
  exists: (path) => adapter.exists(path),
125
- extractFs,
153
+ removeFolder: (path) => removeFolder.call(adapter.packs, path),
126
154
  reservedNamespaces: adapter.packs.reservedNamespaces(),
127
155
  });
128
156
  catalogCache.invalidate();
129
157
  for (const line of installPackDiagnosticLines(outcome, archivePath)) {
130
158
  adapter.diagnostics(line);
131
159
  }
132
- if (outcome.kind === 'installed') {
133
- return {
134
- kind: 'installed',
135
- namespace: outcome.packName,
136
- replaced: outcome.replaced,
137
- };
138
- }
139
- if (outcome.kind === 'declined') {
140
- return {
141
- kind: 'declined',
142
- step: outcome.step,
143
- namespace: outcome.packName,
144
- };
145
- }
146
- if (outcome.kind === 'reserved') {
147
- return { kind: 'reserved', namespace: outcome.packName };
148
- }
149
- return { kind: 'rejected', reason: outcome.reason };
160
+ return outcome;
150
161
  },
151
162
  async loadPacks() {
152
163
  if (!adapter.packs) {
@@ -4,12 +4,8 @@ import type { HostEditor, HostTextEdit } from './adapter.js';
4
4
  export declare const STANDARD_ORIGIN_TAG = "standard";
5
5
  export declare const LAYOUT_ORIGIN_TAG = "layout";
6
6
  /**
7
- * Survey finding A5: `apps/vscode/src/completion.ts:completionOriginTag`
8
- * and `apps/obsidian/src/complete-component.ts:completionOriginTag` had
9
- * IDENTICAL function bodies, differing only in which locally-defined
10
- * constant stood in for the standard/layout tag string. Both apps'
11
- * constants held the same lower-case values (`'standard'`/`'layout'`),
12
- * so there was never a wording difference to preserve.
7
+ * The origin tag a completion item carries: the lower-case
8
+ * `'standard'`/`'layout'` strings above, or the pack name.
13
9
  */
14
10
  export declare function completionOriginTag(item: CompletionItem): string;
15
11
  /** A small cache in front of `buildComponentCatalog` (`../insert/component-catalog.js`), so a completion/hover provider does not re-discover packs from disk on every keystroke. */
@@ -2,16 +2,16 @@
2
2
  * The `completeAt`/`hoverAt`/`insertComponent` behaviors' shared half,
3
3
  * merged from `apps/vscode/src/completion.ts` + `completion-catalog.ts`,
4
4
  * `apps/obsidian/src/complete-component.ts`, and both apps'
5
- * `insert-component.ts` (survey finding A5, plus table rows 31/33).
5
+ * `insert-component.ts`.
6
6
  *
7
- * NODE-FREE by design (`tmp/W11-adapter-design.md`, section 1): re-exported
7
+ * NODE-FREE by design: re-exported
8
8
  * from `../browser.ts` as well as `../index.ts`, so VS Code's webview
9
9
  * bundle can read the origin-tag vocabulary and the pure completion math
10
10
  * directly if a future slice needs to. `CatalogCache.get`'s injected
11
11
  * `load` is the one thing that reaches disk, and it is always supplied by
12
12
  * the caller (a main-entry-only pack-discovery call), never imported here.
13
13
  *
14
- * What stays app-side (deliberately, per the survey's section D): VS
14
+ * What stays app-side (deliberately): VS
15
15
  * Code's sectioned quick-pick presentation (`Standard`/`Layout`/`<pack>
16
16
  * pack` separators) and Obsidian's flat fuzzy list are a genuine product
17
17
  * difference, not duplication — this module returns the catalog and the
@@ -25,12 +25,8 @@ import { closesOpenContainerFence, completionAt, componentDocumentation, fenceEx
25
25
  export const STANDARD_ORIGIN_TAG = 'standard';
26
26
  export const LAYOUT_ORIGIN_TAG = 'layout';
27
27
  /**
28
- * Survey finding A5: `apps/vscode/src/completion.ts:completionOriginTag`
29
- * and `apps/obsidian/src/complete-component.ts:completionOriginTag` had
30
- * IDENTICAL function bodies, differing only in which locally-defined
31
- * constant stood in for the standard/layout tag string. Both apps'
32
- * constants held the same lower-case values (`'standard'`/`'layout'`),
33
- * so there was never a wording difference to preserve.
28
+ * The origin tag a completion item carries: the lower-case
29
+ * `'standard'`/`'layout'` strings above, or the pack name.
34
30
  */
35
31
  export function completionOriginTag(item) {
36
32
  if (item.kind !== 'component')
@@ -2,7 +2,7 @@
2
2
  * The `exportNote` behavior: outcome shape, wording, and format gating,
3
3
  * merged from `apps/vscode/src/export-html.ts`, the host-neutral half of
4
4
  * `apps/obsidian/src/export-note.ts`, and `apps/cli/src/export-note.ts`'s
5
- * `exportHtml` (survey finding A10, table rows 22/26).
5
+ * `exportHtml`.
6
6
  *
7
7
  * `html` and `md-plain` are fully implemented here, over `../export/note-export.js`'s
8
8
  * `buildNoteExport` (already host-neutral, already shared) and
@@ -12,26 +12,25 @@
12
12
  * AGENTS.md's package boundaries), so it is rendered through the injected
13
13
  * `renderAnsi` option; a host that omits it gets `Unsupported`.
14
14
  *
15
- * PDF stays a seam, never an implementation here, exactly as
16
- * `tmp/W11-adapter-design.md` specifies: this module builds the same HTML
15
+ * PDF stays a seam, never an implementation here, this module builds the same HTML
17
16
  * document `html` would have written and hands it to
18
17
  * `adapter.exports.htmlToPdf`, but the DEGRADE-TO-HTML-ON-FAILURE product
19
18
  * behavior Obsidian's own `exportNoteAsPdf` layers on top (falling back to
20
19
  * writing the HTML file when printing is unavailable or fails, and
21
20
  * reporting which of the two happened) is Obsidian-specific policy about
22
21
  * what to do with a seam's failure, not the seam itself — it stays
23
- * app-side. See `tmp/W11-phase1b.md` for this call.
22
+ * app-side.
24
23
  *
25
24
  * Cascade orchestration (multi-note export, `walkNoteCascade` +
26
25
  * `assignCascadeFileNames` + `rewriteCascadeLinks` + `zipExportArchive`)
27
- * is NOT moved in this pass — see `tmp/W11-phase1b.md`'s deviations
28
- * section for why, and what Phase 2 (or a follow-up) still needs to do.
26
+ * is not part of this module: it stays with each host.
29
27
  */
30
28
  import type { StoredValue } from '@markii/runtime';
31
29
  import type { OnDiagnostic, UnavailableComponent } from '@markii/stdlib';
32
30
  import type { HtmlExportEngine, ExportBodyRenderer, ExportPackStylesheet, ExportRenderInfo, StaticExportReason } from '../export/note-export.js';
33
31
  import type { EmbeddedImageReport, ExportImageReader } from '../export/image-embed.js';
34
32
  import type { HostAdapter, HostExportFormat, HostLabels } from './adapter.js';
33
+ import type { ExportOutcome } from './create-host.js';
35
34
  /** The file name an export lands under, by format: the note's own base name with the format's own extension, so the export lands beside the note unless the host's picker says otherwise. */
36
35
  export declare function exportDefaultFileName(notePath: string, format: HostExportFormat): string;
37
36
  /** What a `'html'` or `'pdf'` export attempt produced. Every shape except `failed` means the host got a file, and every shape but `failed` carries the same diagnostics-facing detail so the two hosts' wording can share it. */
@@ -60,7 +59,7 @@ export declare function fileNameOf(path: string): string;
60
59
  * host's own diagnostics surface (`labels.diagnosticsSurface`), never at
61
60
  * a stack trace — the verbatim reason goes there instead.
62
61
  */
63
- export declare function exportResultMessage(outcome: NoteFileExportOutcome, labels: HostLabels): string;
62
+ export declare function exportResultMessage(outcome: ExportOutcome, labels: HostLabels): string;
64
63
  /** One diagnostic line describing HOW an export's body was rendered: the pack-vs-static distinction, kept off the result message and put here instead. */
65
64
  export declare function renderEngineDiagnosticLine(render: ExportRenderInfo): string;
66
65
  /** `bytes`, formatted as a human-readable size (`"1.2 KB"`, `"512 bytes"`). */
@@ -23,7 +23,7 @@ export function fileNameOf(path) {
23
23
  * a stack trace — the verbatim reason goes there instead.
24
24
  */
25
25
  export function exportResultMessage(outcome, labels) {
26
- if (outcome.kind === 'failed') {
26
+ if (outcome.kind !== 'exported') {
27
27
  return `Markii: could not export this note. Open ${labels.diagnosticsSurface} for details.`;
28
28
  }
29
29
  const name = fileNameOf(outcome.path);