pageship 0.3.0 → 0.4.1

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 +56 -9
  2. package/dist/skills/pageship/SKILL.md +2 -2
  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
@@ -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
+ }