@markii/pack-build 0.0.0-stage → 0.17.0-next.2

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.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Plain string analysis over ONE pack's emitted CSS (`./pack-build.ts`'s
3
+ * output, either freshly built or read back from the cache) — two
4
+ * authoring rules, both WARNINGS only, per the pack CSS design
5
+ * (docs/packs.md, the "packs style themselves" slice): a warning never
6
+ * fails a build, never throws, and never blocks a pack's stylesheet from
7
+ * loading (AGENTS.md's "clean is not silent": a problem gets a full
8
+ * diagnostic somewhere findable — the "Markii" output channel via
9
+ * `./pack-diagnostics.ts` — never a build failure or missing styling).
10
+ *
11
+ * No CSS parser dependency (AGENTS.md's dependency rule: only what's listed
12
+ * under Stack) — both rules are small, deliberately permissive string scans
13
+ * over the EMITTED (already-bundled-by-esbuild) CSS text, not the author's
14
+ * original source. `vscode`-free and pure so it is exhaustively testable
15
+ * without esbuild or a webview.
16
+ */
17
+ /**
18
+ * Rule A: warn when a declaration's value carries a raw color literal
19
+ * (hex/`rgb()`/`rgba()`/`hsl()`/`hsla()`) instead of one of `doc.css`'s
20
+ * `--mk-*` palette tokens — the exact bug this feature exists to prevent
21
+ * (hardcoded light colors, unreadable once a host's dark theme remaps the
22
+ * tokens). Scans the whole emitted CSS text; each match reports the pack
23
+ * name and the offending `property: value;` declaration. A literal serving
24
+ * as a `var()` fallback is exempt: see `stripVarFallbacks`.
25
+ */
26
+ export declare function lintPackCssColors(packName: string, css: string): readonly string[];
27
+ /**
28
+ * Rule B: warn when a pack CSS selector leads with a class token that does
29
+ * not carry the pack's `.mk-<packname>_` prefix — the class-uniqueness
30
+ * mirror of the directive-namespace rule `@markii/pack`'s
31
+ * `detectNamespaceCollisions` already enforces at install time (two packs
32
+ * can never share a namespace, so two packs correctly prefixing their
33
+ * classes can never collide either). Keyframe inner selectors (`0%`,
34
+ * `from`, `to`) are exempt — see `extractStyleRules`'s `inKeyframes`.
35
+ */
36
+ export declare function lintPackCssPrefix(packName: string, css: string): readonly string[];
37
+ /** Both rules, in order (colors first, then prefix), as one flat warning list — what `./pack-build.ts` attaches to a `'built'` outcome and `./pack-context.ts`/`./pack-diagnostics.ts` surface to the "Markii" output channel. Warnings only: never thrown, never a build failure. */
38
+ export declare function lintPackCss(packName: string, css: string): readonly string[];
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Plain string analysis over ONE pack's emitted CSS (`./pack-build.ts`'s
3
+ * output, either freshly built or read back from the cache) — two
4
+ * authoring rules, both WARNINGS only, per the pack CSS design
5
+ * (docs/packs.md, the "packs style themselves" slice): a warning never
6
+ * fails a build, never throws, and never blocks a pack's stylesheet from
7
+ * loading (AGENTS.md's "clean is not silent": a problem gets a full
8
+ * diagnostic somewhere findable — the "Markii" output channel via
9
+ * `./pack-diagnostics.ts` — never a build failure or missing styling).
10
+ *
11
+ * No CSS parser dependency (AGENTS.md's dependency rule: only what's listed
12
+ * under Stack) — both rules are small, deliberately permissive string scans
13
+ * over the EMITTED (already-bundled-by-esbuild) CSS text, not the author's
14
+ * original source. `vscode`-free and pure so it is exhaustively testable
15
+ * without esbuild or a webview.
16
+ */
17
+ /**
18
+ * Single-pass, stack-based extraction of every non-at-rule block in `css`:
19
+ * whenever a `{` is reached, the buffered text since the last brace is the
20
+ * block's "prelude". A prelude starting with `@` is a container (`@media`,
21
+ * `@supports`, `@keyframes`, `@font-face`, …) whose own prelude is never
22
+ * itself a rule; anything else — at ANY nesting depth, including inside an
23
+ * `@media`/`@supports` container — is a real style rule. This naturally
24
+ * handles one level of media-query nesting (the common case) and reports
25
+ * each such nested rule's OWN selector, not the wrapping `@media (...)`
26
+ * text. `@keyframes` inner blocks (`0%`, `from`, `to`) are still collected
27
+ * as "rules" by this same logic — `inKeyframes` is what lets callers skip
28
+ * the prefix check for exactly those.
29
+ */
30
+ function extractStyleRules(css) {
31
+ const rules = [];
32
+ const stack = [];
33
+ let buffer = '';
34
+ let enclosingAtRule;
35
+ for (let i = 0; i < css.length; i++) {
36
+ const ch = css[i];
37
+ if (ch === '{') {
38
+ const prelude = buffer.trim();
39
+ buffer = '';
40
+ const isAtRule = prelude.startsWith('@');
41
+ stack.push({ prelude, isAtRule, contentStart: i + 1, enclosingAtRule });
42
+ if (isAtRule)
43
+ enclosingAtRule = prelude;
44
+ continue;
45
+ }
46
+ if (ch === '}') {
47
+ const frame = stack.pop();
48
+ buffer = '';
49
+ if (!frame)
50
+ continue;
51
+ if (!frame.isAtRule) {
52
+ rules.push({
53
+ selector: frame.prelude,
54
+ declarations: css.slice(frame.contentStart, i),
55
+ inKeyframes: /@(-[a-z]+-)?keyframes\b/i.test(frame.enclosingAtRule ?? ''),
56
+ });
57
+ }
58
+ enclosingAtRule = frame.enclosingAtRule;
59
+ continue;
60
+ }
61
+ buffer += ch;
62
+ }
63
+ return rules;
64
+ }
65
+ /**
66
+ * Strips CSS block comments (`slash-star ... star-slash`). esbuild's CSS
67
+ * bundler always emits at least one — a source-path banner comment per
68
+ * bundled file, see `./pack-build.ts`'s doc comment — so both rules below
69
+ * must not mistake a banner comment's leading text for part of the next
70
+ * real selector/declaration. Non-greedy so multiple separate comments in
71
+ * the same file are each stripped individually, rather than everything
72
+ * between the FIRST comment's start and the LAST comment's end collapsing
73
+ * into one match.
74
+ */
75
+ function stripCssComments(css) {
76
+ return css.replace(/\/\*[\s\S]*?\*\//g, '');
77
+ }
78
+ /** Matches a `property: value;` declaration anywhere in the CSS text, independent of brace nesting/selector shape — good enough for the deliberately permissive scan Rule A performs. */
79
+ const DECLARATION_RE = /([a-zA-Z-]+)\s*:\s*([^;{}]+);/g;
80
+ /** A hex color, or an `rgb()`/`rgba()`/`hsl()`/`hsla()` function call. `transparent`, `currentColor`, and `inherit` never match this — they are keywords, not literals, so they need no explicit allowlist. */
81
+ const COLOR_LITERAL_RE = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?)\([^)]*\)/;
82
+ /**
83
+ * A literal used as a `var()` FALLBACK is not the bug this rule hunts:
84
+ * `var(--mk-border, #e4e4e7)` still follows the palette wherever the palette
85
+ * exists, and the fallback is what keeps a pack readable against an older
86
+ * `doc.css` that predates the token. `docs/integration.md` recommends
87
+ * exactly this shape for host theme layers, so flagging it in pack CSS would
88
+ * contradict the guidance. Strip the fallback arguments before scanning, and
89
+ * what remains is genuinely hardcoded color.
90
+ *
91
+ * Deliberately simple: this removes everything from the comma to the closing
92
+ * paren of a `var(`, which is the only form a fallback takes. A nested
93
+ * `var()` inside a fallback is not worth parsing for a warning-only rule.
94
+ */
95
+ function stripVarFallbacks(value) {
96
+ let out = '';
97
+ let index = 0;
98
+ while (index < value.length) {
99
+ const start = value.indexOf('var(', index);
100
+ if (start === -1) {
101
+ out += value.slice(index);
102
+ break;
103
+ }
104
+ out += value.slice(index, start);
105
+ // Scan for this `var(`'s matching close paren, tracking depth so a
106
+ // fallback that is itself a function call is handled. A naive
107
+ // `[^()]*` pattern stops at the first inner `(` and leaves the
108
+ // literal exposed, which produced a false positive on the real
109
+ // `var(--mk-shadow-sm, rgba(0, 0, 0, 0.05))`.
110
+ let depth = 0;
111
+ let cursor = start + 3;
112
+ let firstComma = -1;
113
+ for (; cursor < value.length; cursor += 1) {
114
+ const char = value[cursor];
115
+ if (char === '(')
116
+ depth += 1;
117
+ else if (char === ')') {
118
+ depth -= 1;
119
+ if (depth === 0)
120
+ break;
121
+ }
122
+ else if (char === ',' && depth === 1 && firstComma === -1) {
123
+ firstComma = cursor;
124
+ }
125
+ }
126
+ if (cursor >= value.length) {
127
+ // Unbalanced; leave the remainder untouched rather than guessing.
128
+ out += value.slice(start);
129
+ break;
130
+ }
131
+ const nameEnd = firstComma === -1 ? cursor : firstComma;
132
+ out += `var(${value.slice(start + 4, nameEnd).trim()})`;
133
+ index = cursor + 1;
134
+ }
135
+ return out;
136
+ }
137
+ /**
138
+ * The offending `property: value;` text as it appears in a warning line:
139
+ * collapsed to one line (a multi-line value like a formatted
140
+ * `linear-gradient(...)` would otherwise splinter the diagnostic across
141
+ * several console lines) and truncated past `MAX_DECLARATION_EXCERPT`
142
+ * characters — the excerpt exists to locate the declaration, not to
143
+ * reproduce it.
144
+ */
145
+ const MAX_DECLARATION_EXCERPT = 100;
146
+ function declarationExcerpt(property, value) {
147
+ const oneLine = `${property.trim()}: ${value.trim()};`.replace(/\s+/g, ' ');
148
+ if (oneLine.length <= MAX_DECLARATION_EXCERPT)
149
+ return oneLine;
150
+ return `${oneLine.slice(0, MAX_DECLARATION_EXCERPT - 1)}\u2026`;
151
+ }
152
+ /**
153
+ * Rule A: warn when a declaration's value carries a raw color literal
154
+ * (hex/`rgb()`/`rgba()`/`hsl()`/`hsla()`) instead of one of `doc.css`'s
155
+ * `--mk-*` palette tokens — the exact bug this feature exists to prevent
156
+ * (hardcoded light colors, unreadable once a host's dark theme remaps the
157
+ * tokens). Scans the whole emitted CSS text; each match reports the pack
158
+ * name and the offending `property: value;` declaration. A literal serving
159
+ * as a `var()` fallback is exempt: see `stripVarFallbacks`.
160
+ */
161
+ export function lintPackCssColors(packName, css) {
162
+ const warnings = [];
163
+ const text = stripCssComments(css);
164
+ DECLARATION_RE.lastIndex = 0;
165
+ let match;
166
+ while ((match = DECLARATION_RE.exec(text)) !== null) {
167
+ const property = match[1] ?? '';
168
+ const value = match[2] ?? '';
169
+ if (!COLOR_LITERAL_RE.test(stripVarFallbacks(value)))
170
+ continue;
171
+ warnings.push(`pack "${packName}" CSS uses a raw color literal in "${declarationExcerpt(property, value)}" — use an --mk-* palette token (see docs/integration.md's theming section) instead of a hardcoded color, or it will be unreadable once a host's dark theme remaps the palette.`);
172
+ }
173
+ return warnings;
174
+ }
175
+ /**
176
+ * The required class prefix for `packName`'s selectors — mirrors the
177
+ * directive-namespace join (`docs/packs.md`: pack name + component name,
178
+ * joined with `_`), so a pack that avoids namespace collisions in its
179
+ * directives avoids them in its classes too. Because a pack name (a
180
+ * `SEGMENT_RE`-validated lowercase-kebab segment) can never itself contain
181
+ * an underscore, the underscore here always marks exactly the same
182
+ * boundary the directive-name join does — so, just like two packs'
183
+ * composed directive names can never collide, two packs' class namespaces
184
+ * can never overlap either.
185
+ */
186
+ function requiredPrefix(packName) {
187
+ return `.mk-${packName}_`;
188
+ }
189
+ /**
190
+ * True when every comma-separated part of `selector` either starts with
191
+ * the pack's required prefix, or is not a class selector at all (a bare
192
+ * element/attribute/pseudo selector with no `.` at all — e.g. `*`, `:root`,
193
+ * `[data-x]` — carries no namespace risk, so it is not warned on; only a
194
+ * selector that DOES lead with a class token is held to the prefix).
195
+ */
196
+ function selectorPartsMissingPrefix(selector, packName) {
197
+ const prefix = requiredPrefix(packName);
198
+ return selector
199
+ .split(',')
200
+ .map((part) => part.trim())
201
+ .filter((part) => part.length > 0)
202
+ .filter((part) => part.startsWith('.'))
203
+ .filter((part) => !part.startsWith(prefix));
204
+ }
205
+ /**
206
+ * Rule B: warn when a pack CSS selector leads with a class token that does
207
+ * not carry the pack's `.mk-<packname>_` prefix — the class-uniqueness
208
+ * mirror of the directive-namespace rule `@markii/pack`'s
209
+ * `detectNamespaceCollisions` already enforces at install time (two packs
210
+ * can never share a namespace, so two packs correctly prefixing their
211
+ * classes can never collide either). Keyframe inner selectors (`0%`,
212
+ * `from`, `to`) are exempt — see `extractStyleRules`'s `inKeyframes`.
213
+ */
214
+ export function lintPackCssPrefix(packName, css) {
215
+ const warnings = [];
216
+ for (const rule of extractStyleRules(stripCssComments(css))) {
217
+ if (rule.inKeyframes)
218
+ continue;
219
+ for (const part of selectorPartsMissingPrefix(rule.selector, packName)) {
220
+ warnings.push(`pack "${packName}" CSS selector "${part}" does not start with the pack's required prefix "${requiredPrefix(packName)}" — pack class selectors must be prefixed so two packs' styles can never collide.`);
221
+ }
222
+ }
223
+ return warnings;
224
+ }
225
+ /** Both rules, in order (colors first, then prefix), as one flat warning list — what `./pack-build.ts` attaches to a `'built'` outcome and `./pack-context.ts`/`./pack-diagnostics.ts` surface to the "Markii" output channel. Warnings only: never thrown, never a build failure. */
226
+ export function lintPackCss(packName, css) {
227
+ return [
228
+ ...lintPackCssColors(packName, css),
229
+ ...lintPackCssPrefix(packName, css),
230
+ ];
231
+ }
@@ -0,0 +1,45 @@
1
+ import type { DiscoveredPack } from '@markii/host';
2
+ import type { PackExportBuilder, PackExportFs } from './pack-export.js';
3
+ export type PackExportArchiveOutcome = {
4
+ readonly kind: 'built';
5
+ readonly packName: string;
6
+ /** `<name>-<version>.mkp`, or `<name>.mkp` with no declared version; see `@markii/pack`'s `packArchiveFileName`. */
7
+ readonly fileName: string;
8
+ readonly bytes: Uint8Array;
9
+ readonly scriptBytes: number;
10
+ /** Set when the archive carries a `terminal.js`. */
11
+ readonly terminalScriptBytes?: number;
12
+ readonly stylesheetBytes?: number;
13
+ /** How many `*.lua` files from the pack's `scripts/` directory were included. 0 when it has none. */
14
+ readonly scriptFilesCopied: number;
15
+ /** The build's pack-CSS lint warnings, passed through unchanged. */
16
+ readonly warnings: readonly string[];
17
+ } | {
18
+ readonly kind: 'failed';
19
+ readonly packName: string;
20
+ readonly reason: string;
21
+ };
22
+ export interface ExportPackArchiveOptions {
23
+ readonly pack: DiscoveredPack;
24
+ /** The host-owned build cache directory: the build still runs through the normal cache, only its output is zipped. */
25
+ readonly cacheDir: string;
26
+ readonly build: PackExportBuilder;
27
+ /**
28
+ * Builds the pack's terminal script (`buildPackTerminalScript` through the
29
+ * host's cache). When given and the pack names `ansi` sources, the archive
30
+ * carries the result as `terminal.js`. A host that compiles only for its
31
+ * own preview omits it; the archive then holds React views only.
32
+ */
33
+ readonly buildTerminal?: PackExportBuilder;
34
+ /** Only the read half of `PackExportFs`: an archive export never writes anywhere on its own. */
35
+ readonly fs: Pick<PackExportFs, 'readFile' | 'listDirectory'>;
36
+ }
37
+ /**
38
+ * NEVER throws: every failure (an unreadable `pack.json`, a build failure,
39
+ * an unreadable built script) comes back as `{ kind: 'failed', reason }`,
40
+ * matching `exportPack`'s contract. There is no overwrite confirmation
41
+ * here, unlike `exportPack`: nothing is written to any path by this
42
+ * function, so "does the destination already have a file" is entirely the
43
+ * caller's concern once it knows where the user wants the `.mkp` saved.
44
+ */
45
+ export declare function exportPackArchive(options: ExportPackArchiveOptions): Promise<PackExportArchiveOutcome>;
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Builds the one output shape "Markii: Export Pack" produces (GitHub issue
3
+ * #16): compiles a pack through the same build cache `./pack-export.ts`
4
+ * uses and zips its distributable form (`pack.json`, `webview.js`,
5
+ * `webview.css` when built, `scripts/*.lua`) into a single `.mkp` file's
6
+ * bytes, named per `@markii/pack`'s `packArchiveFileName`. VS Code is the
7
+ * authoring host and owns pack packaging; this module composes through
8
+ * `./pack-export.ts`'s `PackExportBuilder` and `PackExportFs` types rather
9
+ * than duplicating the compile step.
10
+ *
11
+ * Uses `fflate`'s `zipSync`, already a runtime dependency of this package
12
+ * (`packages/markii-host/package.json`; `@markii/bundle`'s own zip reader
13
+ * is built on the same library), so this adds no new dependency. The
14
+ * result is a PLAIN zip at the archive root, not a `@markii/bundle`
15
+ * bundle: a `.mkp` carries no `manifest.json`/bundle semantics of its own,
16
+ * just the flat layout `@markii/pack`'s `openPackArchive` reads back.
17
+ *
18
+ * The pack's own source folder is never written to, exactly like
19
+ * `exportPack`. This function never writes anywhere at all: it only reads
20
+ * (the pack's own `pack.json`, the build output, the `scripts/` folder)
21
+ * and returns bytes; a caller's `vscode`-specific wiring decides where the
22
+ * user wants the `.mkp` saved and whether to confirm an overwrite there.
23
+ */
24
+ import * as path from 'node:path';
25
+ import { zipSync } from 'fflate';
26
+ import { packArchiveFileName } from '@markii/pack';
27
+ import { PREBUILT_TERMINAL_SCRIPT_FILENAME, emptyWebviewScript, } from '@markii/host';
28
+ const MANIFEST_FILENAME = 'pack.json';
29
+ const SCRIPT_FILENAME = 'webview.js';
30
+ const STYLESHEET_FILENAME = 'webview.css';
31
+ function describeThrown(err) {
32
+ return err instanceof Error ? err.message : String(err);
33
+ }
34
+ function packNameOf(pack) {
35
+ return pack.manifest.name;
36
+ }
37
+ /**
38
+ * NEVER throws: every failure (an unreadable `pack.json`, a build failure,
39
+ * an unreadable built script) comes back as `{ kind: 'failed', reason }`,
40
+ * matching `exportPack`'s contract. There is no overwrite confirmation
41
+ * here, unlike `exportPack`: nothing is written to any path by this
42
+ * function, so "does the destination already have a file" is entirely the
43
+ * caller's concern once it knows where the user wants the `.mkp` saved.
44
+ */
45
+ export async function exportPackArchive(options) {
46
+ const { pack, cacheDir, build, buildTerminal, fs } = options;
47
+ const packName = packNameOf(pack);
48
+ let manifestText;
49
+ try {
50
+ manifestText = await fs.readFile(path.join(pack.folder, MANIFEST_FILENAME));
51
+ }
52
+ catch (err) {
53
+ return { kind: 'failed', packName, reason: describeThrown(err) };
54
+ }
55
+ if (manifestText === undefined) {
56
+ return {
57
+ kind: 'failed',
58
+ packName,
59
+ reason: `pack.json for pack "${packName}" could not be read`,
60
+ };
61
+ }
62
+ let outcome;
63
+ try {
64
+ outcome = await build(pack, cacheDir);
65
+ }
66
+ catch (err) {
67
+ return { kind: 'failed', packName, reason: describeThrown(err) };
68
+ }
69
+ if (outcome.kind === 'failed') {
70
+ // Verbatim: the reason's one home is the caller's own diagnostics
71
+ // module, matching `exportPack`'s identical posture.
72
+ return { kind: 'failed', packName, reason: outcome.reason };
73
+ }
74
+ let terminalText;
75
+ const hasTerminalSources = Object.keys(pack.componentPathsByEngine?.['ansi'] ?? {}).length > 0;
76
+ if (buildTerminal !== undefined && hasTerminalSources) {
77
+ let terminalOutcome;
78
+ try {
79
+ terminalOutcome = await buildTerminal(pack, cacheDir);
80
+ }
81
+ catch (err) {
82
+ return { kind: 'failed', packName, reason: describeThrown(err) };
83
+ }
84
+ if (terminalOutcome.kind === 'failed') {
85
+ return {
86
+ kind: 'failed',
87
+ packName,
88
+ reason: `terminal build: ${terminalOutcome.reason}`,
89
+ };
90
+ }
91
+ if (terminalOutcome.kind === 'built') {
92
+ try {
93
+ terminalText = await fs.readFile(terminalOutcome.scriptPath);
94
+ }
95
+ catch {
96
+ terminalText = undefined;
97
+ }
98
+ if (terminalText === undefined) {
99
+ return {
100
+ kind: 'failed',
101
+ packName,
102
+ reason: `built terminal script for pack "${packName}" could not be read back`,
103
+ };
104
+ }
105
+ }
106
+ }
107
+ let scriptText;
108
+ if (outcome.kind === 'skipped') {
109
+ if (terminalText === undefined) {
110
+ return {
111
+ kind: 'failed',
112
+ packName,
113
+ reason: `pack "${packName}" was not built`,
114
+ };
115
+ }
116
+ scriptText = emptyWebviewScript(JSON.stringify(pack.manifest));
117
+ }
118
+ else {
119
+ try {
120
+ scriptText = await fs.readFile(outcome.scriptPath);
121
+ }
122
+ catch (err) {
123
+ return { kind: 'failed', packName, reason: describeThrown(err) };
124
+ }
125
+ if (scriptText === undefined) {
126
+ return {
127
+ kind: 'failed',
128
+ packName,
129
+ reason: `built script for pack "${packName}" could not be read back`,
130
+ };
131
+ }
132
+ }
133
+ let stylesheetText;
134
+ if (outcome.kind === 'built' && outcome.stylesheetPath !== undefined) {
135
+ try {
136
+ stylesheetText = await fs.readFile(outcome.stylesheetPath);
137
+ }
138
+ catch {
139
+ // A read failure here is treated as "no stylesheet" rather than
140
+ // failing the whole export, matching `exportPack`.
141
+ stylesheetText = undefined;
142
+ }
143
+ }
144
+ let scriptEntries = [];
145
+ try {
146
+ scriptEntries = await fs.listDirectory(pack.scriptsDir);
147
+ }
148
+ catch {
149
+ scriptEntries = [];
150
+ }
151
+ const luaFilenames = scriptEntries.filter((name) => name.endsWith('.lua'));
152
+ const luaFiles = [];
153
+ for (const filename of luaFilenames) {
154
+ let text;
155
+ try {
156
+ text = await fs.readFile(path.join(pack.scriptsDir, filename));
157
+ }
158
+ catch {
159
+ text = undefined;
160
+ }
161
+ if (text === undefined)
162
+ continue; // unreadable individual file: quietly skipped
163
+ luaFiles.push({ name: filename, text });
164
+ }
165
+ const encoder = new TextEncoder();
166
+ const zipInput = {
167
+ [MANIFEST_FILENAME]: encoder.encode(manifestText),
168
+ [SCRIPT_FILENAME]: encoder.encode(scriptText),
169
+ };
170
+ const scriptBytes = zipInput[SCRIPT_FILENAME].byteLength;
171
+ let terminalScriptBytes;
172
+ if (terminalText !== undefined) {
173
+ const encoded = encoder.encode(terminalText);
174
+ zipInput[PREBUILT_TERMINAL_SCRIPT_FILENAME] = encoded;
175
+ terminalScriptBytes = encoded.byteLength;
176
+ }
177
+ let stylesheetBytes;
178
+ if (stylesheetText !== undefined) {
179
+ const encoded = encoder.encode(stylesheetText);
180
+ zipInput[STYLESHEET_FILENAME] = encoded;
181
+ stylesheetBytes = encoded.byteLength;
182
+ }
183
+ for (const file of luaFiles) {
184
+ zipInput[`scripts/${file.name}`] = encoder.encode(file.text);
185
+ }
186
+ const bytes = zipSync(zipInput);
187
+ return {
188
+ kind: 'built',
189
+ packName,
190
+ fileName: packArchiveFileName(pack.manifest),
191
+ bytes,
192
+ scriptBytes,
193
+ ...(terminalScriptBytes !== undefined ? { terminalScriptBytes } : {}),
194
+ ...(stylesheetBytes !== undefined ? { stylesheetBytes } : {}),
195
+ scriptFilesCopied: luaFiles.length,
196
+ warnings: outcome.kind === 'built' ? outcome.warnings : [],
197
+ };
198
+ }
@@ -0,0 +1,94 @@
1
+ import type { DiscoveredPack } from '@markii/host';
2
+ import type { PackBuildOutcome } from './pack-build.js';
3
+ /**
4
+ * The filesystem seam, injected so this module is testable with in-memory
5
+ * fakes. `exists`/`readFile`/`listDirectory` never throw (an unreadable or
6
+ * missing path resolves the "absent" value); `writeFile`/`deleteFile`/
7
+ * `makeDirectory` may reject, and `exportPack` wraps every call to them
8
+ * itself, turning a rejection into a `'failed'` outcome rather than
9
+ * throwing.
10
+ */
11
+ export interface PackExportFs {
12
+ readonly exists: (absolutePath: string) => Promise<boolean>;
13
+ readonly readFile: (absolutePath: string) => Promise<string | undefined>;
14
+ readonly writeFile: (absolutePath: string, text: string) => Promise<void>;
15
+ readonly deleteFile: (absolutePath: string) => Promise<void>;
16
+ /** Creates a directory (and any missing parents), or resolves if it already exists. May reject on a genuine I/O failure. */
17
+ readonly makeDirectory: (absolutePath: string) => Promise<void>;
18
+ /** Lists the plain file names directly inside a directory (no recursion), or `[]` when the directory does not exist / cannot be read. Never rejects. */
19
+ readonly listDirectory: (absolutePath: string) => Promise<readonly string[]>;
20
+ }
21
+ /** Asked once before overwriting artifacts that already exist in the destination folder. Resolves true to proceed. */
22
+ export type ConfirmPackOverwrite = (request: {
23
+ readonly packName: string;
24
+ readonly existingPaths: readonly string[];
25
+ }) => Promise<boolean>;
26
+ export type PackExportBuilder = (pack: DiscoveredPack, cacheDir: string) => Promise<PackBuildOutcome>;
27
+ export interface ExportPackOptions {
28
+ readonly pack: DiscoveredPack;
29
+ /** The host-owned build cache directory, unchanged: the build still runs through the normal cache, only its OUTPUT is copied into the export folder. */
30
+ readonly cacheDir: string;
31
+ /** The PARENT directory the user chose to export into. */
32
+ readonly destinationDir: string;
33
+ /** The folder name to create inside `destinationDir` — a host defaults this to the pack's own name, but it is user-editable, so it is validated exactly like every other path segment (see `resolveExportTarget`). */
34
+ readonly exportName: string;
35
+ readonly build: PackExportBuilder;
36
+ /** Builds the terminal script; when given and the pack names `ansi` sources, `terminal.js` is written beside `webview.js`. */
37
+ readonly buildTerminal?: PackExportBuilder;
38
+ readonly fs: PackExportFs;
39
+ readonly confirmOverwrite: ConfirmPackOverwrite;
40
+ }
41
+ export type PackExportOutcome = {
42
+ readonly kind: 'written';
43
+ readonly packName: string;
44
+ /** Absolute path to the exported folder (`destinationDir`/`exportName`). */
45
+ readonly destinationFolder: string;
46
+ readonly manifestPath: string;
47
+ readonly manifestBytes: number;
48
+ readonly scriptPath: string;
49
+ readonly scriptBytes: number;
50
+ readonly terminalScriptPath?: string;
51
+ readonly terminalScriptBytes?: number;
52
+ readonly stylesheetPath?: string;
53
+ readonly stylesheetBytes?: number;
54
+ /** Set when a stale webview.css from an earlier export was removed because this build emitted no stylesheet. */
55
+ readonly removedStylesheetPath?: string;
56
+ /** How many `*.lua` files were copied from the pack's `scripts/` directory. 0 when the pack has no `scripts/` directory or it holds no `.lua` files. */
57
+ readonly scriptFilesCopied: number;
58
+ /** The build's pack-CSS lint warnings, passed through unchanged. */
59
+ readonly warnings: readonly string[];
60
+ } | {
61
+ readonly kind: 'cancelled';
62
+ readonly packName: string;
63
+ } | {
64
+ readonly kind: 'failed';
65
+ readonly packName: string;
66
+ readonly reason: string;
67
+ };
68
+ /**
69
+ * Path jail. Joins `segments` onto `root`, or `undefined` when any segment
70
+ * is not a plain path segment (empty, `.`, `..`, containing `/` or `\`, or
71
+ * absolute) or the resolved join would land outside `root`. This is the
72
+ * ONLY function `exportPack` uses to compute a write target: nothing is
73
+ * ever written to a path this function did not itself derive. The
74
+ * caller-supplied `exportName` goes through this exact same validation as
75
+ * every other segment — an `exportName` carrying a separator, `..`, or an
76
+ * absolute path is refused here, before anything is written.
77
+ */
78
+ export declare function resolveExportTarget(root: string, ...segments: readonly string[]): string | undefined;
79
+ /**
80
+ * NEVER throws: every failure (an unsafe `exportName`, a build failure, an
81
+ * unreadable build output or `pack.json`, a write that fails) comes back as
82
+ * `{ kind: 'failed', reason }`. A declined overwrite comes back as
83
+ * `{ kind: 'cancelled' }` with nothing written.
84
+ *
85
+ * Order of operations: resolve the destination folder and every write
86
+ * target inside it (path-jailed against `destinationDir`, including the
87
+ * user-supplied `exportName` itself); run the build through the normal
88
+ * cache; read `pack.json` and the built script (and stylesheet, when the
89
+ * build produced one) back as text; list the pack's `scripts/` directory
90
+ * for `.lua` files; ask `confirmOverwrite` once, only if the destination
91
+ * folder already holds files this export would replace; then create the
92
+ * destination folder and write everything.
93
+ */
94
+ export declare function exportPack(options: ExportPackOptions): Promise<PackExportOutcome>;