@markii/pack-build 0.0.1 → 0.17.0-next.3
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 +8 -2
- package/dist/index.d.ts +7 -0
- package/dist/index.js +8 -0
- package/dist/pack-build.d.ts +113 -0
- package/dist/pack-build.js +1259 -0
- package/dist/pack-css-lint.d.ts +38 -0
- package/dist/pack-css-lint.js +231 -0
- package/dist/pack-export-archive.d.ts +45 -0
- package/dist/pack-export-archive.js +198 -0
- package/dist/pack-export.d.ts +94 -0
- package/dist/pack-export.js +335 -0
- package/package.json +41 -4
|
@@ -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>;
|