pageship 0.2.0 → 0.4.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.
Files changed (32) hide show
  1. package/dist/cli.mjs +31 -21
  2. package/dist/skills/pageship/SKILL.md +8 -8
  3. package/dist/skills/pageship-chart/SKILL.md +18 -2
  4. package/dist/skills/pageship-code-block/SKILL.md +1 -1
  5. package/dist/skills/pageship-decision-record/SKILL.md +5 -1
  6. package/dist/skills/pageship-diagram/SKILL.md +16 -14
  7. package/dist/skills/pageship-diagram/references/check-diagram.js +3 -2
  8. package/dist/skills/pageship-docs/SKILL.md +32 -8
  9. package/dist/skills/pageship-docs/references/axe.js +34 -0
  10. package/dist/skills/pageship-docs/references/check.md +35 -8
  11. package/dist/skills/pageship-docs/references/design.md +38 -18
  12. package/dist/skills/pageship-docs/references/html-validate-rules.mjs +326 -0
  13. package/dist/skills/pageship-docs/references/html-validate.json +13 -0
  14. package/dist/skills/pageship-docs/references/palette.mjs +791 -0
  15. package/dist/skills/pageship-docs/references/plain-default.md +1 -1
  16. package/dist/skills/pageship-explainer/SKILL.md +13 -5
  17. package/dist/skills/pageship-interaction/SKILL.md +7 -5
  18. package/dist/skills/pageship-interaction/references/pointer.md +3 -0
  19. package/dist/skills/pageship-interaction/references/step-through.md +10 -25
  20. package/dist/skills/pageship-key-figures/SKILL.md +1 -1
  21. package/dist/skills/pageship-media/SKILL.md +2 -2
  22. package/dist/skills/pageship-motion/SKILL.md +31 -28
  23. package/dist/skills/pageship-motion/references/figure-motion.js +252 -0
  24. package/dist/skills/pageship-motion/references/figure-motion.md +294 -0
  25. package/dist/skills/pageship-plan/SKILL.md +3 -3
  26. package/dist/skills/pageship-report/SKILL.md +5 -1
  27. package/dist/skills/pageship-review-report/SKILL.md +5 -1
  28. package/dist/skills/pageship-slide-deck/SKILL.md +5 -3
  29. package/dist/skills/pageship-slide-deck/references/engine.md +1 -1
  30. package/dist/skills/pageship-spec/SKILL.md +5 -1
  31. package/dist/skills/pageship-table/SKILL.md +5 -3
  32. package/package.json +1 -1
@@ -2,45 +2,63 @@
2
2
 
3
3
  ## Who sets the look
4
4
 
5
- Take the look from the first source that has one:
5
+ Settle the look and its colours yourself, from the first source that has one:
6
6
 
7
7
  1. The user's explicit visual instructions.
8
8
  2. The project's design system: its tokens, stylesheets or brand guide.
9
- 3. An installed design skill: one in your list of available skills, not a skill file found elsewhere on disk. Prefer Anthropic's `frontend-design`, then the Taste Skill stack (`design-taste-frontend` and its companion skills). Follow its guidance on type, colour values and character. When it asks questions or offers options, take its default and keep going.
9
+ 3. An installed design skill: one in your list of available skills, not a skill file found elsewhere on disk. Prefer Anthropic's `frontend-design`, then the Taste Skill stack (`design-taste-frontend` and its companion skills). Follow its guidance on type and character. When it asks questions or offers options, take its default and keep going.
10
10
  4. Otherwise, copy [the plain default](plain-default.md).
11
11
 
