@yargote/artifacts-cli 0.1.0-beta.5 → 0.1.0-beta.7
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 +35 -9
- package/package.json +3 -3
- package/skills/artifacts-cli/SKILL.md +16 -4
- package/skills/artifacts-cli/references/commands.md +40 -1
- package/skills/artifacts-cli/references/verify-retained.md +119 -0
- package/skills/artifacts-cli/scripts/prepare-retained.mjs +149 -0
- package/skills/artifacts-design/SKILL.md +12 -2
- package/skills/artifacts-design/references/canvas.md +100 -0
- package/skills/artifacts-design/references/local-checks.md +70 -0
- package/skills/artifacts-design/scripts/embed-fonts.mjs +208 -7
- package/skills/artifacts-design/scripts/verify-local.mjs +333 -0
- package/src/index.js +14 -3
- package/src/operations.d.ts +48 -2
- package/src/operations.js +7 -1
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Author a design canvas
|
|
2
|
+
|
|
3
|
+
Create a retained `design` snapshot when several HTML boards belong in one canvas. Each board is a complete self-contained HTML document. The canvas supplies placement, page selection, notes, pan, zoom, and a focus view. The calling MCP or CLI skill owns saving and publication.
|
|
4
|
+
|
|
5
|
+
## Compose the boards
|
|
6
|
+
|
|
7
|
+
1. Choose a frame size for each board. Common starting sizes are 1440 by 900 for desktop, 390 by 844 for a phone, and 768 by 1024 for a tablet. These are authoring conventions, not schema limits.
|
|
8
|
+
2. Place boards in a row with 80 px between their edges. Start another row 120 px below the tallest board in the preceding row. Coordinates are canvas pixels, and boards can use different sizes.
|
|
9
|
+
3. Write each board under `boards/` as one UTF-8 `.html` file. Inline its styles, scripts, images, and fonts. Files retained beside a board are downloads, not relative executable assets.
|
|
10
|
+
4. Apply this skill's typography and Catppuccin tokens inside each board. The canvas ground follows the viewer's light or dark theme independently.
|
|
11
|
+
5. Define `canvas.json` with the exact index below. Keep snapshot metadata outside the index.
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"v": 1,
|
|
16
|
+
"boards": {
|
|
17
|
+
"boards/Desktop.html": { "x": 0, "y": 0, "w": 1440, "h": 900, "title": "Desktop", "page": "overview" },
|
|
18
|
+
"boards/Phone.html": { "x": 1520, "y": 0, "w": 390, "h": 844, "title": "Phone", "page": "overview" },
|
|
19
|
+
"boards/Detail.html": { "x": 0, "y": 0, "w": 1440, "h": 900, "title": "Detail", "page": "detail" },
|
|
20
|
+
"boards/DetailPhone.html": { "x": 1520, "y": 0, "w": 390, "h": 844, "title": "Detail phone", "page": "detail" }
|
|
21
|
+
},
|
|
22
|
+
"order": ["boards/Desktop.html", "boards/Phone.html", "boards/Detail.html", "boards/DetailPhone.html"],
|
|
23
|
+
"notes": {
|
|
24
|
+
"review": { "x": 0, "y": 1020, "w": 390, "text": "Review both layouts.", "page": "overview" }
|
|
25
|
+
},
|
|
26
|
+
"pages": [
|
|
27
|
+
{ "id": "overview", "name": "Overview" },
|
|
28
|
+
{ "id": "detail", "name": "Detail" }
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The first page opens initially. Boards and notes without `page` appear on the first page when pages exist. Without pages, every board and note appears together. A page can reuse the coordinates of another page.
|
|
34
|
+
|
|
35
|
+
## Index reference
|
|
36
|
+
|
|
37
|
+
The index and its nested entries reject unknown fields. The complete rules are:
|
|
38
|
+
|
|
39
|
+
| Field | Rule |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| `v` | Required integer `1`. |
|
|
42
|
+
| `boards` | Required object with at most 32 entries. Every key matches `^boards/[A-Za-z0-9_][A-Za-z0-9_.-]*\.html$`. Names are case-sensitive and flat. |
|
|
43
|
+
| Board `x`, `y` | Required finite numbers from -1,000,000 to 1,000,000 inclusive. Fractions are accepted. |
|
|
44
|
+
| Board `w`, `h` | Required finite numbers from 40 to 8000 inclusive. Fractions are accepted. |
|
|
45
|
+
| Board `title` | Optional string of 1 to 256 characters. The board path is the display fallback. |
|
|
46
|
+
| Board `page` | Optional ID that names a declared page. |
|
|
47
|
+
| `order` | Required array of 1 to 32 board paths. It names every board exactly once and contains no other path. Its order controls rendering. |
|
|
48
|
+
| `notes` | Optional object with at most 200 entries. Each key is an ID. |
|
|
49
|
+
| Note `x`, `y`, `text` | Coordinates use the board coordinate limits. `text` is a required string rendered as escaped text, not HTML. |
|
|
50
|
+
| Note `w`, `page` | Optional width from 40 to 8000 and an optional declared page ID. |
|
|
51
|
+
| `pages` | Optional array of at most 32 objects with required `id` and `name`. IDs are unique. `name` contains 1 to 256 characters. |
|
|
52
|
+
| ID | 1 to 40 ASCII letters, digits, underscores, or hyphens. `__proto__` is rejected. |
|
|
53
|
+
|
|
54
|
+
Every indexed board has a matching snapshot file with the same `path`, `mediaType: "text/html"`, `encoding: "utf8"`, and the complete HTML in `data`. A snapshot has at most 32 files total, including other retained downloads. The directory preparer rejects every unindexed entry in `boards/`, missing boards, directories, symbolic links, and invalid UTF-8. The snapshot contract itself permits other unindexed attachments.
|
|
55
|
+
|
|
56
|
+
## Embed fonts within the shared budget
|
|
57
|
+
|
|
58
|
+
Run the real font helper once for each board after completing its HTML:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
node <design-skill-directory>/scripts/embed-fonts.mjs my-canvas/boards/Desktop.html
|
|
62
|
+
node <design-skill-directory>/scripts/embed-fonts.mjs my-canvas/boards/Phone.html
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
For the CLI, resolve `<design-skill-directory>` with `artifacts skills path artifacts-design`. The helper selects bundled Latin font subsets from actual CSS declarations and embeds `data:` WOFF2 faces. It rewrites the board in place and is safe to rerun.
|
|
66
|
+
|
|
67
|
+
Use one small, consistent selection of bundled families and styles across boards. Each board needs its own embedded font bytes because each iframe is an independent document. A subset cannot be shared through an attachment URL. Three families add about 135 KB per board, so repeated embedding can exhaust the canvas budget. Prefer one family and only the styles the board uses when many boards share one revision. The helper checks one board's 1 MiB limit. The preparer and server check the combined revision.
|
|
68
|
+
|
|
69
|
+
The shared limit is 1,048,576 decoded bytes. It includes serialized snapshot metadata, UTF-8 index text, every file path and media type, and every decoded file body. Whitespace in `canvas.json` counts. The 1 MiB limit applies to all boards together, not to each board separately.
|
|
70
|
+
|
|
71
|
+
## Save the canvas
|
|
72
|
+
|
|
73
|
+
For MCP, send one complete snapshot to `artifacts_create` or `artifacts_update`:
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
{
|
|
77
|
+
metadata: { title: 'Product layouts', description: 'Desktop and phone review.' },
|
|
78
|
+
content: { kind: 'design', text: JSON.stringify(index) },
|
|
79
|
+
files: boardPaths.map((path) => ({ path, mediaType: 'text/html', encoding: 'utf8', data: boardHtml[path] }))
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Here `index`, `boardPaths`, and `boardHtml` come from your completed source files. Read the saved revision back through `artifacts_read` before reporting persistence.
|
|
84
|
+
|
|
85
|
+
For the CLI, keep `canvas.json` and `boards/*.html` in one directory. Run the bundled preparer:
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
node <cli-skill-directory>/scripts/prepare-retained.mjs ./my-canvas > snapshot.json
|
|
89
|
+
artifacts create --snapshot snapshot.json
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Resolve `<cli-skill-directory>` with `artifacts skills path artifacts-cli`. Use the script at that returned path so its installed contracts dependency resolves. The preparer preserves the index text and board HTML and sorts snapshot files by path. Canvas rendering still follows `order`. It emits a validated snapshot on stdout, uses the directory basename as `metadata.title`, and sets `metadata.description` to `""`. Edit those metadata fields in the emitted JSON before saving when the task calls for a specific title or description. The directory form accepts exactly one positional argument. The named-option form prepares retained-verification configuration and keeps its separate contract.
|
|
93
|
+
|
|
94
|
+
## Check the retained canvas
|
|
95
|
+
|
|
96
|
+
Open the exact saved revision in the authenticated panel. Confirm all boards fit on the initial page, select each page, and open a board through its name button. Focus view shows that board at its declared width and height and enables its controls. **Volver al lienzo** returns to the prior pan and zoom. Escape also returns when a canvas control has keyboard focus. After interacting inside a board, use **Volver al lienzo** to return. For Escape, first use Shift+Tab to move focus back to the canvas controls. Embedded boards remain inert in the overview.
|
|
97
|
+
|
|
98
|
+
Check publication through the protected URL returned by `artifacts_publish`. In unsupported browsers, the canvas displays an inert listing of board source. Rendering uses nested opaque `srcdoc` iframes with `sandbox="allow-scripts"` and the existing content policy. Board scripts can still consume renderer time or briefly perturb focus. The canvas is not an absolute keyboard isolation boundary.
|
|
99
|
+
|
|
100
|
+
Export retains the index as `content.json` and boards as `files/boards/*.html`. To recreate a snapshot, use the manifest's `metadata`, preserve `content.json` as `content.text`, and reconstruct each file using its declared encoding. The ZIP manifest sorts files by path, matching the directory preparer. Snapshot digests preserve the original `files` array order, so keep that order from an independent saved read when comparing digests after recreation. A directory preparer also derives new metadata. Restore the manifest metadata and original file order before comparing snapshot digests.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Check local HTML before saving
|
|
2
|
+
|
|
3
|
+
Run the helper after embedding fonts and before the calling skill's create or update operation.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
design_skill=$(artifacts skills path artifacts-design)
|
|
7
|
+
node "$design_skill/scripts/verify-local.mjs" page.html --evidence ./page-evidence
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The helper needs Node 24+, an existing `playwright` or `playwright-core` package, and Chrome or Chromium 152+. It resolves the package from the current project first, then from its own installation. It tries existing Playwright browser binaries and system executables. To select an existing browser explicitly, add `--browser /absolute/path/to/chromium`. An explicit selection does not fall back to another executable.
|
|
11
|
+
|
|
12
|
+
Missing dependencies, incompatible versions, and launch failures produce actionable diagnostics. The helper never installs software, downloads a browser, disables browser sandboxing, or relaxes document policies.
|
|
13
|
+
|
|
14
|
+
## Describe controls
|
|
15
|
+
|
|
16
|
+
Add `--expectations checks.json` to replay actions and assert their results. Each matrix case starts with a fresh document. Steps run in order. A failed step ends that case's sequence because later steps may depend on it. The remaining cases still run.
|
|
17
|
+
|
|
18
|
+
```json
|
|
19
|
+
{
|
|
20
|
+
"expectations": [
|
|
21
|
+
{ "kind": "visibleCount", "selector": ".mobile-map, .desktop-map", "count": 1 },
|
|
22
|
+
{ "kind": "visible", "selector": ".mobile-map", "visible": true, "when": { "width": 375 } },
|
|
23
|
+
{ "kind": "visible", "selector": ".desktop-map", "visible": true, "when": { "width": 1280 } },
|
|
24
|
+
{ "kind": "text", "selector": "#total", "text": "5.00" },
|
|
25
|
+
{ "kind": "click", "selector": "#calculate" },
|
|
26
|
+
{ "kind": "text", "selector": "#total", "text": "28.40" },
|
|
27
|
+
{ "kind": "press", "selector": "#reset", "key": "Enter" },
|
|
28
|
+
{ "kind": "text", "selector": "#total", "text": "5.00" }
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Every step requires `kind` and a Playwright locator `selector`. Optional `when` limits it to `width` of 375 or 1280, `theme` of `light` or `dark`, or both. Unknown fields and malformed values fail configuration validation.
|
|
34
|
+
|
|
35
|
+
| Kind | Additional fields | Observable result |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| `text` | `text`, a string | Exact inner text of one element. Add a `visible` check when visibility is required. |
|
|
38
|
+
| `count` | `count`, a nonnegative integer | Number of matching elements. |
|
|
39
|
+
| `visible` | `visible`, a boolean | Visibility of exactly one matching element. A missing element fails. |
|
|
40
|
+
| `visibleCount` | `count`, a nonnegative integer | Number of visible matches. Use one selector matching both responsive variants to assert exclusivity. |
|
|
41
|
+
| `attribute` | `name`, a nonempty string, and `value`, a string or `null` | Exact attribute value. `null` requires absence. |
|
|
42
|
+
| `click` | None. | Real pointer activation. |
|
|
43
|
+
| `press` | `key`, a Playwright key string such as `Enter`, `Space`, or `Tab` | Keyboard input to the selected element. |
|
|
44
|
+
| `fill` | `value`, a string | Input or textarea edit. |
|
|
45
|
+
| `check` | `checked`, a boolean | Checkbox or radio state. |
|
|
46
|
+
| `select` | `values`, an array of strings | Selected option values. |
|
|
47
|
+
|
|
48
|
+
Assertions poll for their expected result. Actions wait for Playwright's actionability checks. The JSON document accepts at most 100 steps and an optional `timeoutMs` from 100 to 30000. Its default is 3000 ms per expectation. With no expectations file, the helper checks rendering and errors but does not infer the document's intended controls.
|
|
49
|
+
|
|
50
|
+
Each variant also has a total deadline of `timeoutMs * (step count + 1)`, bounded between 10 and 60 seconds. The deadline includes loading, checks, captures, and final observations. If the renderer stops responding, the helper closes that variant's context, records `deadline.exceeded`, and marks its remaining evidence incomplete. The other variants still run. A deadline failure cannot pass the automatic checks.
|
|
51
|
+
|
|
52
|
+
## Inspect the evidence
|
|
53
|
+
|
|
54
|
+
The helper creates a fresh `local-*` subdirectory under `--evidence`. It writes `report.json`, `index.md`, and PNG images. Repeated runs keep separate evidence.
|
|
55
|
+
|
|
56
|
+
The fixed matrix is 375 and 1280 px wide at 900 px high, each in light and dark. Every case checks document overflow before and after its scenario, runtime exceptions, error console entries, and forbidden resource attempts. CSP violations include resource attempts blocked before a browser request occurs. Initial and final captures scroll the actual iframe in viewport-sized steps. Each phase saves at most 12 tiles. Capture records include actual scroll offsets and mark incomplete coverage in `captureCoverage.truncated`.
|
|
57
|
+
|
|
58
|
+
The document runs in an opaque-origin iframe with `sandbox="allow-scripts"`. Its response uses the Artifacts preview CSP, `Connection-Allowlist: ()`, and disabled DNS prefetching. Fonts, images, and media must be embedded as `data:` URIs. Network connections, remote scripts, frames, objects, forms, and workers remain blocked. The local server exposes only the generated wrapper and content, without arbitrary filesystem access. Browser request routing also denies other destinations.
|
|
59
|
+
|
|
60
|
+
The report records the chosen executable, version, user agent, rejected browser candidates, source hashes before and after verification, actual content response policy and hash, per-case checks, errors, and screenshots. It identifies the evidence as **renderizado local** and leaves `authenticatedPublication` as `not-verified`. It does not modify the HTML, save a revision, publish, or contact the Artifacts service. No service credentials are required.
|
|
61
|
+
|
|
62
|
+
| Exit code | Meaning |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| 0 | Automatic checks passed. Visual review remains required. |
|
|
65
|
+
| 1 | An automatic check failed. |
|
|
66
|
+
| 2 | Setup or configuration blocked the run. |
|
|
67
|
+
|
|
68
|
+
A structured blocked report is written when a valid evidence destination is available. The helper prints the report as JSON on standard output.
|
|
69
|
+
|
|
70
|
+
Read the screenshots and the [interface checks](interface-checks.md) before accepting the design. Check content fidelity, reading order, labels, focus, alignment, density, and both themes. A clean console cannot establish those judgments. Each run observes load, font readiness, its declared interactions, and a short settling period. Delayed behavior beyond the run and content outside captured coverage require further inspection. Verify the retained revision and protected publication separately through the service workflow after saving.
|
|
@@ -14,7 +14,208 @@ const faces = [
|
|
|
14
14
|
{ family: 'Newsreader', style: 'italic', weight: '400 700', file: 'newsreader-italic.woff2' },
|
|
15
15
|
];
|
|
16
16
|
const limit = 1024 * 1024;
|
|
17
|
-
|
|
17
|
+
|
|
18
|
+
function htmlTypographySources(page) {
|
|
19
|
+
const sources = [];
|
|
20
|
+
const managed = [];
|
|
21
|
+
const tags = /<!--[\s\S]*?(?:-->|$)|<![^>]*>|<\/?([a-z][a-z0-9:-]*)(?=[\s/>])(?:[^"'<>]|"[^"]*"|'[^']*')*>/gi;
|
|
22
|
+
let italic = false;
|
|
23
|
+
let anchor;
|
|
24
|
+
let head;
|
|
25
|
+
let match;
|
|
26
|
+
while ((match = tags.exec(page))) {
|
|
27
|
+
const [tag, name] = match;
|
|
28
|
+
if (tag.startsWith('<!--')) continue;
|
|
29
|
+
if (/^<!doctype\b/i.test(tag)) head ??= tags.lastIndex;
|
|
30
|
+
if (!name || tag.startsWith('</')) continue;
|
|
31
|
+
const lower = name.toLowerCase();
|
|
32
|
+
if (lower === 'head') head = tags.lastIndex;
|
|
33
|
+
const attributes = new Map();
|
|
34
|
+
const attribute = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
35
|
+
const entities = { quot: '"', apos: "'", amp: '&', lt: '<', gt: '>', sol: '/' };
|
|
36
|
+
for (const entry of tag.slice(name.length + 1, -1).matchAll(attribute)) {
|
|
37
|
+
const key = entry[1].toLowerCase();
|
|
38
|
+
const value = (entry[2] ?? entry[3] ?? entry[4] ?? '').replace(/&(#(?:[xX][\da-fA-F]+|\d+));?|&(quot|QUOT|apos|amp|AMP|lt|LT|gt|GT|sol);/g, (entity, numeric, named) => {
|
|
39
|
+
if (named) return entities[named.toLowerCase()];
|
|
40
|
+
const code = numeric[1].toLowerCase() === 'x' ? parseInt(numeric.slice(2), 16) : Number(numeric.slice(1));
|
|
41
|
+
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : '\ufffd';
|
|
42
|
+
});
|
|
43
|
+
if (!attributes.has(key)) attributes.set(key, value);
|
|
44
|
+
}
|
|
45
|
+
if (attributes.has('style')) sources.push({ css: attributes.get('style'), inline: true });
|
|
46
|
+
if (['em', 'i', 'cite'].includes(lower)) italic = true;
|
|
47
|
+
if (lower === 'plaintext') break;
|
|
48
|
+
if (['style', 'script', 'textarea', 'title', 'xmp', 'iframe', 'noembed', 'noframes'].includes(lower)) {
|
|
49
|
+
const close = new RegExp(`</${lower}\\s*>`, 'gi');
|
|
50
|
+
close.lastIndex = tags.lastIndex;
|
|
51
|
+
const end = close.exec(page);
|
|
52
|
+
const contentEnd = end?.index ?? page.length;
|
|
53
|
+
if (lower === 'style') {
|
|
54
|
+
if (attributes.has('data-artifacts-fonts')) {
|
|
55
|
+
let after = end ? close.lastIndex : page.length;
|
|
56
|
+
if (page[after] === '\n') after++;
|
|
57
|
+
managed.push([match.index, after]);
|
|
58
|
+
} else if (!attributes.get('type') || /^text\/css$/i.test(attributes.get('type'))) {
|
|
59
|
+
anchor ??= match.index;
|
|
60
|
+
sources.push({ css: page.slice(tags.lastIndex, contentEnd), inline: false });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
tags.lastIndex = end ? close.lastIndex : page.length;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { sources, managed, italic, insertionAt: anchor ?? head ?? 0 };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function cssDeclarations(css, inline) {
|
|
70
|
+
const declarations = [];
|
|
71
|
+
const blocks = [];
|
|
72
|
+
let segment = '';
|
|
73
|
+
let quote = '';
|
|
74
|
+
let parentheses = 0;
|
|
75
|
+
let brackets = 0;
|
|
76
|
+
let valueBraces = 0;
|
|
77
|
+
function emit() {
|
|
78
|
+
const match = /^\s*(--[\w-]+|[-a-z]+)\s*:\s*([\s\S]*)$/i.exec(segment);
|
|
79
|
+
if (match && (inline || blocks.length) && !blocks.includes(true)) {
|
|
80
|
+
declarations.push({ property: match[1].startsWith('--') ? match[1] : match[1].toLowerCase(), value: match[2].replace(/\s*!\s*important\s*$/i, '') });
|
|
81
|
+
}
|
|
82
|
+
segment = '';
|
|
83
|
+
}
|
|
84
|
+
for (let index = 0; index < css.length; index++) {
|
|
85
|
+
const char = css[index];
|
|
86
|
+
if (char === '\\') {
|
|
87
|
+
segment += css.slice(index, index + 2);
|
|
88
|
+
index++;
|
|
89
|
+
} else if (quote) {
|
|
90
|
+
segment += char;
|
|
91
|
+
if (char === quote) quote = '';
|
|
92
|
+
} else if (char === '"' || char === "'") {
|
|
93
|
+
quote = char;
|
|
94
|
+
segment += char;
|
|
95
|
+
} else if (char === '/' && css[index + 1] === '*') {
|
|
96
|
+
const end = css.indexOf('*/', index + 2);
|
|
97
|
+
index = end < 0 ? css.length : end + 1;
|
|
98
|
+
segment += ' ';
|
|
99
|
+
} else if (char === '(' || char === '[') {
|
|
100
|
+
if (char === '(') parentheses++;
|
|
101
|
+
else brackets++;
|
|
102
|
+
segment += char;
|
|
103
|
+
} else if (char === ')' || char === ']') {
|
|
104
|
+
if (char === ')') parentheses--;
|
|
105
|
+
else brackets--;
|
|
106
|
+
segment += char;
|
|
107
|
+
} else if (!parentheses && !brackets && char === '{') {
|
|
108
|
+
if (valueBraces || /^\s*--[\w-]+\s*:/.test(segment)) {
|
|
109
|
+
valueBraces++;
|
|
110
|
+
segment += char;
|
|
111
|
+
} else {
|
|
112
|
+
blocks.push(/^\s*@font-face\b/i.test(segment));
|
|
113
|
+
segment = '';
|
|
114
|
+
}
|
|
115
|
+
} else if (!parentheses && !brackets && char === '}' && valueBraces) {
|
|
116
|
+
valueBraces--;
|
|
117
|
+
segment += char;
|
|
118
|
+
} else if (!parentheses && !brackets && !valueBraces && (char === ';' || char === '}')) {
|
|
119
|
+
emit();
|
|
120
|
+
if (char === '}') blocks.pop();
|
|
121
|
+
} else segment += char;
|
|
122
|
+
}
|
|
123
|
+
emit();
|
|
124
|
+
return declarations;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function splitValue(value, separator) {
|
|
128
|
+
const parts = [];
|
|
129
|
+
let start = 0;
|
|
130
|
+
let depth = 0;
|
|
131
|
+
let quote = '';
|
|
132
|
+
for (let index = 0; index < value.length; index++) {
|
|
133
|
+
const char = value[index];
|
|
134
|
+
if (char === '\\') index++;
|
|
135
|
+
else if (quote) { if (char === quote) quote = ''; }
|
|
136
|
+
else if (char === '"' || char === "'") quote = char;
|
|
137
|
+
else if (char === '(') depth++;
|
|
138
|
+
else if (char === ')') depth--;
|
|
139
|
+
else if (!depth && separator.test(char)) {
|
|
140
|
+
parts.push(value.slice(start, index));
|
|
141
|
+
if (char === '/') parts.push('/');
|
|
142
|
+
start = index + 1;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
parts.push(value.slice(start));
|
|
146
|
+
return parts;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function fontValueAlternatives(value, properties, visiting = new Set()) {
|
|
150
|
+
let quote = '';
|
|
151
|
+
for (let index = 0; index < value.length; index++) {
|
|
152
|
+
const char = value[index];
|
|
153
|
+
if (char === '\\') index++;
|
|
154
|
+
else if (quote) { if (char === quote) quote = ''; }
|
|
155
|
+
else if (char === '"' || char === "'") quote = char;
|
|
156
|
+
else if (/^var\(/i.test(value.slice(index, index + 4)) && !/[\w-]/.test(value[index - 1] ?? '')) {
|
|
157
|
+
let depth = 1;
|
|
158
|
+
let end = index + 4;
|
|
159
|
+
let innerQuote = '';
|
|
160
|
+
for (; end < value.length && depth; end++) {
|
|
161
|
+
const inner = value[end];
|
|
162
|
+
if (inner === '\\') end++;
|
|
163
|
+
else if (innerQuote) { if (inner === innerQuote) innerQuote = ''; }
|
|
164
|
+
else if (inner === '"' || inner === "'") innerQuote = inner;
|
|
165
|
+
else if (inner === '(') depth++;
|
|
166
|
+
else if (inner === ')') depth--;
|
|
167
|
+
}
|
|
168
|
+
if (depth) return [];
|
|
169
|
+
const [name, ...fallback] = splitValue(value.slice(index + 4, end - 1), /,/);
|
|
170
|
+
const key = name.trim();
|
|
171
|
+
const candidates = [];
|
|
172
|
+
if (!visiting.has(key)) {
|
|
173
|
+
const path = new Set(visiting).add(key);
|
|
174
|
+
for (const definition of properties.get(key) ?? []) candidates.push(...fontValueAlternatives(definition, properties, path));
|
|
175
|
+
}
|
|
176
|
+
if (fallback.length) candidates.push(...fontValueAlternatives(fallback.join(','), properties, visiting));
|
|
177
|
+
return [...new Set(candidates.flatMap((candidate) => fontValueAlternatives(value.slice(0, index) + candidate + value.slice(end), properties, visiting)))];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return [value];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function typographyUsage(document) {
|
|
184
|
+
const declarations = document.sources.flatMap(({ css, inline }) => cssDeclarations(css, inline));
|
|
185
|
+
const properties = new Map();
|
|
186
|
+
for (const { property, value } of declarations) {
|
|
187
|
+
if (property.startsWith('--')) properties.set(property, [...properties.get(property) ?? [], value]);
|
|
188
|
+
}
|
|
189
|
+
const usage = { families: new Set(), italic: document.italic };
|
|
190
|
+
for (const { property, value } of declarations) {
|
|
191
|
+
if (!['font-family', 'font', 'font-style'].includes(property)) continue;
|
|
192
|
+
for (let resolved of fontValueAlternatives(value, properties)) {
|
|
193
|
+
resolved = resolved.trim();
|
|
194
|
+
if (property === 'font-style') {
|
|
195
|
+
if (/^italic$/i.test(resolved)) usage.italic = true;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (property === 'font') {
|
|
199
|
+
const tokens = splitValue(resolved, /[\s/]/).filter(Boolean);
|
|
200
|
+
const size = tokens.findIndex((token) => /^(?:\d*\.?\d+(?:[a-z]+|%)|0|xx?-small|small|medium|large|x{1,3}-large|smaller|larger)$/i.test(token) || /^(?:calc|min|max|clamp)\(/i.test(token));
|
|
201
|
+
if (size < 0) continue;
|
|
202
|
+
if (tokens.slice(0, size).some((token) => /^italic$/i.test(token))) usage.italic = true;
|
|
203
|
+
resolved = tokens.slice(size + (tokens[size + 1] === '/' ? 3 : 1)).join(' ');
|
|
204
|
+
}
|
|
205
|
+
for (let family of splitValue(resolved, /,/)) {
|
|
206
|
+
family = family.trim();
|
|
207
|
+
if (/^(["'])[\s\S]*\1$/.test(family)) family = family.slice(1, -1);
|
|
208
|
+
family = family.replace(/\\([\da-f]{1,6})(?:\r\n|[\t\n\r\f ])?|\\([^\r\n\f])/gi, (_, hex, escaped) => {
|
|
209
|
+
const code = parseInt(hex, 16);
|
|
210
|
+
return hex ? code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : '\ufffd' : escaped;
|
|
211
|
+
}).replace(/\s+/g, ' ').toLowerCase();
|
|
212
|
+
const face = faces.find((candidate) => candidate.family.toLowerCase() === family);
|
|
213
|
+
if (face) usage.families.add(face.family);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return usage;
|
|
218
|
+
}
|
|
18
219
|
|
|
19
220
|
const paths = process.argv.slice(2);
|
|
20
221
|
if (paths.length !== 1) {
|
|
@@ -22,18 +223,18 @@ if (paths.length !== 1) {
|
|
|
22
223
|
process.exit(2);
|
|
23
224
|
}
|
|
24
225
|
const [path] = paths;
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
const
|
|
226
|
+
let page = await readFile(path, 'utf8');
|
|
227
|
+
for (const [start, end] of htmlTypographySources(page).managed.reverse()) page = page.slice(0, start) + page.slice(end);
|
|
228
|
+
const document = htmlTypographySources(page);
|
|
229
|
+
const usage = typographyUsage(document);
|
|
230
|
+
const used = faces.filter((face) => usage.families.has(face.family) && (face.style === 'normal' || usage.italic));
|
|
28
231
|
const rules = await Promise.all(used.map(async (face) => {
|
|
29
232
|
const data = (await readFile(join(fonts, face.file))).toString('base64');
|
|
30
233
|
return `@font-face{font-family:"${face.family}";font-style:${face.style};font-weight:${face.weight};font-display:swap;src:url(data:font/woff2;base64,${data}) format("woff2");unicode-range:${latin}}`;
|
|
31
234
|
}));
|
|
32
235
|
const block = rules.length ? `<style data-artifacts-fonts>${rules.join('')}</style>\n` : '';
|
|
33
236
|
|
|
34
|
-
const
|
|
35
|
-
const head = /<head[^>]*>\n?/i.exec(page) ?? /<!doctype[^>]*>\n?/i.exec(page);
|
|
36
|
-
const at = anchor >= 0 ? anchor : head ? head.index + head[0].length : 0;
|
|
237
|
+
const at = document.insertionAt;
|
|
37
238
|
const output = page.slice(0, at) + block + page.slice(at);
|
|
38
239
|
const bytes = Buffer.byteLength(output);
|
|
39
240
|
if (bytes > limit) {
|