@allons-y/webc-patterns 0.1.0-beta.0 → 0.1.0-beta.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.
- package/README.md +506 -5
- package/components/norn-accordion.webc +124 -0
- package/components/norn-breadcrumbs.webc +71 -0
- package/components/norn-callout.webc +97 -0
- package/components/norn-card-grid.webc +60 -0
- package/components/norn-card.webc +143 -0
- package/components/norn-code-tabs.webc +270 -0
- package/components/norn-copy-button.webc +220 -0
- package/components/norn-fact-list.webc +52 -0
- package/components/norn-footer.webc +122 -0
- package/components/norn-head.webc +59 -0
- package/components/norn-header.webc +238 -0
- package/components/norn-heading-anchor.webc +71 -0
- package/components/norn-hero.webc +81 -0
- package/components/norn-icon.webc +140 -0
- package/components/norn-link-button.webc +96 -0
- package/components/norn-notice.webc +94 -0
- package/components/norn-pager.webc +67 -0
- package/components/norn-rich-text.webc +105 -0
- package/components/norn-search.webc +303 -0
- package/components/norn-segmented-control.webc +143 -0
- package/components/norn-side-nav.webc +149 -0
- package/components/norn-sidebar-layout.webc +185 -0
- package/components/norn-simple-table.webc +120 -0
- package/components/norn-skip-link.webc +41 -0
- package/components/norn-theme-toggle.webc +127 -0
- package/components/norn-toc.webc +260 -0
- package/css/base.css +75 -0
- package/css/print.css +8 -0
- package/css/reset.css +45 -0
- package/fallback/norn-code-block.webc +13 -0
- package/index.js +170 -1
- package/layouts/norn-base.webc +137 -0
- package/layouts/norn-docs-home.webc +109 -0
- package/layouts/norn-docs.webc +77 -0
- package/layouts/norn-landing.webc +73 -0
- package/layouts/norn-workspace.webc +25 -0
- package/layouts/norn-workspaces-home.webc +44 -0
- package/lib/icons.js +166 -0
- package/lib/npm.js +49 -0
- package/lib/optional.js +25 -0
- package/lib/site.js +87 -0
- package/lib/workspaces.js +71 -0
- package/markdown.js +278 -0
- package/package.json +32 -3
- package/plugin.js +279 -0
- package/project.js +15 -0
- package/schema/project.schema.json +112 -0
package/lib/icons.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
4
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
5
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
/** A lowercase kebab name: `chevron-up`, `x2`. */
|
|
12
|
+
const NAME = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
13
|
+
|
|
14
|
+
/** The viewBox of a file that has none, and no numeric width and height. */
|
|
15
|
+
const VIEW_BOX = "0 0 24 24";
|
|
16
|
+
|
|
17
|
+
/** Presentation attributes of the root `<svg>` that move to a `<g>`, so a file's own paint survives. */
|
|
18
|
+
const PAINT = new Set([
|
|
19
|
+
"clip-rule",
|
|
20
|
+
"fill",
|
|
21
|
+
"fill-opacity",
|
|
22
|
+
"fill-rule",
|
|
23
|
+
"opacity",
|
|
24
|
+
"stroke",
|
|
25
|
+
"stroke-dasharray",
|
|
26
|
+
"stroke-dashoffset",
|
|
27
|
+
"stroke-linecap",
|
|
28
|
+
"stroke-linejoin",
|
|
29
|
+
"stroke-miterlimit",
|
|
30
|
+
"stroke-opacity",
|
|
31
|
+
"stroke-width",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const ATTRIBUTE = /([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
35
|
+
const ROOT = /<svg((?:\s+[^\s"'<>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?)*)\s*(\/?)>/i;
|
|
36
|
+
|
|
37
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
38
|
+
const quoted = (name) => `"${name}"`;
|
|
39
|
+
|
|
40
|
+
/** The attributes of a tag's attribute text, as `[name, value][]`. */
|
|
41
|
+
const attributes = (text) => [...text.matchAll(ATTRIBUTE)].map(([, name, double, single, bare]) => [name, double ?? single ?? bare ?? ""]);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The parts of one SVG file an icon keeps: its viewBox, and its drawing as markup.
|
|
45
|
+
* The viewBox is the file's own, else `0 0 width height` from a numeric `width` and `height`, else `0 0 24 24`.
|
|
46
|
+
* The `<title>` and `<desc>` go, as the drawing is hidden from assistive tech. Scripts, `on*` attributes, and `javascript:` links go too.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} text - The file's text
|
|
49
|
+
* @param {string} file - The file's path, for errors
|
|
50
|
+
* @returns {{ viewBox: string, body: string }}
|
|
51
|
+
* @throws {Error} When the file has no `<svg>` element
|
|
52
|
+
*/
|
|
53
|
+
function parse(text, file) {
|
|
54
|
+
const source = text.replace(/<\?[\s\S]*?\?>|<!--[\s\S]*?-->|<!DOCTYPE[^>]*>/gi, "");
|
|
55
|
+
const root = ROOT.exec(source);
|
|
56
|
+
if (!root) throw new Error(`norn: icons: ${file} has no <svg> element.`);
|
|
57
|
+
const own = attributes(root[1]);
|
|
58
|
+
const size = (key) => own.find(([name]) => name.toLowerCase() === key)?.[1].trim().replace(/px$/, "");
|
|
59
|
+
const [width, height] = [size("width"), size("height")];
|
|
60
|
+
const sized = /^[\d.]+$/.test(width ?? "") && /^[\d.]+$/.test(height ?? "") ? `0 0 ${width} ${height}` : VIEW_BOX;
|
|
61
|
+
const viewBox = size("viewbox") || sized;
|
|
62
|
+
if (!/^\s*-?[\d.]+(?:[\s,]+-?[\d.]+){3}\s*$/.test(viewBox)) throw new Error(`norn: icons: ${file} has the viewBox "${viewBox}", which is not four numbers.`);
|
|
63
|
+
const paint = own.filter(([name]) => PAINT.has(name.toLowerCase())).map(([name, value]) => ` ${name.toLowerCase()}="${value.replaceAll('"', """)}"`);
|
|
64
|
+
let inner = "";
|
|
65
|
+
if (!root[2]) {
|
|
66
|
+
const start = root.index + root[0].length;
|
|
67
|
+
const end = source.toLowerCase().lastIndexOf("</svg");
|
|
68
|
+
inner = end < start ? "" : source.slice(start, end);
|
|
69
|
+
}
|
|
70
|
+
inner = inner
|
|
71
|
+
.replace(/<(title|desc|script)\b[\s\S]*?<\/\1\s*>/gi, "")
|
|
72
|
+
.replace(/<(?:title|desc|script)\b[^>]*\/>/gi, "")
|
|
73
|
+
.replace(/\s+on[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
|
|
74
|
+
.replace(/\s+((?:xlink:)?href)\s*=\s*(?:"\s*javascript:[^"]*"|'\s*javascript:[^']*')/gi, "")
|
|
75
|
+
.trim();
|
|
76
|
+
if (!inner) throw new Error(`norn: icons: ${file} has no drawing.`);
|
|
77
|
+
return { viewBox, body: paint.length ? `<g${paint.join("")}>${inner}</g>` : inner };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Give every id in an icon's drawing, and each reference to it, a prefix made from the icon's name.
|
|
82
|
+
* Inlined icons share the page's one id space, so two icons that each define `id="a"`, as SVGO's `cleanupIds` leaves them, would clip, mask, and fill each other:
|
|
83
|
+
* a reference resolves to the first element with the id, whichever icon it is in. The prefix is the same every time, so an icon used twice repeats its ids only with itself.
|
|
84
|
+
* References are `url(#id)`, `href` and `xlink:href`, and the `#id` selectors of a `<style>` in the drawing.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} markup - The drawing
|
|
87
|
+
* @param {string} name - The icon's name, a lowercase kebab name
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
function scopeIds(markup, name) {
|
|
91
|
+
const ids = [...new Set([...markup.matchAll(/\sid\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)].map(([, double, single]) => double ?? single))].filter(Boolean);
|
|
92
|
+
if (!ids.length) return markup;
|
|
93
|
+
const prefix = `norn-icon-${name}-`;
|
|
94
|
+
// The longest first, so an id that starts another is not matched as the shorter one.
|
|
95
|
+
const any = ids
|
|
96
|
+
.sort((a, b) => b.length - a.length)
|
|
97
|
+
.map((id) => id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
98
|
+
.join("|");
|
|
99
|
+
const scoped = (_, before, id) => `${before}${prefix}${id}`;
|
|
100
|
+
return markup
|
|
101
|
+
.replace(new RegExp(`(\\sid\\s*=\\s*["'])(${any})(?=["'])`, "gi"), scoped)
|
|
102
|
+
.replace(new RegExp(`(url\\(\\s*["']?#)(${any})(?=["']?\\s*\\))`, "gi"), scoped)
|
|
103
|
+
.replace(new RegExp(`((?:xlink:)?href\\s*=\\s*["']#)(${any})(?=["'])`, "gi"), scoped)
|
|
104
|
+
.replace(/<style\b[\s\S]*?<\/style\s*>/gi, (style) => style.replace(new RegExp(`(#)(${any})(?![\\w-])`, "g"), scoped));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The `{ src, mirror }` of one record entry, or an error naming the icon. */
|
|
108
|
+
function entry(name, value) {
|
|
109
|
+
if (!NAME.test(name)) throw new Error(`norn: icons: ${quoted(name)} is not a lowercase kebab name, such as "chevron-up".`);
|
|
110
|
+
const spec = typeof value === "string" ? { src: value } : value;
|
|
111
|
+
if (!isObject(spec) || typeof spec.src !== "string" || !spec.src) throw new Error(`norn: icons: ${quoted(name)} must be a path to an SVG file, or { src, mirror }.`);
|
|
112
|
+
const unknown = Object.keys(spec).filter((key) => key !== "src" && key !== "mirror");
|
|
113
|
+
if (unknown.length) throw new Error(`norn: icons: ${quoted(name)} has an unknown option ${unknown.map(quoted).join(", ")}. Options: src, mirror.`);
|
|
114
|
+
if (spec.mirror !== undefined && typeof spec.mirror !== "boolean") throw new Error(`norn: icons: the mirror option of ${quoted(name)} must be true or false.`);
|
|
115
|
+
return { src: spec.src, mirror: spec.mirror === true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The icons an `icons` option names, read from disk.
|
|
120
|
+
* A glob, or a list of globs, names each file after its basename without `.svg`. A record names each icon and gives its path, or `{ src, mirror }`.
|
|
121
|
+
*
|
|
122
|
+
* @param {unknown} option - The plugin's `icons` option
|
|
123
|
+
* @param {string} root - The folder paths resolve against
|
|
124
|
+
* @returns {Record<string, { viewBox: string, body: string, mirror: boolean }>} Icons by name
|
|
125
|
+
* @throws {Error} On a bad option, a missing or unreadable file, a name that is not lowercase kebab, or two files with one name
|
|
126
|
+
*/
|
|
127
|
+
export function readIcons(option, root = process.cwd()) {
|
|
128
|
+
if (option === undefined) return {};
|
|
129
|
+
/** @type {Map<string, { src: string, file: string, mirror: boolean }>} */
|
|
130
|
+
const found = new Map();
|
|
131
|
+
const add = (name, src, file, mirror) => {
|
|
132
|
+
if (found.has(name)) throw new Error(`norn: icons: two files are named ${quoted(name)}: ${found.get(name).src} and ${src}.`);
|
|
133
|
+
found.set(name, { src, file, mirror });
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
if (typeof option === "string" || Array.isArray(option)) {
|
|
137
|
+
const globs = [option].flat();
|
|
138
|
+
if (!globs.length || globs.some((glob) => typeof glob !== "string" || !glob)) throw new Error("norn: `icons` must be a glob, a list of globs, or a record of icon names to SVG files.");
|
|
139
|
+
for (const glob of globs) {
|
|
140
|
+
for (const match of fs.globSync(glob, { cwd: root }).sort()) {
|
|
141
|
+
const file = path.resolve(root, match);
|
|
142
|
+
if (fs.statSync(file).isDirectory()) continue;
|
|
143
|
+
if (path.extname(file).toLowerCase() !== ".svg") throw new Error(`norn: icons: ${match} is not an SVG file. Match .svg files only.`);
|
|
144
|
+
const name = path.basename(file, path.extname(file));
|
|
145
|
+
if (!NAME.test(name)) throw new Error(`norn: icons: ${match} names the icon ${quoted(name)}, which is not a lowercase kebab name. Rename the file, or use a record.`);
|
|
146
|
+
add(name, match, file, false);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} else if (isObject(option)) {
|
|
150
|
+
for (const [name, value] of Object.entries(option)) {
|
|
151
|
+
const { src, mirror } = entry(name, value);
|
|
152
|
+
const file = path.resolve(root, src);
|
|
153
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) throw new Error(`norn: icons: ${quoted(name)} points at ${src}, which is not a file.`);
|
|
154
|
+
add(name, src, file, mirror);
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
throw new Error("norn: `icons` must be a glob, a list of globs, or a record of icon names to SVG files.");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return Object.fromEntries(
|
|
161
|
+
[...found].map(([name, { src, file, mirror }]) => {
|
|
162
|
+
const { viewBox, body } = parse(fs.readFileSync(file, "utf8"), src);
|
|
163
|
+
return [name, { viewBox, body: scopeIds(body, name), mirror }];
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
}
|
package/lib/npm.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
4
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
5
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
|
|
10
|
+
/** How long a registry answer is reused. */
|
|
11
|
+
const CACHE_MS = 60 * 60 * 1000;
|
|
12
|
+
|
|
13
|
+
/** Registry answers by package name: `{ at, released }`. */
|
|
14
|
+
const cache = new Map();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether the package has a release on npm: a `latest` dist-tag. A missing package, a private one, or no network counts as no release.
|
|
18
|
+
*
|
|
19
|
+
* @param {string | null | undefined} packageName
|
|
20
|
+
* @param {{ fetch?: typeof globalThis.fetch, now?: () => number }} [options] - A fetch to use instead of the global one, and a clock, for tests
|
|
21
|
+
* @returns {Promise<boolean>}
|
|
22
|
+
*/
|
|
23
|
+
export async function npmRelease(packageName, { fetch = globalThis.fetch, now = Date.now } = {}) {
|
|
24
|
+
if (!packageName || typeof fetch !== "function") return false;
|
|
25
|
+
const hit = cache.get(packageName);
|
|
26
|
+
if (hit && now() - hit.at < CACHE_MS) return hit.released;
|
|
27
|
+
let released;
|
|
28
|
+
try {
|
|
29
|
+
// The abbreviated document still has `dist-tags`, without the metadata of every version.
|
|
30
|
+
const response = await fetch(`https://registry.npmjs.org/${packageName.replace("/", "%2F")}`, { signal: AbortSignal.timeout(5000), headers: { accept: "application/vnd.npm.install-v1+json" } });
|
|
31
|
+
released = response.ok && typeof (await response.json())?.["dist-tags"]?.latest === "string";
|
|
32
|
+
} catch {
|
|
33
|
+
released = false;
|
|
34
|
+
}
|
|
35
|
+
cache.set(packageName, { at: now(), released });
|
|
36
|
+
return released;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The project accent as six hex digits, for the badge color: the first `--norn-ui-accent: #rrggbb` in a stylesheet.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} cssPath
|
|
43
|
+
* @returns {string | null} Null when the file or the declaration is missing
|
|
44
|
+
*/
|
|
45
|
+
export function accentHex(cssPath) {
|
|
46
|
+
if (!fs.existsSync(cssPath)) return null;
|
|
47
|
+
const match = fs.readFileSync(cssPath, "utf8").match(/--norn-ui-accent:\s*#([0-9a-f]{6})\b/i);
|
|
48
|
+
return match ? match[1].toLowerCase() : null;
|
|
49
|
+
}
|
package/lib/optional.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
4
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
5
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Whether an optional peer package is installed. Only a package that cannot be found counts as absent.
|
|
10
|
+
* A package that is installed but fails to load, because one of its own dependencies is missing, is not absent: the caller imports it next and that error is thrown.
|
|
11
|
+
* Matching the error message instead would not tell the two apart, since Node names the importing file in it, and that path holds the peer's name.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} specifier - A bare package name, such as `@allons-y/webc-code-sample`
|
|
14
|
+
* @returns {boolean}
|
|
15
|
+
* @throws {Error} Any resolution failure other than the package not being found
|
|
16
|
+
*/
|
|
17
|
+
export function isInstalled(specifier) {
|
|
18
|
+
try {
|
|
19
|
+
import.meta.resolve(specifier);
|
|
20
|
+
return true;
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error?.code === "ERR_MODULE_NOT_FOUND") return false;
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
package/lib/site.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
4
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
5
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
import hostedGitInfo from "hosted-git-info";
|
|
12
|
+
import { minVersion, validRange } from "semver";
|
|
13
|
+
|
|
14
|
+
/** Where a project may keep its own policy files, in the order they are looked for. */
|
|
15
|
+
const POLICY_DIRS = ["", ".github", "docs"];
|
|
16
|
+
|
|
17
|
+
/** @param {string} file */
|
|
18
|
+
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
|
19
|
+
|
|
20
|
+
/** Lowest major in an engines range, as `24+`; null when unbounded. */
|
|
21
|
+
function minimumVersion(range) {
|
|
22
|
+
if (!validRange(range)) return null;
|
|
23
|
+
const { major } = minVersion(range);
|
|
24
|
+
return major ? `${major}+` : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** First sentence, minus the period. */
|
|
28
|
+
const firstSentence = (text) => text.split(/(?<=\.)\s/)[0].replace(/\.$/, "");
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The URL of a policy file the project keeps, or null.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} root - The project root
|
|
34
|
+
* @param {(file: string) => string} link - The URL of a file in the repository
|
|
35
|
+
* @param {string} name - A file name, such as `SECURITY.md`
|
|
36
|
+
* @returns {string | null}
|
|
37
|
+
*/
|
|
38
|
+
function policyUrl(root, link, name) {
|
|
39
|
+
const found = POLICY_DIRS.map((dir) => path.posix.join(dir, name)).find((file) => fs.existsSync(path.join(root, ...file.split("/"))));
|
|
40
|
+
return found ? link(found) : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Site facts from the project's `package.json` and `project.json`, for a project's mapping to the layout props.
|
|
45
|
+
*
|
|
46
|
+
* @param {{ packagePath: string, projectPath?: string, host?: string }} options - The two files, and a host that serves the site at one path per repository, such as `https://example.github.io`, for the URL when `project.json` sets none
|
|
47
|
+
* @returns {Record<string, string | null>} The site facts
|
|
48
|
+
* @throws {Error} When the package has no name, license, description, or repository, or its repository is not a hosted Git URL
|
|
49
|
+
*/
|
|
50
|
+
export function buildSite({ packagePath, projectPath, host }) {
|
|
51
|
+
const pkg = readJson(packagePath);
|
|
52
|
+
const project = projectPath && fs.existsSync(projectPath) ? readJson(projectPath) : {};
|
|
53
|
+
for (const key of ["name", "license", "description"]) {
|
|
54
|
+
if (typeof pkg[key] !== "string" || !pkg[key]) {
|
|
55
|
+
throw new Error(`${packagePath} has no \`${key}\`. The site's facts are resolved from it: set it there.`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const raw = typeof pkg.repository === "string" ? pkg.repository : pkg.repository?.url;
|
|
59
|
+
if (!raw) throw new Error(`${packagePath} has no \`repository\`. The site's links are resolved from it.`);
|
|
60
|
+
const info = hostedGitInfo.fromUrl(raw);
|
|
61
|
+
if (!info) throw new Error(`${packagePath} has a \`repository\` that is not a hosted Git URL: ${raw}`);
|
|
62
|
+
|
|
63
|
+
const repo = info.browse();
|
|
64
|
+
const repoName = repo.split("/").pop();
|
|
65
|
+
const root = path.dirname(packagePath);
|
|
66
|
+
/** A file on the default branch, in the URL form its host uses: GitHub's `blob/main/…` is Bitbucket's `src/main/…`. */
|
|
67
|
+
const link = (file) => info.browseFile(file, { committish: "main" });
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
name: pkg.name.split("/").pop(),
|
|
71
|
+
packageName: pkg.name,
|
|
72
|
+
tagline: project.tagline ?? firstSentence(pkg.description),
|
|
73
|
+
description: pkg.description,
|
|
74
|
+
license: pkg.license,
|
|
75
|
+
licenseUrl: link("LICENSE"),
|
|
76
|
+
repo,
|
|
77
|
+
repoName,
|
|
78
|
+
npm: pkg.private ? null : `https://www.npmjs.com/package/${pkg.name}`,
|
|
79
|
+
node: minimumVersion(pkg.engines?.node),
|
|
80
|
+
url: project.url ? project.url.replace(/\/?$/, "/") : host ? `${host.replace(/\/$/, "")}/${repoName}/` : null,
|
|
81
|
+
issuesUrl: pkg.bugs?.url ?? `${repo}/issues`,
|
|
82
|
+
contributingUrl: link(".github/CONTRIBUTING.md"),
|
|
83
|
+
changelogUrl: link("CHANGELOG.md"),
|
|
84
|
+
conductUrl: policyUrl(root, link, "CODE_OF_CONDUCT.md"),
|
|
85
|
+
securityUrl: policyUrl(root, link, "SECURITY.md"),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* SPDX-License-Identifier: MPL-2.0
|
|
3
|
+
* This Source Code Form is subject to the terms of the Mozilla Public
|
|
4
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
5
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
import markdownIt from "markdown-it";
|
|
12
|
+
|
|
13
|
+
import { callouts, headingAnchors, scrollTables, webcTags } from "../markdown.js";
|
|
14
|
+
import { isInstalled } from "./optional.js";
|
|
15
|
+
|
|
16
|
+
/** The code sample's Markdown add-on, when `@allons-y/webc-code-sample` is installed: fences render as `norn-code-block`. Without it they stay `<pre><code>`. */
|
|
17
|
+
const highlightFences = isInstalled("@allons-y/webc-code-sample") ? (await import("@allons-y/webc-code-sample/markdown")).highlightFences : null;
|
|
18
|
+
|
|
19
|
+
/** @param {string} file */
|
|
20
|
+
const read = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
|
21
|
+
|
|
22
|
+
/** Manifests matched by workspace patterns, relative to the root. */
|
|
23
|
+
const manifests = (root, patterns) =>
|
|
24
|
+
fs.globSync(
|
|
25
|
+
patterns.map((pattern) => `${pattern}/package.json`),
|
|
26
|
+
{ cwd: root, exclude: (name) => name === "node_modules" },
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
/** Front matter and the first heading come off: the page has its own title. Windows line endings count as the same. */
|
|
30
|
+
const body = (text) => text.replace(/\r\n?/g, "\n").replace(/^---\n[\s\S]*?\n---\n/, "").replace(/^\s*# .*\n/, "");
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One entry per workspace of the repository: its package.json and its page,
|
|
34
|
+
* the workspace's SKILL.md or else its README.md, rendered with the site's Markdown add-ons: anchored headings, callouts, tables, fences, and component tags.
|
|
35
|
+
* Its raw HTML renders as HTML, as on GitHub. Nothing is sanitized: the README is the project's own, and `norn-workspace` compiles it as WebC.
|
|
36
|
+
*
|
|
37
|
+
* @param {{ root: string }} options - The project root, where the `workspaces` field lives
|
|
38
|
+
* @returns {{ name: string, title: string, description?: string, slug: string, href: string, html: string }[]} Each an item, `href`, `title`, and `description`, with the package `name`, its `slug`, and its page `html`
|
|
39
|
+
*/
|
|
40
|
+
export function buildWorkspaces({ root }) {
|
|
41
|
+
// HTML on, as on GitHub and in Eleventy: the README is the project's own, as trusted as its pages. `norn-workspace` compiles it as WebC, and nothing is sanitized.
|
|
42
|
+
const md = markdownIt({ html: true });
|
|
43
|
+
webcTags(md);
|
|
44
|
+
highlightFences?.(md);
|
|
45
|
+
scrollTables(md);
|
|
46
|
+
callouts(md);
|
|
47
|
+
headingAnchors(md);
|
|
48
|
+
const { workspaces = [] } = read(path.join(root, "package.json"));
|
|
49
|
+
const patterns = Array.isArray(workspaces) ? workspaces : (workspaces.packages ?? []);
|
|
50
|
+
const excluded = new Set(manifests(root, patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1))));
|
|
51
|
+
return manifests(
|
|
52
|
+
root,
|
|
53
|
+
patterns.filter((pattern) => !pattern.startsWith("!")),
|
|
54
|
+
)
|
|
55
|
+
.filter((file) => !excluded.has(file))
|
|
56
|
+
.sort()
|
|
57
|
+
.map((file) => {
|
|
58
|
+
const pkg = read(path.join(root, file));
|
|
59
|
+
const dir = path.join(root, path.dirname(file));
|
|
60
|
+
const source = ["SKILL.md", "README.md"].map((name) => path.join(dir, name)).find(fs.existsSync);
|
|
61
|
+
const slug = pkg.name.split("/").pop();
|
|
62
|
+
return {
|
|
63
|
+
name: pkg.name,
|
|
64
|
+
title: pkg.name,
|
|
65
|
+
description: pkg.description,
|
|
66
|
+
slug,
|
|
67
|
+
href: `/${slug}/`,
|
|
68
|
+
html: source ? md.render(body(fs.readFileSync(source, "utf8"))) : "",
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
}
|