12
- Whatever sets the look, these rules own structure, density, reading width, print, the colour count below and the [file rules](file.md). A design skill's one-accent or colour-lock rule shapes the values it picks, but the colour count here wins whatever a design skill says about accents: two accents, four series colours and three status colours. A document is for reading: leave out the landing-page parts a design skill may suggest, such as hero banners, decorative sections, marketing copy and entrance animations.
12
+ Whatever sets the look, these rules own structure, density, reading width, print, the colours below and the [file rules](file.md). The colours here win whatever a design skill says about palettes or accents: two accents, four series colours and three status colours, from [the palette calculator](#the-palette-calculator). A document is for reading: leave out the landing-page parts a design skill may suggest, such as hero banners, decorative sections, marketing copy, and entrance animations on text and sections. Figures move only as `pageship-motion` says.
13
13
 
14
14
  ## The look's colour variables
15
15
 
16
- Every source file sets these fourteen variables in `:root`, once for light and once more under `@media (prefers-color-scheme: dark)`. The look's source fills their values: a design system from its tokens, a design skill from the palette it chooses, the plain default as written. Each takes a plain colour value: hex, `rgb()`, `hsl()`, `oklch()`, or `light-dark()` of those. Pattern skills colour their parts from these variables and no other colour.
16
+ Every source file sets these fourteen variables in `:root`, once for light and once more under `@media (prefers-color-scheme: dark)`. [The palette calculator](#the-palette-calculator) fills their values, unless the user gives the colours or the project's design system has colour tokens: then those fill them. Each takes a plain colour value: hex, `rgb()`, `hsl()`, `oklch()`, or `light-dark()` of those. Pattern skills colour their parts from these variables and no other colour.
17
17
 
18
- | Variable | Colours | Floor against `--bg` |
19
- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- |
20
- | `--bg` | The page background. | |
21
- | `--fg` | Text. | 4.5:1 |
22
- | `--muted` | Secondary text: meta lines, captions, axis labels, connector labels. | 4.5:1 |
23
- | `--line` | Hairlines: rules, table borders, frames, gridlines. | |
24
- | `--surface` | Tinted panels: callouts, code blocks, caption strips, diagram nodes. | |
25
- | `--accent` | The first accent: the title area, section headings, callout bars, focus outlines and the focus of each figure. | 3:1 |
26
- | `--accent-2` | The second accent: links, the title rule and quote bars. In each theme, at least 60° from the first in OKLCH hue, so it stands out from the first accent and from the page. | 4.5:1 |
27
- | `--series-1` to `--series-4` | Chart series and diagram node categories, in this order. Four hues apart from each other and from the accents. | 3:1 |
28
- | `--good`, `--warn`, `--bad` | A status the source gives, such as on track, at risk and blocked: green, amber and red tuned to the look. | 3:1 |
18
+ | Variable | Colours | Floor against `--bg` |
19
+ | ---------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------- |
20
+ | `--bg` | The page background. | |
21
+ | `--fg` | Text. | 4.5:1 |
22
+ | `--muted` | Secondary text: meta lines, captions, axis labels, connector labels. | 4.5:1 |
23
+ | `--line` | Hairlines: rules, table borders, frames, gridlines. | |
24
+ | `--surface` | Tinted panels: callouts, code blocks, caption strips, diagram nodes. | |
25
+ | `--accent` | The first accent: the title area, section headings, callout bars, focus outlines and the focus of each figure. | 4.5:1 |
26
+ | `--accent-2` | The second accent: links, the title rule and quote bars. | 4.5:1 |
27
+ | `--series-1` to `--series-4` | Chart series and diagram node categories, in this order. Four hues apart from each other and from the accents. | 3:1 |
28
+ | `--good`, `--warn`, `--bad` | A status the source gives, such as on track, at risk and blocked: green, amber and red tuned to the look. | 3:1 |
29
29
 
30
- Spend colour by the 60-30-10 rule: the neutrals fill most of the page, the first accent carries the look, and the second accent is the small share that stands out. Both accents colour something a reader sees in every source file, as the table places them. The first accent meets 4.5:1 where it colours text, such as headings.
30
+ Spend colour by the 60-30-10 rule: the neutrals fill most of the page, the first accent carries the look, and the second accent is the small share that stands out. Both accents colour something a reader sees in every source file, as the table places them.
31
31
 
32
32
  A status the source gives shows in the source's words, in an element with `data-status="good"`, `"warn"` or `"bad"` that carries the matching colour on a dot, border or bar beside the words; the words stay `--fg`. The words are the element's own text, or a `.status` label inside it when the element holds more, such as a card. Rate it only as the source's word does: good for done or on track, warn for at risk, bad for blocked, late or missed. Any other status, such as in progress or not started, stays `--muted` with no `data-status`.
33
33
 
34
+ ## The palette calculator
35
+
36
+ [palette.mjs](palette.mjs) writes all fourteen variables for light and dark, each at its floor. The accents are what it gives. Make each choice below yourself, from the topic and the source.
37
+
38
+ - **Seed** (`--seed`): the project's brand colour when it has one, otherwise a colour you pick for the topic: hex, `rgb()`, `hsl()` or `oklch()`, with hues in degrees.
39
+ - **Number of colours** (`--colours`, 1 to 5): the hues the colour system sets: 1 for a calm look of one hue, more for a livelier one. Hues 1 and 2 become the accents, hues 3 to 5 the first series colours, and the calculator picks the rest. With 1, the second accent is a shade of the first.
40
+ - **Gap** (`--gap`): degrees between hues. Leave it out to space the hues evenly. A small gap, such as 30, gives neighbouring hues; a large one, contrasting hues.
41
+ - **Preset** (`--preset`), in place of the number and the gap: `split-complementary` (three hues at 0°, 150° and 210°) or `rectangle` (four at 0°, 60°, 180° and 240°).
42
+ - **Neutral tint** (`--tint`): how much of the seed's hue the background, text and lines carry: `none`, `subtle` (the default) or `strong`.
43
+
44
+ ```sh
45
+ node <pageship-docs folder>/references/palette.mjs --seed '#0d9488' --colours 3 --gap 40
46
+ node <pageship-docs folder>/references/palette.mjs --seed '#0d9488' --preset split-complementary --tint none
47
+ ```
48
+
49
+ It prints the `:root` block for light and the one for dark. Put their declarations in the look's light and dark root rules, in place of any colour values there. Without Node, keep [the plain default](plain-default.md)'s colours.
50
+
34
51
  ## Base rules every file meets
35
52
 
36
53
  - **Light and dark.** Both themes follow the system setting through `prefers-color-scheme`.
37
54
  - **Contrast.** Text meets WCAG AA: 4.5:1 for body text, 3:1 for large text, icons and control borders. Each colour variable meets its floor in the table above, in light and in dark.
38
55
  - **Reading width.** Prose lines hold about 70 characters (`max-width: 70ch` on prose).
39
56
  - **Type size.** Body text is at least 16px. Nothing a reader needs is smaller than 13px, including captions, table text, labels and code.
57
+ - **Height.** Content sets each part's height, or a length in `rem` or `px`. Previews frame the page at its full height, so a height that follows the window (`vh`, `svh`, `lvh`, `dvh`) grows with the frame.
40
58
  - **Phone width.** At 390px wide the page never scrolls sideways and keeps at least 12px of side padding, so no text or figure frame touches the screen's edge. Wide content scrolls inside its own box: a `<div class="scroll">` (`overflow-x: auto`) around a diagram or chart, the `.table-scroll` box `pageship-table` gives, or a code block's own `pre`.
41
59
  - **Print.** A print stylesheet shows all content, expands anything collapsed (`details::details-content { content-visibility: visible }`), hides controls, prints dark text on white, and avoids breaking inside figures, table rows and callouts.
42
60
  - **Links and controls.** Hovering a link or control changes how it looks, such as a thicker underline or a tinted background, and keyboard focus shows a visible outline. Every control is a native interactive element. Before adding one, load `pageship-interaction`.
43
- - **Motion.** Nothing moves until the reader acts. Under `prefers-reduced-motion: reduce`, nothing animates and every state shows in full. Before making anything move, load `pageship-motion`.
61
+ - **Motion.** Every chart, graphic and sequence plays once when it scrolls into view and stops on its end state, which its markup holds; everything else holds still until the reader acts. Under `prefers-reduced-motion: reduce`, nothing plays on its own and every state shows in full. Before drawing a chart, a graphic or a sequence, or making anything else move, load `pageship-motion`.
44
62
  - **Headings.** One `h1`, then levels in order. The document has a `lang` attribute and a `title`.
45
63
 
46
64
  ## The form of each figure
@@ -57,4 +75,6 @@ Take each figure's form from what the reader wants of it. Before writing a form
57
75
  | The lines of code the text is about | Code block | `pageship-code-block` |
58
76
  | An image, video or audio the source gives | Media | `pageship-media` |
59
77
  | Items with one attribute each, or steps in one line with no branch whose timing doesn't matter | List | |
60
- | One or two relationships, or two or three numbers that aren't parts of a whole | Sentence | |
78
+ | A single relationship, or one or two numbers, that a figure would only repeat | Sentence | |
79
+
80
+ A point that fits a figure's row and the list or sentence row takes the figure, with the list or sentence as the prose beside it.
@@ -0,0 +1,326 @@
1
+ // Pageship's html-validate rules, loaded by html-validate.json beside this file.
2
+ // Run: npx --yes html-validate@11.17.0 --config <this folder>/html-validate.json <file>
3
+
4
+ import { readFileSync, realpathSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { pathToFileURL } from "node:url";
7
+
8
+ // `npx` installs html-validate where this file can't import it by name, so take its rule class
9
+ // from the copy that is running, found through the command's path.
10
+ async function loadHtmlValidate() {
11
+ const command = process.argv[1] ? realpathSync(process.argv[1]) : "";
12
+ if (/[/\\]html-validate[/\\]bin[/\\]html-validate\.m?js$/u.test(command)) {
13
+ return import(pathToFileURL(join(dirname(command), "../dist/esm/index.js")).href);
14
+ }
15
+ return import("html-validate");
16
+ }
17
+
18
+ const { Rule } = await loadHtmlValidate();
19
+
20
+ const fileRules = new URL("./file.md", import.meta.url);
21
+
22
+ /** The script and stylesheet tags the file rules give, with their exact attributes. */
23
+ const trustedTags = [
24
+ ...readFileSync(fileRules, "utf8").matchAll(/^```html\n([\s\S]*?)^```/gmu),
25
+ ].flatMap(([, block]) =>
26
+ [...block.matchAll(/<(script|link)\b([^>]*)>/gu)].map(([, name, attributes]) => {
27
+ const values = new Map(
28
+ [...attributes.matchAll(/([\w-]+)="([^"]*)"/gu)].map(([, key, value]) => [key, value]),
29
+ );
30
+ return {
31
+ url: values.get(name === "script" ? "src" : "href"),
32
+ integrity: values.get("integrity"),
33
+ crossorigin: values.get("crossorigin"),
34
+ };
35
+ }),
36
+ );
37
+
38
+ const hasScheme = /^[a-z][a-z\d+.-]*:/iu;
39
+
40
+ /** Hosted font CSS differs by browser, so it can't carry `integrity`. */
41
+ const hostedFonts = /^https:\/\/fonts\.googleapis\.com\/css2?\?/u;
42
+
43
+ const attributeValue = (element, name) => {
44
+ const value = element.getAttributeValue(name);
45
+ return typeof value === "string" ? value : null;
46
+ };
47
+
48
+ const linkTypes = (element) =>
49
+ (attributeValue(element, "rel") ?? "").toLowerCase().split(/\s+/u).filter(Boolean);
50
+
51
+ /** The location of `text.slice(begin, end)`, for `text` that starts at `location`. */
52
+ function locate(location, text, begin, end) {
53
+ const before = text.slice(0, begin);
54
+ const newline = before.lastIndexOf("\n");
55
+ return {
56
+ filename: location.filename,
57
+ offset: location.offset + begin,
58
+ line: location.line + before.split("\n").length - 1,
59
+ column: newline === -1 ? location.column + begin : begin - newline,
60
+ size: end - begin,
61
+ };
62
+ }
63
+
64
+ /** Spaces in place of each match, so offsets into the text stay the same. */
65
+ const blank = (text, pattern) => text.replace(pattern, (match) => " ".repeat(match.length));
66
+ const cssComments = /\/\*[\s\S]*?\*\//gu;
67
+ const cssStrings = /"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'/gu;
68
+
69
+ /**
70
+ * The markup inside each `<svg>`, which html-validate leaves unparsed, and the location in the
71
+ * file of an offset into that markup.
72
+ */
73
+ function* svgContents(document, source) {
74
+ // Blanking comments keeps commented-out markup from counting.
75
+ const data = blank(source.data, /<!--[\s\S]*?-->/gu);
76
+ for (const element of document.querySelectorAll("svg")) {
77
+ // An svg tag, read past any ">" inside its quoted attribute values.
78
+ const tags = /<(\/?)svg\b(?:[^>"']|"[^"]*"|'[^']*')*?(\/?)>/giu;
79
+ tags.lastIndex = Math.max(0, element.location.offset - source.offset - 1);
80
+ const opening = tags.exec(data);
81
+ if (!opening) continue;
82
+ const start = opening.index + opening[0].length;
83
+ // The svg's own closing tag, past any svg nested inside it.
84
+ let depth = 1;
85
+ let end = -1;
86
+ for (let tag = tags.exec(data); tag; tag = tags.exec(data)) {
87
+ // A self-closing <svg/> opens nothing.
88
+ if (tag[2]) continue;
89
+ depth += tag[1] ? -1 : 1;
90
+ if (depth === 0) {
91
+ end = tag.index;
92
+ break;
93
+ }
94
+ }
95
+ if (end === -1) continue;
96
+ const at = (begin, length) =>
97
+ locate(source, source.data, start + begin, start + begin + length);
98
+ yield { element, markup: data.slice(start, end), at };
99
+ }
100
+ }
101
+
102
+ /** The CSS of every `<style>` element and `style` attribute, SVG's included, with where it starts. */
103
+ function* stylesheets(document, source) {
104
+ for (const element of document.querySelectorAll("style")) {
105
+ const text = element.childNodes[0];
106
+ if (text) yield { element, css: element.textContent, location: text.location };
107
+ }
108
+ for (const element of document.querySelectorAll("[style]")) {
109
+ const attribute = element.getAttribute("style");
110
+ if (typeof attribute?.value === "string") {
111
+ yield { element, css: attribute.value, location: attribute.valueLocation };
112
+ }
113
+ }
114
+ for (const { element, markup, at } of svgContents(document, source)) {
115
+ const css = /<style\b[^>]*>([\s\S]*?)<\/style\s*>|\sstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/giu;
116
+ for (const match of markup.matchAll(css)) {
117
+ const text = match[1] ?? match[2] ?? match[3];
118
+ const begin = match.index + match[0].indexOf(text, match[0].indexOf(match[1] ? ">" : "="));
119
+ yield { element, css: text, location: at(begin, text.length) };
120
+ }
121
+ }
122
+ }
123
+
124
+ class TrustedLibraries extends Rule {
125
+ documentation() {
126
+ return {
127
+ description:
128
+ "Load scripts and stylesheets only with a tag copied from the file rules (references/file.md): the same URL, `integrity` and `crossorigin`. A hosted Google Fonts stylesheet needs no `integrity`.",
129
+ };
130
+ }
131
+
132
+ setup() {
133
+ this.on("dom:ready", ({ document, source }) => {
134
+ for (const element of document.querySelectorAll("script[src]")) {
135
+ this.check(element, attributeValue(element, "src"));
136
+ }
137
+ for (const element of document.querySelectorAll("link")) {
138
+ const types = linkTypes(element);
139
+ const preloads = (attributeValue(element, "as") ?? "").toLowerCase();
140
+ const stylesheet =
141
+ types.includes("stylesheet") || (types.includes("preload") && preloads === "style");
142
+ const script =
143
+ types.includes("modulepreload") || (types.includes("preload") && preloads === "script");
144
+ if (stylesheet || script) {
145
+ this.check(element, attributeValue(element, "href"), { stylesheet });
146
+ }
147
+ }
148
+ for (const { element, css, location } of stylesheets(document, source)) {
149
+ const code = blank(css, cssComments);
150
+ for (const match of code.matchAll(/@import\s+(?:url\(\s*)?["']?([^"')\s;]+)/giu)) {
151
+ const url = match[1];
152
+ // A path beside the file is pageship/no-relative-paths' problem.
153
+ if (hostedFonts.test(url) || !hasScheme.test(url)) continue;
154
+ this.report(
155
+ element,
156
+ `The @import of ${url} is not a stylesheet the file rules give: load only the tags they give`,
157
+ locate(location, css, match.index, match.index + match[0].length),
158
+ );
159
+ }
160
+ }
161
+ for (const element of document.querySelectorAll("script")) {
162
+ if (element.hasAttribute("src")) continue;
163
+ const imported = /\bimport\b[^;]*?["'](https?:\/\/[^"']+)/u.exec(element.textContent)?.[1];
164
+ if (imported) {
165
+ this.report(
166
+ element,
167
+ `The module import of ${imported} can't be checked by integrity: load the library with a <script src> tag from the file rules`,
168
+ );
169
+ }
170
+ }
171
+ });
172
+ }
173
+
174
+ check(element, url, { stylesheet = false } = {}) {
175
+ if (url === null || (stylesheet && hostedFonts.test(url))) return;
176
+ const trusted = trustedTags.find((tag) => tag.url === url);
177
+ if (!trusted) {
178
+ this.report(element, `${url} is not a tag in the file rules: load only the tags they give`);
179
+ return;
180
+ }
181
+ for (const name of ["integrity", "crossorigin"]) {
182
+ if (attributeValue(element, name) !== trusted[name]) {
183
+ this.report(element, `${url} needs ${name}="${trusted[name]}", as the file rules give it`);
184
+ }
185
+ }
186
+ }
187
+ }
188
+
189
+ /** Attributes that load a resource, by the elements that carry them. */
190
+ const loadingAttributes = {
191
+ script: ["src"],
192
+ img: ["src", "srcset"],
193
+ source: ["src", "srcset"],
194
+ video: ["src", "poster"],
195
+ audio: ["src"],
196
+ track: ["src"],
197
+ iframe: ["src"],
198
+ embed: ["src"],
199
+ object: ["data"],
200
+ input: ["src"],
201
+ link: ["href"],
202
+ };
203
+ /** The same inside an `<svg>`, read from its markup. */
204
+ const svgLoads =
205
+ /<(?:image|use|feimage|script)\b[^>]*?\s(?:xlink:)?(?:href|src)\s*=\s*(?:"([^"]*)"|'([^']*)')/giu;
206
+ /** The specifier of each static or dynamic module import in a script. */
207
+ const imports = /\bimport\s*(?:\(\s*|[\w$*{}\s,]+?\s*from\s*)?["']([^"']+)["']/gu;
208
+
209
+ const allowedUrl = /^(?:https:\/\/|data:|#)/iu;
210
+
211
+ /** The URL of each candidate in a `srcset`, where a `data:` URL may hold commas of its own. */
212
+ function srcsetUrls(srcset) {
213
+ const urls = [];
214
+ let rest = srcset;
215
+ for (;;) {
216
+ rest = rest.replace(/^[\s,]+/u, "");
217
+ if (!rest) return urls;
218
+ const url = /^\S+/u.exec(rest)[0];
219
+ rest = rest.slice(url.length);
220
+ if (url.endsWith(",")) {
221
+ urls.push(url.replace(/,+$/u, ""));
222
+ } else {
223
+ urls.push(url);
224
+ // Skip the descriptor, such as "2x" or "640w", up to the next candidate.
225
+ rest = rest.replace(/^[^,]*/u, "");
226
+ }
227
+ }
228
+ }
229
+
230
+ class NoRelativePaths extends Rule {
231
+ documentation() {
232
+ return {
233
+ description:
234
+ "A published file is the only file Pageship serves, so nothing loads from beside it. Use an `https://` or `data:` URL, or a `#fragment`.",
235
+ };
236
+ }
237
+
238
+ setup() {
239
+ this.on("dom:ready", ({ document, source }) => {
240
+ for (const [name, attributes] of Object.entries(loadingAttributes)) {
241
+ for (const element of document.querySelectorAll(name)) {
242
+ for (const attribute of attributes) {
243
+ const value = attributeValue(element, attribute);
244
+ if (value === null) continue;
245
+ const urls = attribute === "srcset" ? srcsetUrls(value) : [value.trim()];
246
+ for (const url of urls) this.check(element, url, element.location);
247
+ }
248
+ }
249
+ }
250
+ for (const { element, markup, at } of svgContents(document, source)) {
251
+ for (const match of markup.matchAll(svgLoads)) {
252
+ this.check(element, (match[1] ?? match[2]).trim(), at(match.index, match[0].length));
253
+ }
254
+ }
255
+ for (const element of document.querySelectorAll("script")) {
256
+ const text = element.childNodes[0];
257
+ if (element.hasAttribute("src") || !text) continue;
258
+ for (const match of element.textContent.matchAll(imports)) {
259
+ // An import from a URL is pageship/trusted-libraries' problem.
260
+ if (hasScheme.test(match[1])) continue;
261
+ const location = locate(
262
+ text.location,
263
+ element.textContent,
264
+ match.index,
265
+ match.index + match[0].length,
266
+ );
267
+ this.check(element, match[1], location);
268
+ }
269
+ }
270
+ for (const { element, css, location } of stylesheets(document, source)) {
271
+ const code = blank(css, cssComments);
272
+ for (const match of code.matchAll(/url\(\s*["']?([^"')]*)|@import\s+["']([^"']*)/giu)) {
273
+ const url = (match[1] ?? match[2]).trim();
274
+ this.check(
275
+ element,
276
+ url,
277
+ locate(location, css, match.index, match.index + match[0].length),
278
+ );
279
+ }
280
+ }
281
+ });
282
+ }
283
+
284
+ check(element, url, location) {
285
+ if (allowedUrl.test(url)) return;
286
+ this.report(
287
+ element,
288
+ `"${url}" loads from beside the file, which isn't published with it: use an https:// or data: URL`,
289
+ location,
290
+ );
291
+ }
292
+ }
293
+
294
+ const viewportHeight = /(?<![\w.-])-?(?:\d+\.?\d*|\.\d+)(?:vh|svh|lvh|dvh)\b/giu;
295
+
296
+ class NoViewportHeight extends Rule {
297
+ documentation() {
298
+ return {
299
+ description:
300
+ "Size parts by their content, `rem` or `%`, not by the window's height: `vh`, `svh`, `lvh` and `dvh` are not allowed.",
301
+ };
302
+ }
303
+
304
+ setup() {
305
+ this.on("dom:ready", ({ document, source }) => {
306
+ for (const { element, css, location } of stylesheets(document, source)) {
307
+ for (const match of blank(blank(css, cssComments), cssStrings).matchAll(viewportHeight)) {
308
+ this.report(
309
+ element,
310
+ `${match[0]} sizes by the window's height: size by the content, rem or % instead`,
311
+ locate(location, css, match.index, match.index + match[0].length),
312
+ );
313
+ }
314
+ }
315
+ });
316
+ }
317
+ }
318
+
319
+ export default {
320
+ name: "pageship",
321
+ rules: {
322
+ "pageship/trusted-libraries": TrustedLibraries,
323
+ "pageship/no-relative-paths": NoRelativePaths,
324
+ "pageship/no-viewport-height": NoViewportHeight,
325
+ },
326
+ };
@@ -0,0 +1,13 @@
1
+ {
2
+ "plugins": ["./html-validate-rules.mjs"],
3
+ "extends": ["html-validate:recommended", "html-validate:document", "html-validate:prettier"],
4
+ "rules": {
5
+ "no-inline-style": "off",
6
+ "prefer-native-element": ["error", { "exclude": ["region"] }],
7
+ "require-sri": ["error", { "exclude": ["https://fonts.googleapis.com/"] }],
8
+ "heading-level": ["error", { "allowMultipleH1": false }],
9
+ "pageship/trusted-libraries": "error",
10
+ "pageship/no-relative-paths": "error",
11
+ "pageship/no-viewport-height": "error"
12
+ }
13
+ }