@codepress/design-kit 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -7
- package/package.json +1 -1
- package/src/cli.mjs +8 -2
- package/src/kit.d.ts +32 -0
- package/src/kit.mjs +327 -36
- package/src/storage/index.mjs +37 -11
- package/src/storage/url.mjs +174 -0
- package/src/templates/design-kit-skill.md +1 -1
package/README.md
CHANGED
|
@@ -392,6 +392,7 @@ design-kit init [--root dir] [--dry-run] [--force] [--workflow-only]
|
|
|
392
392
|
design-kit upload --dir kit [--config f] [--branch b] [--sha commit]
|
|
393
393
|
design-kit fetch [--branch b] [--out dir] [--config f] [--repo owner/name]
|
|
394
394
|
[--token-file f]
|
|
395
|
+
design-kit fetch --url download-url --sha commit [--branch b] [--out dir]
|
|
395
396
|
design-kit latest [--branch b] [--config f] [--repo owner/name] [--token-file f]
|
|
396
397
|
design-kit capture stories [--config f] [--out dir] [--storybook-dir dir]
|
|
397
398
|
[--only id,... | --only-file f] [--base previous-kit]
|
|
@@ -411,8 +412,19 @@ design-kit storybook-guide
|
|
|
411
412
|
kit (`codepress`, `s3` and `r2`; with `github` it exits 2, since the
|
|
412
413
|
workflow's upload step does it). `fetch` downloads the newest kit once per
|
|
413
414
|
commit, into `$TMPDIR/design-kit-<sha>` unless `--out` says otherwise, and
|
|
414
|
-
prints `path=`, `sha=` and
|
|
415
|
-
|
|
415
|
+
prints `path=`, `sha=`, `branch=` and the kit's summary line (as `kit info`
|
|
416
|
+
prints it); a second `fetch` of the same commit downloads nothing.
|
|
417
|
+
`fetch --url <download_url> --sha <commit>` needs no key and no config: it
|
|
418
|
+
installs from a download URL already looked up (with `codepress` storage, the
|
|
419
|
+
`download_url` of the latest route, read through the CodePress Platform MCP)
|
|
420
|
+
into the same cache folder, which it reuses when complete. It streams the zip to
|
|
421
|
+
a scratch folder beside the target, unzips it there, checks that
|
|
422
|
+
`manifest.json` names `--sha`, and renames it into place, so a refused or
|
|
423
|
+
expired URL (`forbidden`), a broken zip (`bad_archive`) or another commit's kit
|
|
424
|
+
(`sha_mismatch`) exits 1 with one line and leaves nothing behind, as does an
|
|
425
|
+
`--out` folder that holds files but no kit (`out_not_empty`, left untouched). Of two
|
|
426
|
+
fetches racing for one commit, the loser keeps the winner's folder. The URL's
|
|
427
|
+
query string is never printed. `latest` prints the newest kit's `sha=` and `created=`.
|
|
416
428
|
Without `--branch` they read the default branch. A store's refusal keeps its
|
|
417
429
|
name (`forbidden`, `no_kit`, `repo_not_connected`, …), a store that cannot be
|
|
418
430
|
reached is `kit_storage_unavailable`, and both exit 1.
|
|
@@ -521,23 +533,53 @@ keeps the kit's totals and adds `page_sources.<source>` with each one's own.
|
|
|
521
533
|
The tool needs only Node: no install and no network.
|
|
522
534
|
|
|
523
535
|
```bash
|
|
524
|
-
node kit.mjs info
|
|
525
|
-
node kit.mjs find button # ≤ 30 lines: id
|
|
536
|
+
node kit.mjs info [--json] # one line: sha, build time, counts (--json: the manifest)
|
|
537
|
+
node kit.mjs find button # ≤ 30 lines: id (widths grouped), size, title
|
|
526
538
|
node kit.mjs find "transfer app" # the words on screen work too
|
|
527
539
|
node kit.mjs show <id> [--full] # path, size and an outline (≤ 2 KB) or the markup
|
|
528
540
|
node kit.mjs compose --frame route:dashboard@1280 --slot main=new.html \
|
|
529
541
|
[--theme light|dark] [--show-new] [--css pruned|full] --out page.html
|
|
542
|
+
node kit.mjs figure --img shot.png [--href '#cp-page:<key>'] [--caption text] \
|
|
543
|
+
[--alt text] [--width px] --out figure.html
|
|
530
544
|
```
|
|
531
545
|
|
|
532
|
-
`
|
|
546
|
+
`figure` needs no kit: it writes a self-contained `<figure>` fragment with
|
|
547
|
+
the `.png`, `.jpg` or `.webp` image as a data URI, inside a link when `--href`
|
|
548
|
+
is given (a fragment such as `#cp-page:<key>`, which links a plan page to a
|
|
549
|
+
screen page, or a relative link), and the caption and alt text escaped. It
|
|
550
|
+
warns on stderr when the image is over 1 MB.
|
|
551
|
+
|
|
552
|
+
`info` prints one line, such as
|
|
553
|
+
`480e11d built 2026-11-15T18:58:59.954Z · stories 12821 · routes 133 · failed 13`,
|
|
554
|
+
and names the failed ids from `gaps.json` when there are five or fewer;
|
|
555
|
+
`info --json` prints `manifest.json` as it is.
|
|
556
|
+
|
|
557
|
+
`find` prints one line per frame: rows that differ only by `@<width>` share
|
|
558
|
+
it, with the widths in braces and one size per width (or one size when they
|
|
559
|
+
print the same), for example
|
|
560
|
+
`route:orchestrator@{1280,390} 131/136 KB Orchestrator project`. A frame
|
|
561
|
+
captured at one width prints its plain id. The title is left out when it only
|
|
562
|
+
restates the id (`Chat/SubAgentBlock/Orchestrator Row` for
|
|
563
|
+
`story:chat-subagentblock--orchestrator-row`), and a route's trailing
|
|
564
|
+
` (1280)` is dropped; a frame whose widths have different titles prints each,
|
|
565
|
+
separated by `|`. `find` lists the rows holding every word of the query in their id, title,
|
|
533
566
|
component, source file or visible text. Rows with the words in the id come
|
|
534
567
|
first, then the title, component or source file, then the text only; among
|
|
535
568
|
rows that tie, one whose text holds the whole query comes first. A row found
|
|
536
569
|
only through its text ends with a quoted excerpt of about 60 characters
|
|
537
570
|
around the words, so you see why it matched. When more rows match than the
|
|
538
571
|
30 lines hold, rows found by name fill the list but its last 5 lines are kept
|
|
539
|
-
for rows found by text, and the closing line counts
|
|
540
|
-
(`+37 more by name, +3 more by on-screen text; narrow the query`). The
|
|
572
|
+
for rows found by text, and the closing line counts the rows each left out
|
|
573
|
+
(`+37 more by name, +3 more by on-screen text; narrow the query`). The
|
|
574
|
+
formatter is exported as `findMatchLines(matches)` from
|
|
575
|
+
`@codepress/design-kit/kit`, so the cloud Orchestrator's `design_kit_find`
|
|
576
|
+
prints the same lines.
|
|
577
|
+
|
|
578
|
+
`show` prints the row's header (id, kind and size, title, file, source,
|
|
579
|
+
slots) and an outline of at most 2 KB: one line per element down to depth 4,
|
|
580
|
+
a run of identical siblings (each with what is nested under it) printed once
|
|
581
|
+
with `×N`, and generated ids (`radix-…`, React's `_r…_` and the capture's
|
|
582
|
+
renumbered `_k…_`) left out. `show --full` prints the markup. The text
|
|
541
583
|
is `text.json`, built
|
|
542
584
|
whenever a capture writes the kit: each row's markup without its scripts,
|
|
543
585
|
styles, SVG, head and templates, tags and attribute values dropped, entities
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -35,9 +35,13 @@ const USAGE = `usage: design-kit <command> [options]
|
|
|
35
35
|
(codepress, s3 or r2; github uploads in the workflow)
|
|
36
36
|
--dir <kit> [--config <file>] [--branch <name>] [--sha <commit>]
|
|
37
37
|
fetch download the newest kit, once per commit (cached under
|
|
38
|
-
$TMPDIR/design-kit-<sha>); prints path=, sha
|
|
38
|
+
$TMPDIR/design-kit-<sha>); prints path=, sha=, branch=
|
|
39
|
+
and the kit's summary line
|
|
39
40
|
[--branch <name>] [--out <dir>] [--config <file>] [--repo <owner/name>]
|
|
40
41
|
[--token-file <path>] codepress: the API key, if not CODEPRESS_API_KEY
|
|
42
|
+
--url <download_url> --sha <commit> [--branch <name>] [--out <dir>]
|
|
43
|
+
install from the download_url of the latest route (read
|
|
44
|
+
through the Platform MCP): no key and no config needed
|
|
41
45
|
latest print the newest kit's sha= and created=
|
|
42
46
|
[--branch <name>] [--config <file>] [--repo <owner/name>]
|
|
43
47
|
[--token-file <path>]
|
|
@@ -245,7 +249,9 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
|
|
|
245
249
|
...(command === "upload"
|
|
246
250
|
? { dir: "value", sha: "value" }
|
|
247
251
|
: { "token-file": "value" }),
|
|
248
|
-
...(command === "fetch"
|
|
252
|
+
...(command === "fetch"
|
|
253
|
+
? { out: "value", url: "value", sha: "value" }
|
|
254
|
+
: {}),
|
|
249
255
|
};
|
|
250
256
|
const options = parse(argv.slice(1), spec);
|
|
251
257
|
const storage = await import("./storage/index.mjs");
|
package/src/kit.d.ts
CHANGED
|
@@ -51,6 +51,14 @@ export function findMatches(kit: Kit, query: string): KitMatch[];
|
|
|
51
51
|
/** The rows of `findMatches`, in the same order. */
|
|
52
52
|
export function find(kit: Kit, query: string): KitRow[];
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* `kit find`'s lines for `findMatches` results, at most 30: rows that differ
|
|
56
|
+
* only by width share one line (`route:x@{1280,390} 131/136 KB Title`), a
|
|
57
|
+
* title that only restates the id is left out, and when the matches do not
|
|
58
|
+
* fit, a last line counts the rows not shown.
|
|
59
|
+
*/
|
|
60
|
+
export function findMatchLines(matches: KitMatch[]): string[];
|
|
61
|
+
|
|
54
62
|
/** Characters of visible text text.json keeps per row: 600. */
|
|
55
63
|
export const TEXT_LIMIT: number;
|
|
56
64
|
|
|
@@ -126,3 +134,27 @@ export function run(
|
|
|
126
134
|
|
|
127
135
|
/** Whether this file is the script node was started with. */
|
|
128
136
|
export function isMain(moduleUrl?: string, script?: string): boolean;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The kit's one-line summary, as `kit info` prints it:
|
|
140
|
+
* `sha7 built <iso> · stories N · routes N · failed N` (the failed ids when
|
|
141
|
+
* there are five or fewer).
|
|
142
|
+
*/
|
|
143
|
+
export function kitSummaryLine(kit: Kit): string;
|
|
144
|
+
|
|
145
|
+
/** Image bytes above which `kit figure` warns: 1 MB. */
|
|
146
|
+
export const FIGURE_WARN_BYTES: number;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `kit figure`'s fragment: a `<figure>` with the image as a data URI, inside
|
|
150
|
+
* `<a href>` when `href` is given (a fragment such as `#cp-page:<key>` or a
|
|
151
|
+
* relative link), and the caption escaped. `width` is CSS pixels.
|
|
152
|
+
*/
|
|
153
|
+
export function figureHtml(options: {
|
|
154
|
+
image: Uint8Array;
|
|
155
|
+
type: "image/png" | "image/jpeg" | "image/webp";
|
|
156
|
+
href?: string;
|
|
157
|
+
caption?: string;
|
|
158
|
+
alt?: string;
|
|
159
|
+
width?: string | number;
|
|
160
|
+
}): string;
|
package/src/kit.mjs
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
* The design kit's own tool. It ships inside every kit, so tool and data
|
|
4
4
|
* always match. Pure Node: no dependencies, no network.
|
|
5
5
|
*
|
|
6
|
-
* node kit.mjs info
|
|
6
|
+
* node kit.mjs info [--json]
|
|
7
7
|
* node kit.mjs find <query> (ids, titles and the words on screen)
|
|
8
8
|
* node kit.mjs show <id> [--full]
|
|
9
9
|
* node kit.mjs compose --frame <id> [--slot <name>=<file>]… [--theme light|dark]
|
|
10
10
|
* [--show-new] [--css pruned|full] --out <file>
|
|
11
|
+
* node kit.mjs figure --img <file> [--href <#cp-page:key>] [--caption <text>]
|
|
12
|
+
* [--alt <text>] [--width <px>] --out <file>
|
|
11
13
|
*
|
|
12
14
|
* `--kit <dir>` (or DESIGN_KIT_DIR) points at a kit; the default is the
|
|
13
15
|
* folder this file sits in. Format: the @codepress/design-kit README.
|
|
@@ -1834,10 +1836,16 @@ function excerpt(words, at, length) {
|
|
|
1834
1836
|
|
|
1835
1837
|
// ── show / find / info ──────────────────────────────────────────────────────
|
|
1836
1838
|
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1839
|
+
/**
|
|
1840
|
+
* An id attribute a library or React generated (`radix-…`, React's `useId`
|
|
1841
|
+
* values as the capture renumbers them): it changes between builds and says
|
|
1842
|
+
* nothing about the element, so the outline leaves it out.
|
|
1843
|
+
*/
|
|
1844
|
+
const GENERATED_ID = /^radix-|_r_?[0-9a-z]+_|_k\d+_|:r[0-9a-z]+:/;
|
|
1845
|
+
|
|
1846
|
+
/** One outline line per element down to depth 4, with its depth. */
|
|
1847
|
+
function outlineEntries(html) {
|
|
1848
|
+
const entries = [];
|
|
1841
1849
|
for (const element of elements(html)) {
|
|
1842
1850
|
if (element.depth > 4) continue;
|
|
1843
1851
|
const classes = (element.attrs.class ?? "")
|
|
@@ -1848,10 +1856,71 @@ export function outline(html, limit = SHOW_LIMIT) {
|
|
|
1848
1856
|
.join("");
|
|
1849
1857
|
const marks = ["id", "role", "data-kit-slot", "aria-label", "data-testid"]
|
|
1850
1858
|
.filter((name) => element.attrs[name])
|
|
1859
|
+
.filter((name) => name !== "id" || !GENERATED_ID.test(element.attrs.id))
|
|
1851
1860
|
.map((name) => `[${name}=${element.attrs[name].slice(0, 40)}]`)
|
|
1852
1861
|
.join("");
|
|
1853
1862
|
const text = element.text.replace(/\s+/g, " ").trim().slice(0, 40);
|
|
1854
|
-
|
|
1863
|
+
entries.push({
|
|
1864
|
+
depth: element.depth,
|
|
1865
|
+
line: `${" ".repeat(element.depth)}<${element.name}${classes}${marks}>${text ? ` "${text}"` : ""}`,
|
|
1866
|
+
});
|
|
1867
|
+
}
|
|
1868
|
+
return entries;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
/**
|
|
1872
|
+
* Runs of identical siblings, each with the lines nested under it, printed
|
|
1873
|
+
* once with `×N` after the first line.
|
|
1874
|
+
*/
|
|
1875
|
+
function collapseRuns(entries) {
|
|
1876
|
+
// ends[i]: the index after the last line nested under line i.
|
|
1877
|
+
const ends = new Array(entries.length).fill(entries.length);
|
|
1878
|
+
const open = [];
|
|
1879
|
+
entries.forEach((entry, i) => {
|
|
1880
|
+
while (open.length && entries[open.at(-1)].depth >= entry.depth)
|
|
1881
|
+
ends[open.pop()] = i;
|
|
1882
|
+
open.push(i);
|
|
1883
|
+
});
|
|
1884
|
+
const same = (a, b, length) => {
|
|
1885
|
+
for (let k = 0; k < length; k += 1)
|
|
1886
|
+
if (entries[a + k].line !== entries[b + k].line) return false;
|
|
1887
|
+
return true;
|
|
1888
|
+
};
|
|
1889
|
+
const lines = [];
|
|
1890
|
+
const walk = (from, to) => {
|
|
1891
|
+
let i = from;
|
|
1892
|
+
while (i < to) {
|
|
1893
|
+
const length = ends[i] - i;
|
|
1894
|
+
let next = ends[i];
|
|
1895
|
+
let count = 1;
|
|
1896
|
+
while (
|
|
1897
|
+
next < to &&
|
|
1898
|
+
entries[next].depth === entries[i].depth &&
|
|
1899
|
+
ends[next] - next === length &&
|
|
1900
|
+
same(i, next, length)
|
|
1901
|
+
) {
|
|
1902
|
+
count += 1;
|
|
1903
|
+
next = ends[next];
|
|
1904
|
+
}
|
|
1905
|
+
lines.push(`${entries[i].line}${count > 1 ? ` ×${count}` : ""}`);
|
|
1906
|
+
walk(i + 1, ends[i]);
|
|
1907
|
+
i = next;
|
|
1908
|
+
}
|
|
1909
|
+
};
|
|
1910
|
+
walk(0, entries.length);
|
|
1911
|
+
return lines;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
/**
|
|
1915
|
+
* A markup outline of at most `limit` bytes: one line per element down to
|
|
1916
|
+
* depth 4, identical sibling runs collapsed to one line with `×N`, and
|
|
1917
|
+
* generated ids left out.
|
|
1918
|
+
*/
|
|
1919
|
+
export function outline(html, limit = SHOW_LIMIT) {
|
|
1920
|
+
const lines = [];
|
|
1921
|
+
let size = 0;
|
|
1922
|
+
let truncated = false;
|
|
1923
|
+
for (const line of collapseRuns(outlineEntries(html))) {
|
|
1855
1924
|
// The limit is bytes: an outline line can carry non-ASCII text.
|
|
1856
1925
|
const bytes = Buffer.byteLength(line) + 1;
|
|
1857
1926
|
if (size + bytes > limit) {
|
|
@@ -1936,32 +2005,103 @@ export function find(kit, query) {
|
|
|
1936
2005
|
return findMatches(kit, query).map(({ row }) => row);
|
|
1937
2006
|
}
|
|
1938
2007
|
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
2008
|
+
/** Letters and digits only, lowercased: how a title can restate an id. */
|
|
2009
|
+
function flat(text) {
|
|
2010
|
+
return text.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
/** `story:x@1280` → `story:x`: the id without its width. */
|
|
2014
|
+
function baseId(id) {
|
|
2015
|
+
return id.replace(/@\d+$/, "");
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
/** The width an id ends in, as written. */
|
|
2019
|
+
function idWidth(id) {
|
|
2020
|
+
return /@(\d+)$/.exec(id)?.[1] ?? null;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
/** Sizes of a width group: `131/136 KB`, `443 B/1.2 KB`, or `4.9 KB` when they print the same. */
|
|
2024
|
+
function groupBytes(rows) {
|
|
2025
|
+
const sizes = rows.map((row) => row.bytes ?? 0);
|
|
2026
|
+
const kb = (bytes) =>
|
|
2027
|
+
bytes >= 10 * 1024
|
|
2028
|
+
? `${Math.round(bytes / 1024)}`
|
|
2029
|
+
: `${(bytes / 1024).toFixed(1)}`;
|
|
2030
|
+
// Widths that print the same size print it once.
|
|
2031
|
+
const once = (values) => [...new Set(values)].join("/");
|
|
2032
|
+
if (sizes.every((bytes) => bytes >= 1024)) return `${once(sizes.map(kb))} KB`;
|
|
2033
|
+
return once(
|
|
2034
|
+
sizes.map((bytes) => (bytes >= 1024 ? `${kb(bytes)} KB` : `${bytes} B`)),
|
|
2035
|
+
);
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
/**
|
|
2039
|
+
* Matches gathered into lines: rows that differ only by `@<width>` and found
|
|
2040
|
+
* the same way (the same excerpt, or none) share one line, at the place of
|
|
2041
|
+
* the first of them.
|
|
2042
|
+
*/
|
|
2043
|
+
function widthGroups(matches) {
|
|
2044
|
+
const groups = new Map();
|
|
2045
|
+
for (const match of matches) {
|
|
2046
|
+
// An id without a width never shares a line.
|
|
2047
|
+
const id = match.row.id;
|
|
2048
|
+
const key = `${idWidth(id) ? baseId(id) : `${id}\0`}\0${match.excerpt ?? ""}`;
|
|
2049
|
+
const group = groups.get(key);
|
|
2050
|
+
if (group) group.rows.push(match.row);
|
|
2051
|
+
else groups.set(key, { rows: [match.row], excerpt: match.excerpt });
|
|
1949
2052
|
}
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
2053
|
+
return [...groups.values()];
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
/** One find line: id (with its widths), sizes, title unless it restates the id, excerpt. */
|
|
2057
|
+
function groupLine({ rows, excerpt: cut }) {
|
|
2058
|
+
const base = baseId(rows[0].id);
|
|
2059
|
+
const widths = rows.map((row) => idWidth(row.id));
|
|
2060
|
+
const id = rows.length === 1 ? rows[0].id : `${base}@{${widths.join(",")}}`;
|
|
2061
|
+
const name = flat(base.replace(/^[a-z]+:/, ""));
|
|
2062
|
+
const titles = [];
|
|
2063
|
+
for (const row of rows) {
|
|
2064
|
+
const title = (row.title ?? "").replace(/\s*\(\d+\)$/, "").trim();
|
|
2065
|
+
if (title && flat(title) !== name && !titles.includes(title))
|
|
2066
|
+
titles.push(title);
|
|
2067
|
+
}
|
|
2068
|
+
return [
|
|
2069
|
+
id,
|
|
2070
|
+
groupBytes(rows),
|
|
2071
|
+
...(titles.length ? [titles.join(" | ")] : []),
|
|
2072
|
+
...(cut === undefined ? [] : [`"${cut}"`]),
|
|
2073
|
+
].join(" ");
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
/**
|
|
2077
|
+
* `kit find`'s lines for `findMatches` results, at most FIND_LIMIT: rows that
|
|
2078
|
+
* differ only by width share a line (`route:x@{1280,390}`), a title that only
|
|
2079
|
+
* restates the id is left out, and a last line counts the rows not shown.
|
|
2080
|
+
* Rows found by name come first, but a few lines are kept for rows found only
|
|
2081
|
+
* by their on-screen text.
|
|
2082
|
+
*/
|
|
2083
|
+
export function findMatchLines(matches) {
|
|
2084
|
+
const count = (groups) =>
|
|
2085
|
+
groups.reduce((sum, group) => sum + group.rows.length, 0);
|
|
1954
2086
|
// A row with an excerpt was found by on-screen text; any other, by name.
|
|
1955
|
-
const
|
|
2087
|
+
const byName = widthGroups(
|
|
2088
|
+
matches.filter((match) => match.excerpt === undefined),
|
|
2089
|
+
);
|
|
2090
|
+
const byText = widthGroups(
|
|
2091
|
+
matches.filter((match) => match.excerpt !== undefined),
|
|
2092
|
+
);
|
|
2093
|
+
// Keep the order findMatches ranked them in when everything fits.
|
|
2094
|
+
if (byName.length + byText.length <= FIND_LIMIT)
|
|
2095
|
+
return widthGroups(matches).map(groupLine);
|
|
2096
|
+
const room = FIND_LIMIT - 1;
|
|
1956
2097
|
if (!byText.length) {
|
|
1957
2098
|
return [
|
|
1958
|
-
...
|
|
1959
|
-
`… ${
|
|
2099
|
+
...byName.slice(0, room).map(groupLine),
|
|
2100
|
+
`… ${count(byName.slice(room))} more; add a word to narrow it`,
|
|
1960
2101
|
];
|
|
1961
2102
|
}
|
|
1962
2103
|
// Names fill the list first, but the last lines are kept for text hits,
|
|
1963
2104
|
// so an agent sees them even when names alone would fill it.
|
|
1964
|
-
const byName = matches.filter((match) => match.excerpt === undefined);
|
|
1965
2105
|
const textRoom = Math.min(
|
|
1966
2106
|
byText.length,
|
|
1967
2107
|
Math.max(FIND_TEXT_LINES, room - byName.length),
|
|
@@ -1970,19 +2110,33 @@ function findLines(kit, query) {
|
|
|
1970
2110
|
const texts = byText.slice(0, room - names.length);
|
|
1971
2111
|
const left = [
|
|
1972
2112
|
...(byName.length > names.length
|
|
1973
|
-
? [`+${byName.
|
|
2113
|
+
? [`+${count(byName.slice(names.length))} more by name`]
|
|
1974
2114
|
: []),
|
|
1975
2115
|
...(byText.length > texts.length
|
|
1976
|
-
? [`+${byText.
|
|
2116
|
+
? [`+${count(byText.slice(texts.length))} more by on-screen text`]
|
|
1977
2117
|
: []),
|
|
1978
2118
|
];
|
|
1979
2119
|
return [
|
|
1980
|
-
...names.map(
|
|
1981
|
-
...texts.map(
|
|
2120
|
+
...names.map(groupLine),
|
|
2121
|
+
...texts.map(groupLine),
|
|
1982
2122
|
`${left.join(", ")}; narrow the query`,
|
|
1983
2123
|
];
|
|
1984
2124
|
}
|
|
1985
2125
|
|
|
2126
|
+
function findLines(kit, query) {
|
|
2127
|
+
const matches = findMatches(kit, query);
|
|
2128
|
+
if (!matches.length) {
|
|
2129
|
+
const near = nearMatches(
|
|
2130
|
+
query,
|
|
2131
|
+
kit.rows.map((row) => row.id),
|
|
2132
|
+
);
|
|
2133
|
+
throw new KitError(
|
|
2134
|
+
`nothing matches "${query}".${near.length ? ` Near: ${near.join(", ")}` : ""}`,
|
|
2135
|
+
);
|
|
2136
|
+
}
|
|
2137
|
+
return findMatchLines(matches);
|
|
2138
|
+
}
|
|
2139
|
+
|
|
1986
2140
|
function showText(kit, id, full) {
|
|
1987
2141
|
const row = requireRow(kit, id, "id");
|
|
1988
2142
|
const html = readSnippet(kit, row);
|
|
@@ -2003,23 +2157,149 @@ function showText(kit, id, full) {
|
|
|
2003
2157
|
return [head, ...lines, ...(truncated ? [tail] : [])].join("\n");
|
|
2004
2158
|
}
|
|
2005
2159
|
|
|
2006
|
-
|
|
2160
|
+
/** gaps.json's ids: the frames a capture failed on. */
|
|
2161
|
+
function failedIds(dir) {
|
|
2162
|
+
try {
|
|
2163
|
+
const gaps = JSON.parse(readFileSync(join(dir, "gaps.json"), "utf8"));
|
|
2164
|
+
if (!Array.isArray(gaps)) return [];
|
|
2165
|
+
return gaps.map((gap) => gap?.id).filter((id) => typeof id === "string");
|
|
2166
|
+
} catch {
|
|
2167
|
+
// Optional: info prints the count without the ids.
|
|
2168
|
+
return [];
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
/**
|
|
2173
|
+
* The kit's one-line summary: `sha7 built <iso> · stories N · routes N ·
|
|
2174
|
+
* failed N`, with the failed ids when there are five or fewer. `kit info`
|
|
2175
|
+
* prints it, and so does `design-kit fetch` after `path=`.
|
|
2176
|
+
*/
|
|
2177
|
+
export function kitSummaryLine(kit) {
|
|
2178
|
+
const manifest = kit.manifest ?? {};
|
|
2007
2179
|
const routes = kit.rows.filter((row) => rowSource(row) === "routes").length;
|
|
2180
|
+
const sha = typeof manifest.sha === "string" ? manifest.sha.slice(0, 7) : "";
|
|
2181
|
+
const built =
|
|
2182
|
+
typeof manifest.built_at === "string" ? `built ${manifest.built_at}` : "";
|
|
2183
|
+
const ids = failedIds(kit.dir);
|
|
2184
|
+
const counted = manifest.counts?.failed;
|
|
2185
|
+
const failed = typeof counted === "number" ? counted : ids.length;
|
|
2186
|
+
const head = kit.manifest
|
|
2187
|
+
? [sha, built].filter(Boolean).join(" ") || "manifest.json"
|
|
2188
|
+
: "no manifest.json";
|
|
2008
2189
|
return [
|
|
2009
|
-
|
|
2010
|
-
`
|
|
2011
|
-
|
|
2190
|
+
head,
|
|
2191
|
+
`stories ${kit.rows.length - routes}`,
|
|
2192
|
+
`routes ${routes}`,
|
|
2193
|
+
`failed ${failed}${failed > 0 && failed <= 5 && ids.length ? ` (${ids.join(", ")})` : ""}`,
|
|
2194
|
+
].join(" · ");
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
/** `info`: the summary line; `json` prints manifest.json. */
|
|
2198
|
+
function infoText(kit, json) {
|
|
2199
|
+
if (json)
|
|
2200
|
+
return JSON.stringify(
|
|
2201
|
+
kit.manifest ?? { missing: "manifest.json" },
|
|
2202
|
+
null,
|
|
2203
|
+
2,
|
|
2204
|
+
);
|
|
2205
|
+
return kitSummaryLine(kit);
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
// ── figure ──────────────────────────────────────────────────────────────────
|
|
2209
|
+
|
|
2210
|
+
/** Image types `figure` embeds, by file extension. */
|
|
2211
|
+
const FIGURE_TYPES = {
|
|
2212
|
+
".png": "image/png",
|
|
2213
|
+
".jpg": "image/jpeg",
|
|
2214
|
+
".jpeg": "image/jpeg",
|
|
2215
|
+
".webp": "image/webp",
|
|
2216
|
+
};
|
|
2217
|
+
|
|
2218
|
+
/** Above this many image bytes `figure` warns: the page carries them all. */
|
|
2219
|
+
export const FIGURE_WARN_BYTES = 1024 * 1024;
|
|
2220
|
+
|
|
2221
|
+
function escapeHtml(value) {
|
|
2222
|
+
return String(value)
|
|
2223
|
+
.replaceAll("&", "&")
|
|
2224
|
+
.replaceAll("<", "<")
|
|
2225
|
+
.replaceAll(">", ">")
|
|
2226
|
+
.replaceAll('"', """);
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
/**
|
|
2230
|
+
* A self-contained `<figure>`: the image as a data URI, inside a link when
|
|
2231
|
+
* `href` is given, with an escaped caption. `href` must be a fragment or a
|
|
2232
|
+
* relative link (`#cp-page:<key>` links plan pages); `width` is CSS pixels.
|
|
2233
|
+
*/
|
|
2234
|
+
export function figureHtml({ image, type, href, caption, alt, width }) {
|
|
2235
|
+
if (
|
|
2236
|
+
href !== undefined &&
|
|
2237
|
+
(!href || !isSafeUrl(href) || /^data:/i.test(href.trim()))
|
|
2238
|
+
)
|
|
2239
|
+
throw new KitError(
|
|
2240
|
+
`--href ${href}: use a fragment such as '#cp-page:<key>' or a relative link`,
|
|
2241
|
+
);
|
|
2242
|
+
let style = "max-width:100%;height:auto;display:block";
|
|
2243
|
+
if (width !== undefined) {
|
|
2244
|
+
const px = Number(width);
|
|
2245
|
+
if (!(Number.isFinite(px) && px > 0))
|
|
2246
|
+
throw new KitError(
|
|
2247
|
+
`--width ${width}: use a positive number of CSS pixels`,
|
|
2248
|
+
);
|
|
2249
|
+
style = `width:${px}px;${style}`;
|
|
2250
|
+
}
|
|
2251
|
+
const src = `data:${type};base64,${image.toString("base64")}`;
|
|
2252
|
+
let img = `<img src="${src}" alt="${escapeHtml(alt ?? caption ?? "")}" style="${style}">`;
|
|
2253
|
+
if (href !== undefined) img = `<a href="${escapeHtml(href)}">${img}</a>`;
|
|
2254
|
+
const figcaption =
|
|
2255
|
+
caption === undefined
|
|
2256
|
+
? ""
|
|
2257
|
+
: `\n <figcaption>${escapeHtml(caption)}</figcaption>`;
|
|
2258
|
+
return `<figure style="margin:0">\n ${img}${figcaption}\n</figure>\n`;
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
/** `figure --img <file> --out <file>`: writes the fragment; no kit needed. */
|
|
2262
|
+
function figureCommand(options, io) {
|
|
2263
|
+
if (!options.img)
|
|
2264
|
+
throw new KitError("figure needs --img <file.png|jpg|webp>");
|
|
2265
|
+
if (!options.out) throw new KitError("figure needs --out <file>");
|
|
2266
|
+
const file = resolve(options.img);
|
|
2267
|
+
const type = FIGURE_TYPES[extname(file).toLowerCase()];
|
|
2268
|
+
if (!type)
|
|
2269
|
+
throw new KitError(`--img ${options.img}: use a .png, .jpg or .webp file`);
|
|
2270
|
+
if (!existsSync(file))
|
|
2271
|
+
throw new KitError(`--img ${options.img}: no such file`);
|
|
2272
|
+
const image = readFileSync(file);
|
|
2273
|
+
const html = figureHtml({
|
|
2274
|
+
image,
|
|
2275
|
+
type,
|
|
2276
|
+
href: options.href,
|
|
2277
|
+
caption: options.caption,
|
|
2278
|
+
alt: options.alt,
|
|
2279
|
+
width: options.width,
|
|
2280
|
+
});
|
|
2281
|
+
if (image.length > FIGURE_WARN_BYTES)
|
|
2282
|
+
io.err(
|
|
2283
|
+
`kit: warning: ${options.img} is ${formatBytes(image.length)}; the page carries it all (shrink it below 1 MB)`,
|
|
2284
|
+
);
|
|
2285
|
+
const out = resolve(options.out);
|
|
2286
|
+
writeFileSync(out, html);
|
|
2287
|
+
io.out(`${out} ${formatBytes(Buffer.byteLength(html))}`);
|
|
2012
2288
|
}
|
|
2013
2289
|
|
|
2014
2290
|
// ── CLI ─────────────────────────────────────────────────────────────────────
|
|
2015
2291
|
|
|
2016
2292
|
const USAGE = `usage: kit.mjs [--kit <dir>] <command>
|
|
2017
|
-
info
|
|
2018
|
-
find <query>
|
|
2293
|
+
info [--json] sha, build time and counts (--json: the manifest)
|
|
2294
|
+
find <query> up to ${FIND_LIMIT} lines of rows whose id, title or on-screen text holds
|
|
2295
|
+
every word; widths of one frame share a line: id@{1280,390}
|
|
2019
2296
|
show <id> [--full] path, size and an outline (--full: the markup)
|
|
2020
2297
|
compose --frame <id> [--slot <name>=<file>]… [--theme light|dark] [--show-new] [--css pruned|full] --out <file>
|
|
2021
2298
|
--css full keeps every rule of the kit's CSS; the default
|
|
2022
|
-
keeps only rules the page's classes and ids can use
|
|
2299
|
+
keeps only rules the page's classes and ids can use
|
|
2300
|
+
figure --img <file.png|jpg|webp> [--href '#cp-page:<key>'] [--caption <text>] [--alt <text>] [--width <px>] --out <file>
|
|
2301
|
+
a self-contained <figure>: the image as a data URI, linked when
|
|
2302
|
+
--href is given, the caption escaped (no kit needed)`;
|
|
2023
2303
|
|
|
2024
2304
|
function parseArgs(argv) {
|
|
2025
2305
|
const options = { slots: [], positional: [] };
|
|
@@ -2037,7 +2317,13 @@ function parseArgs(argv) {
|
|
|
2037
2317
|
else if (arg === "--theme") options.theme = value();
|
|
2038
2318
|
else if (arg === "--out") options.out = value();
|
|
2039
2319
|
else if (arg === "--css") options.css = value();
|
|
2320
|
+
else if (arg === "--img") options.img = value();
|
|
2321
|
+
else if (arg === "--href") options.href = value();
|
|
2322
|
+
else if (arg === "--caption") options.caption = value();
|
|
2323
|
+
else if (arg === "--alt") options.alt = value();
|
|
2324
|
+
else if (arg === "--width") options.width = value();
|
|
2040
2325
|
else if (arg === "--full") options.full = true;
|
|
2326
|
+
else if (arg === "--json") options.json = true;
|
|
2041
2327
|
else if (arg === "--show-new") options.showNew = true;
|
|
2042
2328
|
else if (arg === "--help" || arg === "-h") options.help = true;
|
|
2043
2329
|
else if (arg.startsWith("--")) throw new KitError(`unknown option ${arg}`);
|
|
@@ -2060,10 +2346,15 @@ export function run(
|
|
|
2060
2346
|
io.out(USAGE);
|
|
2061
2347
|
return command || options.help ? 0 : 2;
|
|
2062
2348
|
}
|
|
2349
|
+
if (command === "figure") {
|
|
2350
|
+
if (rest.length) throw new KitError(`figure takes no ${rest[0]}`);
|
|
2351
|
+
figureCommand(options, io);
|
|
2352
|
+
return 0;
|
|
2353
|
+
}
|
|
2063
2354
|
const dir = resolve(options.kit ?? env.DESIGN_KIT_DIR ?? defaultDir);
|
|
2064
2355
|
const kit = loadKit(dir);
|
|
2065
2356
|
if (command === "info") {
|
|
2066
|
-
io.out(infoText(kit));
|
|
2357
|
+
io.out(infoText(kit, options.json));
|
|
2067
2358
|
} else if (command === "find") {
|
|
2068
2359
|
if (!rest.length) throw new KitError("find needs a query");
|
|
2069
2360
|
io.out(findLines(kit, rest.join(" ")).join("\n"));
|
package/src/storage/index.mjs
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* upload({dir, branch, sha}) → {sha, branch, sizeBytes}
|
|
7
7
|
* fetch({branch, out}) → {path, sha, branch, cached}
|
|
8
8
|
*
|
|
9
|
+
* `fetchFromUrl` (url.mjs) installs a kit from a download URL with no store.
|
|
10
|
+
*
|
|
9
11
|
* `github` (the default) is a GitHub Actions artifact; `codepress` is
|
|
10
12
|
* CodePress; `s3` and `r2` are the user's own bucket. The commands
|
|
11
13
|
* `design-kit upload`, `fetch` and `latest` are here too.
|
|
@@ -18,6 +20,8 @@ import { bucketStorage } from "./bucket.mjs";
|
|
|
18
20
|
import { codepressStorage } from "./codepress.mjs";
|
|
19
21
|
import { findRepo, originDefaultBranch } from "./common.mjs";
|
|
20
22
|
import { githubStorage } from "./github.mjs";
|
|
23
|
+
import { fetchFromUrl } from "./url.mjs";
|
|
24
|
+
import { kitSummaryLine, loadKit } from "../kit.mjs";
|
|
21
25
|
|
|
22
26
|
export { StorageError } from "./common.mjs";
|
|
23
27
|
|
|
@@ -99,25 +103,47 @@ export async function uploadCommand(
|
|
|
99
103
|
/**
|
|
100
104
|
* `design-kit fetch [--branch b] [--out dir]`: downloads the newest kit
|
|
101
105
|
* (once per commit: it is cached under $TMPDIR/design-kit-<sha>) and prints
|
|
102
|
-
* `path=`, `sha=` and
|
|
106
|
+
* `path=`, `sha=`, `branch=` and the kit's summary line.
|
|
107
|
+
*
|
|
108
|
+
* `--url <download_url> --sha <commit>` installs from a download URL already
|
|
109
|
+
* looked up (no credential, no config): same cache, same output.
|
|
103
110
|
*/
|
|
104
111
|
export async function fetchCommand(
|
|
105
112
|
options,
|
|
106
113
|
{ env = process.env, gh, aws } = {},
|
|
107
114
|
) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
let kit;
|
|
116
|
+
if (options.url !== undefined || options.sha !== undefined) {
|
|
117
|
+
if (!options.url || !options.sha)
|
|
118
|
+
throw new ConfigError("fetch --url <download_url> needs --sha <commit>");
|
|
119
|
+
for (const name of ["config", "repo", "token-file"])
|
|
120
|
+
if (options[name] !== undefined)
|
|
121
|
+
throw new ConfigError(
|
|
122
|
+
`fetch --url takes no --${name}: it reads no store`,
|
|
123
|
+
);
|
|
124
|
+
kit = await fetchFromUrl({
|
|
125
|
+
url: options.url,
|
|
126
|
+
sha: options.sha,
|
|
127
|
+
branch: options.branch,
|
|
128
|
+
out: options.out,
|
|
129
|
+
env,
|
|
130
|
+
});
|
|
131
|
+
} else {
|
|
132
|
+
const config = loadConfig(options.config);
|
|
133
|
+
const storage = storageOf(config);
|
|
134
|
+
const store = storageFor(
|
|
135
|
+
storage,
|
|
136
|
+
contextFor(options, config, { env, gh, aws }),
|
|
137
|
+
);
|
|
138
|
+
kit = await store.fetch({
|
|
139
|
+
branch: readBranch(options, storage, config.configDir),
|
|
140
|
+
out: options.out,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
118
143
|
console.log(`path=${kit.path}`);
|
|
119
144
|
console.log(`sha=${kit.sha}`);
|
|
120
145
|
if (kit.branch) console.log(`branch=${kit.branch}`);
|
|
146
|
+
console.log(kitSummaryLine(loadKit(kit.path)));
|
|
121
147
|
return 0;
|
|
122
148
|
}
|
|
123
149
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `design-kit fetch --url <download_url> --sha <commit>`: installs a kit
|
|
3
|
+
* from a download URL someone else already looked up (the `download_url` of
|
|
4
|
+
* `GET /design-kits/repos/{owner}/{repo}/latest`, read through the Platform
|
|
5
|
+
* MCP), with no credential and no config.
|
|
6
|
+
*
|
|
7
|
+
* The kit lands where the key path puts it, `$TMPDIR/design-kit-<sha>`, and a
|
|
8
|
+
* complete folder there is reused without downloading. Otherwise the zip is
|
|
9
|
+
* streamed to a scratch folder beside the target, unzipped there with the
|
|
10
|
+
* package's own reader, checked (its manifest.json must name `sha`), and
|
|
11
|
+
* renamed into place in one step. A fetch that loses a race to another for
|
|
12
|
+
* the same commit keeps the winner's folder. A failure leaves nothing behind.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
createWriteStream,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
mkdtempSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
renameSync,
|
|
20
|
+
rmSync,
|
|
21
|
+
} from "node:fs";
|
|
22
|
+
import http from "node:http";
|
|
23
|
+
import https from "node:https";
|
|
24
|
+
import { dirname, join, resolve } from "node:path";
|
|
25
|
+
import { pipeline } from "node:stream/promises";
|
|
26
|
+
|
|
27
|
+
import { StorageError, checkSha, isKit, kitCacheDir } from "./common.mjs";
|
|
28
|
+
import { unzipTo } from "./zip.mjs";
|
|
29
|
+
|
|
30
|
+
/** How long the download may stall before it counts as failed. */
|
|
31
|
+
const DOWNLOAD_TIMEOUT_MS = Number(
|
|
32
|
+
process.env.DESIGN_KIT_REQUEST_TIMEOUT_MS || 120_000,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
/** What an S3- or R2-style refusal is called, by status. */
|
|
36
|
+
const REFUSED = {
|
|
37
|
+
400: "bad_request",
|
|
38
|
+
401: "unauthorized",
|
|
39
|
+
403: "forbidden",
|
|
40
|
+
404: "not_found",
|
|
41
|
+
410: "expired",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** The query string of a URL replaced by `?…`, so a signature never prints. */
|
|
45
|
+
export function redactUrl(url) {
|
|
46
|
+
return String(url).replace(/\?.*$/s, "?…");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Streams `url` into `file`. A non-2xx answer or a broken stream throws. */
|
|
50
|
+
function download(url, file) {
|
|
51
|
+
let parsed;
|
|
52
|
+
try {
|
|
53
|
+
parsed = new URL(url);
|
|
54
|
+
} catch {
|
|
55
|
+
throw new StorageError("invalid_url", "--url is not a URL");
|
|
56
|
+
}
|
|
57
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
|
|
58
|
+
throw new StorageError(
|
|
59
|
+
"invalid_url",
|
|
60
|
+
"--url must be an https: download URL",
|
|
61
|
+
);
|
|
62
|
+
const client = parsed.protocol === "http:" ? http : https;
|
|
63
|
+
return new Promise((resolvePromise, reject) => {
|
|
64
|
+
const req = client.get(parsed, { timeout: DOWNLOAD_TIMEOUT_MS }, (res) => {
|
|
65
|
+
const status = res.statusCode ?? 0;
|
|
66
|
+
if (status < 200 || status >= 300) {
|
|
67
|
+
res.resume();
|
|
68
|
+
const code =
|
|
69
|
+
REFUSED[status] ??
|
|
70
|
+
(status >= 500 ? "kit_storage_unavailable" : `http_${status}`);
|
|
71
|
+
const hint =
|
|
72
|
+
status === 403 || status === 400 || status === 410
|
|
73
|
+
? "; the download URL may have expired, so ask for a fresh one"
|
|
74
|
+
: "";
|
|
75
|
+
reject(
|
|
76
|
+
new StorageError(
|
|
77
|
+
code,
|
|
78
|
+
`downloading ${redactUrl(url)} was refused (${status})${hint}`,
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
pipeline(res, createWriteStream(file)).then(resolvePromise, (error) =>
|
|
84
|
+
reject(
|
|
85
|
+
new StorageError(
|
|
86
|
+
"download_failed",
|
|
87
|
+
`downloading ${redactUrl(url)} broke off (${error.code ?? error.message})`,
|
|
88
|
+
),
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
req.on("timeout", () => req.destroy(new Error("no answer in time")));
|
|
93
|
+
req.on("error", (error) =>
|
|
94
|
+
reject(
|
|
95
|
+
new StorageError(
|
|
96
|
+
"kit_storage_unavailable",
|
|
97
|
+
`could not reach ${parsed.host} (${error.code ?? error.message})`,
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Nothing when manifest.json's `sha` starts with `sha`, else a StorageError. */
|
|
105
|
+
function checkManifestSha(dir, sha) {
|
|
106
|
+
let manifest;
|
|
107
|
+
try {
|
|
108
|
+
manifest = JSON.parse(readFileSync(join(dir, "manifest.json"), "utf8"));
|
|
109
|
+
} catch {
|
|
110
|
+
throw new StorageError(
|
|
111
|
+
"not_a_kit",
|
|
112
|
+
"the download has no readable manifest.json",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
const named =
|
|
116
|
+
typeof manifest?.sha === "string" ? manifest.sha.toLowerCase() : "";
|
|
117
|
+
const wanted = sha.toLowerCase();
|
|
118
|
+
// A short --sha may name the manifest's full sha, never the other way round.
|
|
119
|
+
if (!named || !named.startsWith(wanted))
|
|
120
|
+
throw new StorageError(
|
|
121
|
+
"sha_mismatch",
|
|
122
|
+
`the downloaded kit is of ${named.slice(0, 12) || "no commit"}, not ${wanted.slice(0, 12)}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Installs the kit of commit `sha` from `url` into `out` (default
|
|
128
|
+
* `$TMPDIR/design-kit-<sha>`): `{path, sha, branch, cached}`.
|
|
129
|
+
*/
|
|
130
|
+
export async function fetchFromUrl({
|
|
131
|
+
url,
|
|
132
|
+
sha,
|
|
133
|
+
branch,
|
|
134
|
+
out,
|
|
135
|
+
env = process.env,
|
|
136
|
+
}) {
|
|
137
|
+
if (!url)
|
|
138
|
+
throw new StorageError("invalid_url", "--url needs the kit's download URL");
|
|
139
|
+
checkSha(sha, "--sha");
|
|
140
|
+
const path = resolve(out ?? kitCacheDir(sha, env));
|
|
141
|
+
const result = { path, sha, branch };
|
|
142
|
+
if (isKit(path)) return { ...result, cached: true };
|
|
143
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
144
|
+
const scratch = mkdtempSync(`${path}.partial-`);
|
|
145
|
+
try {
|
|
146
|
+
const zipFile = join(scratch, "kit.zip");
|
|
147
|
+
const unpacked = join(scratch, "kit");
|
|
148
|
+
await download(url, zipFile);
|
|
149
|
+
try {
|
|
150
|
+
unzipTo(readFileSync(zipFile), unpacked);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
throw new StorageError(
|
|
153
|
+
"bad_archive",
|
|
154
|
+
`the download is not a whole kit zip (${error.message})`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
checkManifestSha(unpacked, sha);
|
|
158
|
+
try {
|
|
159
|
+
renameSync(unpacked, path);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
// Another fetch of the same commit got there first: keep its folder.
|
|
162
|
+
if (isKit(path)) return { ...result, cached: true };
|
|
163
|
+
if (error.code === "ENOTEMPTY" || error.code === "EEXIST")
|
|
164
|
+
throw new StorageError(
|
|
165
|
+
"out_not_empty",
|
|
166
|
+
`${path} exists and is not a kit; choose another --out`,
|
|
167
|
+
);
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
return { ...result, cached: false };
|
|
171
|
+
} finally {
|
|
172
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -71,7 +71,7 @@ is missing.
|
|
|
71
71
|
## 3. Find what is real
|
|
72
72
|
|
|
73
73
|
```bash
|
|
74
|
-
node "$KIT/kit.mjs" find button # ≤ 30 lines: id
|
|
74
|
+
node "$KIT/kit.mjs" find button # ≤ 30 lines: id (widths grouped), size, title
|
|
75
75
|
node "$KIT/kit.mjs" show route:home@1280 # path, size and an outline with slots
|
|
76
76
|
```
|
|
77
77
|
|