@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.
Files changed (48) hide show
  1. package/README.md +506 -5
  2. package/components/norn-accordion.webc +124 -0
  3. package/components/norn-breadcrumbs.webc +71 -0
  4. package/components/norn-callout.webc +97 -0
  5. package/components/norn-card-grid.webc +60 -0
  6. package/components/norn-card.webc +143 -0
  7. package/components/norn-code-tabs.webc +270 -0
  8. package/components/norn-copy-button.webc +220 -0
  9. package/components/norn-fact-list.webc +52 -0
  10. package/components/norn-footer.webc +122 -0
  11. package/components/norn-head.webc +59 -0
  12. package/components/norn-header.webc +238 -0
  13. package/components/norn-heading-anchor.webc +71 -0
  14. package/components/norn-hero.webc +81 -0
  15. package/components/norn-icon.webc +140 -0
  16. package/components/norn-link-button.webc +96 -0
  17. package/components/norn-notice.webc +94 -0
  18. package/components/norn-pager.webc +67 -0
  19. package/components/norn-rich-text.webc +105 -0
  20. package/components/norn-search.webc +303 -0
  21. package/components/norn-segmented-control.webc +143 -0
  22. package/components/norn-side-nav.webc +149 -0
  23. package/components/norn-sidebar-layout.webc +185 -0
  24. package/components/norn-simple-table.webc +120 -0
  25. package/components/norn-skip-link.webc +41 -0
  26. package/components/norn-theme-toggle.webc +127 -0
  27. package/components/norn-toc.webc +260 -0
  28. package/css/base.css +75 -0
  29. package/css/print.css +8 -0
  30. package/css/reset.css +45 -0
  31. package/fallback/norn-code-block.webc +13 -0
  32. package/index.js +170 -1
  33. package/layouts/norn-base.webc +137 -0
  34. package/layouts/norn-docs-home.webc +109 -0
  35. package/layouts/norn-docs.webc +77 -0
  36. package/layouts/norn-landing.webc +73 -0
  37. package/layouts/norn-workspace.webc +25 -0
  38. package/layouts/norn-workspaces-home.webc +44 -0
  39. package/lib/icons.js +166 -0
  40. package/lib/npm.js +49 -0
  41. package/lib/optional.js +25 -0
  42. package/lib/site.js +87 -0
  43. package/lib/workspaces.js +71 -0
  44. package/markdown.js +278 -0
  45. package/package.json +32 -3
  46. package/plugin.js +279 -0
  47. package/project.js +15 -0
  48. package/schema/project.schema.json +112 -0
package/markdown.js ADDED
@@ -0,0 +1,278 @@
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
+ * The Markdown add-on. Each amend turns Markdown into the markup an author would write by hand for the same component.
10
+ * None writes a string of its own: the component's slot fallbacks and `componentStrings` supply every text.
11
+ */
12
+
13
+ /** A custom element's name: a hyphen makes it a component, so ordinary HTML keeps CommonMark's rules. */
14
+ const NAME = String.raw`[a-z][a-z0-9]*-[a-z0-9._-]*`;
15
+ /** An attribute with any name WebC reads (`@variant`, `:@level`, `webc:if`), and an optional value. */
16
+ const ATTR = String.raw`\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>\x60]+))?`;
17
+ const OPEN = new RegExp(String.raw`^<${NAME}(?:${ATTR})*\s*\/?>`, "i");
18
+ const CLOSE = new RegExp(String.raw`^<\/${NAME}\s*>`, "i");
19
+ const TAG_LINE = new RegExp(String.raw`^(?:<${NAME}(?:${ATTR})*\s*\/?>|<\/${NAME}\s*>)\s*$`, "i");
20
+ /** A `<script>` or `<link>` open tag without `webc:keep`. */
21
+ const EMBED = /<(script|link)(?=[\s/>])(?![^>]*\swebc:keep\b)/gi;
22
+
23
+ /**
24
+ * markdown-it amend: a component tag stays raw HTML with WebC's attribute names, so `<norn-callout @variant="tip">` reaches WebC as written.
25
+ * CommonMark's attribute names have no `@`, so markdown-it escapes such a tag to text without it.
26
+ * A line that is only a component's open or close tag starts an HTML block that runs to the next blank line, as CommonMark's own do; a tag inside a paragraph stays inline.
27
+ * Fences and code spans stay literal. It does nothing when the library's `html` option is off.
28
+ * A `<script>` or `<link>` in Markdown is written for the browser, so it gets `webc:keep`: WebC then leaves it in place instead of bundling it, or failing on a full URL or a site path.
29
+ *
30
+ * @param {import("markdown-it").default} md
31
+ */
32
+ export function webcTags(md) {
33
+ if (!md.options.html) return;
34
+ md.block.ruler.before(
35
+ "html_block",
36
+ "webc_block",
37
+ (state, startLine, endLine, silent) => {
38
+ if (state.sCount[startLine] - state.blkIndent >= 4) return false;
39
+ const line = state.src.slice(state.bMarks[startLine] + state.tShift[startLine], state.eMarks[startLine]);
40
+ if (!TAG_LINE.test(line)) return false;
41
+ // Like CommonMark's HTML block of type 7, it can't interrupt a paragraph.
42
+ if (silent) return false;
43
+ let next = startLine + 1;
44
+ while (next < endLine && !state.isEmpty(next)) next++;
45
+ state.line = next;
46
+ const token = state.push("html_block", "", 0);
47
+ token.map = [startLine, next];
48
+ token.content = state.getLines(startLine, next, state.blkIndent, true);
49
+ return true;
50
+ },
51
+ { alt: ["paragraph", "reference", "blockquote"] },
52
+ );
53
+ for (const type of ["html_block", "html_inline"]) {
54
+ const render = md.renderer.rules[type];
55
+ md.renderer.rules[type] = (tokens, index, ...rest) => render(tokens, index, ...rest).replace(EMBED, "<$1 webc:keep");
56
+ }
57
+ md.inline.ruler.before("html_inline", "webc_inline", (state, silent) => {
58
+ if (state.src.charCodeAt(state.pos) !== 0x3c) return false;
59
+ const rest = state.src.slice(state.pos);
60
+ const match = OPEN.exec(rest) ?? CLOSE.exec(rest);
61
+ if (!match) return false;
62
+ if (!silent) state.push("html_inline", "", 0).content = match[0];
63
+ state.pos += match[0].length;
64
+ return true;
65
+ });
66
+ }
67
+
68
+ /**
69
+ * markdown-it amend: wrap each Markdown table in `<norn-simple-table>`, so a wide one scrolls in its own box.
70
+ *
71
+ * @param {import("markdown-it").default} md
72
+ */
73
+ export function scrollTables(md) {
74
+ const render = (tokens, index, options, env, self) => self.renderToken(tokens, index, options);
75
+ const open = md.renderer.rules.table_open ?? render;
76
+ const close = md.renderer.rules.table_close ?? render;
77
+ md.renderer.rules.table_open = (...args) => `<norn-simple-table>${open(...args)}`;
78
+ md.renderer.rules.table_close = (...args) => `${close(...args)}</norn-simple-table>\n`;
79
+ }
80
+
81
+ const CALLOUT = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/i;
82
+
83
+ /**
84
+ * markdown-it amend: a blockquote that opens with `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, or `[!CAUTION]` renders as `<norn-callout>`, as on GitHub.
85
+ * It writes no title: the component's `heading` slot falls back to the site's `componentStrings`, then English.
86
+ *
87
+ * @param {import("markdown-it").default} md
88
+ */
89
+ export function callouts(md) {
90
+ md.core.ruler.push("callouts", (state) => {
91
+ const { tokens } = state;
92
+ for (let index = 0; index < tokens.length; index++) {
93
+ const open = tokens[index];
94
+ const inline = tokens[index + 2];
95
+ if (open.type !== "blockquote_open" || tokens[index + 1]?.type !== "paragraph_open" || inline?.type !== "inline") continue;
96
+ const marker = CALLOUT.exec(inline.content);
97
+ if (!marker) continue;
98
+ const variant = marker[1].toLowerCase();
99
+ inline.content = inline.content.slice(marker[0].length);
100
+ const [first, second] = inline.children;
101
+ first.content = first.content.replace(CALLOUT, "");
102
+ if (!first.content) inline.children.splice(0, second?.type === "softbreak" ? 2 : 1);
103
+ if (!inline.children.length) tokens[index + 1].hidden = tokens[index + 3].hidden = true;
104
+ open.meta = { ...open.meta, callout: variant };
105
+ for (let depth = 0, close = index; close < tokens.length; close++) {
106
+ depth += tokens[close].nesting * (tokens[close].type.startsWith("blockquote") ? 1 : 0);
107
+ if (tokens[close].type === "blockquote_close" && depth === 0) {
108
+ tokens[close].meta = { ...tokens[close].meta, callout: variant };
109
+ break;
110
+ }
111
+ }
112
+ }
113
+ });
114
+ const render = (tokens, index, options, env, self) => self.renderToken(tokens, index, options);
115
+ const callout = (variant) => `<norn-callout @variant="${variant}">\n`;
116
+ const open = md.renderer.rules.blockquote_open ?? render;
117
+ const close = md.renderer.rules.blockquote_close ?? render;
118
+ md.renderer.rules.blockquote_open = (tokens, index, ...rest) => (tokens[index].meta?.callout ? callout(tokens[index].meta.callout) : open(tokens, index, ...rest));
119
+ md.renderer.rules.blockquote_close = (tokens, index, ...rest) => (tokens[index].meta?.callout ? "</norn-callout>\n" : close(tokens, index, ...rest));
120
+ }
121
+
122
+ /** Text for comparing titles: spaces collapsed, case folded. */
123
+ const titleText = (text) => String(text).replace(/\s+/g, " ").trim().toLowerCase();
124
+
125
+ /**
126
+ * markdown-it amend: on a layout that shows the page title as its h1, keep the page to one h1.
127
+ * A leading `# Title` that repeats the title goes, and any other h1 becomes an h2, which `headingAnchors` then anchors.
128
+ * The layout is the page's own `layout`, by name; the title is the layout's, `norn.title`, or else the page's `title`.
129
+ *
130
+ * @param {import("markdown-it").default} md
131
+ * @param {{ layouts?: string[] }} [options] - Layouts that show the title as an h1. Defaults to `norn-docs` and `norn-workspace`.
132
+ */
133
+ export function singleTitle(md, { layouts = ["norn-docs", "norn-workspace"] } = {}) {
134
+ const rule = (state) => {
135
+ const env = state.env && typeof state.env === "object" ? state.env : {};
136
+ const layout = String(env.layout ?? "").split("/").pop().replace(/\.webc$/, "");
137
+ if (!layouts.includes(layout)) return;
138
+ const title = env.norn?.workspace?.name ?? env.norn?.title ?? env.title;
139
+ const { tokens } = state;
140
+ const text = (inline) => inline.children.filter((child) => child.type === "text" || child.type === "code_inline").map((child) => child.content).join("");
141
+ if (tokens[0]?.type === "heading_open" && tokens[0].tag === "h1" && typeof title === "string" && titleText(text(tokens[1])) === titleText(title)) tokens.splice(0, 3);
142
+ for (const token of tokens) {
143
+ if ((token.type === "heading_open" || token.type === "heading_close") && token.tag === "h1") {
144
+ token.tag = "h2";
145
+ token.markup = "##";
146
+ }
147
+ }
148
+ };
149
+ // It runs before the anchors, so a demoted heading gets one.
150
+ if (md.core.ruler.__find__("heading_anchors") === -1) md.core.ruler.push("single_title", rule);
151
+ else md.core.ruler.before("heading_anchors", "single_title", rule);
152
+ }
153
+
154
+ /**
155
+ * A URL fragment from heading text: the letters, marks, and digits of any script, lowercase, with accents off Latin letters, and runs of anything else as one hyphen.
156
+ * An ASCII heading keeps the id it always had.
157
+ *
158
+ * @param {string} text
159
+ * @returns {string}
160
+ */
161
+ export const slugify = (text) =>
162
+ String(text)
163
+ .normalize("NFKD")
164
+ .toLowerCase()
165
+ .replace(/(?<=\p{Script=Latin})\p{M}+/gu, "")
166
+ .normalize("NFC")
167
+ .replace(/[^\p{L}\p{M}\p{N}]+/gu, "-")
168
+ .replace(/^-|-$/g, "") || "section";
169
+
170
+ /** Ids taken so far, by page: Eleventy hands each Markdown render of a page, such as two Markdown blocks, the same `page` object, and a new one each build. Without a page, by render. */
171
+ const taken = new WeakMap();
172
+
173
+ /**
174
+ * The ids given so far on the page this render belongs to.
175
+ *
176
+ * @param {object} env - The render's environment: Eleventy's page data
177
+ * @returns {Set<string>}
178
+ */
179
+ function idsOf(env) {
180
+ const key = env?.page && typeof env.page === "object" ? env.page : env && typeof env === "object" ? env : {};
181
+ if (!taken.has(key)) taken.set(key, new Set());
182
+ return taken.get(key);
183
+ }
184
+
185
+ /**
186
+ * markdown-it amend: give each heading at the chosen levels an id, and render it as `norn-heading-anchor`, as an author writes it, so its text links to itself.
187
+ * A repeated heading gets `-2`, `-3`, and so on, counted across the page. A heading that already has an id keeps it.
188
+ * A heading whose text holds a link gets the id only, so no link nests in another. So does an h1, which the component has no tag for.
189
+ *
190
+ * @param {import("markdown-it").default} md
191
+ * @param {{ levels?: number[] }} [options] - Heading levels to anchor. Defaults to 2 and 3.
192
+ */
193
+ export function headingAnchors(md, { levels = [2, 3] } = {}) {
194
+ md.core.ruler.push("heading_anchors", (state) => {
195
+ const ids = idsOf(state.env);
196
+ state.tokens.forEach((token, index) => {
197
+ const level = Number(token.tag.slice(1));
198
+ if (token.type !== "heading_open" || !levels.includes(level)) return;
199
+ const inline = state.tokens[index + 1];
200
+ let id = token.attrGet("id");
201
+ if (!id) {
202
+ const base = slugify(inline.children.filter((child) => child.type === "text" || child.type === "code_inline").map((child) => child.content).join(""));
203
+ id = base;
204
+ for (let count = 2; ids.has(id); count++) id = `${base}-${count}`;
205
+ token.attrSet("id", id);
206
+ }
207
+ ids.add(id);
208
+ // A link can't hold a link: a heading whose text links elsewhere keeps the author's link and takes only the id.
209
+ if (level < 2 || inline.children.some((child) => child.type === "link_open")) return;
210
+ const anchor = { slug: id, level };
211
+ token.meta = { ...token.meta, anchor };
212
+ const close = state.tokens.slice(index).find((next) => next.type === "heading_close");
213
+ close.meta = { ...close.meta, anchor };
214
+ });
215
+ });
216
+ const render = (tokens, index, options, env, self) => self.renderToken(tokens, index, options);
217
+ const open = md.renderer.rules.heading_open ?? render;
218
+ const close = md.renderer.rules.heading_close ?? render;
219
+ md.renderer.rules.heading_open = (tokens, index, ...rest) => {
220
+ const anchor = tokens[index].meta?.anchor;
221
+ if (!anchor) return open(tokens, index, ...rest);
222
+ return `<norn-heading-anchor @slug="${md.utils.escapeHtml(anchor.slug)}"${anchor.level === 2 ? "" : ` :@level="${anchor.level}"`}>`;
223
+ };
224
+ md.renderer.rules.heading_close = (tokens, index, ...rest) => (tokens[index].meta?.anchor ? "</norn-heading-anchor>\n" : close(tokens, index, ...rest));
225
+ }
226
+
227
+ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'", apos: "'" };
228
+
229
+ /** Markup as plain text: tags off, the common entities decoded, spaces collapsed. */
230
+ const plain = (markup) =>
231
+ markup
232
+ .replace(/<[^>]+>/g, "")
233
+ .replace(/&(amp|lt|gt|quot|#39|apos);/g, (_, name) => ENTITIES[name])
234
+ .replace(/\s+/g, " ")
235
+ .trim();
236
+
237
+ /** A heading with an id, as compiled or written as HTML. */
238
+ const HEADING = /<h([1-6])\b[^>]*\bid="([^"]+)"[^>]*>([\s\S]*?)<\/h\1>/g;
239
+ /** `norn-heading-anchor` before WebC compiles it: as `headingAnchors` writes it, or as an author does in Markdown. */
240
+ const ANCHOR = new RegExp(String.raw`<norn-heading-anchor\b((?:${ATTR})*)\s*>([\s\S]*?)<\/norn-heading-anchor>`, "g");
241
+
242
+ /**
243
+ * A prop of an uncompiled tag: `@name="x"`, `name="x"`, or `:@name` with a string or number literal.
244
+ *
245
+ * @param {string} attributes
246
+ * @param {string} name
247
+ * @returns {string | undefined}
248
+ */
249
+ function prop(attributes, name) {
250
+ const match = new RegExp(String.raw`(?:^|\s)(:?@?)${name}\s*=\s*(?:"([^"]*)"|'([^']*)')`).exec(attributes);
251
+ if (!match) return undefined;
252
+ const value = (match[2] ?? match[3]).replace(/&(amp|lt|gt|quot|#39|apos);/g, (_, entity) => ENTITIES[entity]);
253
+ if (match[1] !== ":@") return value;
254
+ const literal = /^\s*(?:'([^']*)'|"([^"]*)"|(\d+))\s*$/.exec(value);
255
+ return literal ? (literal[1] ?? literal[2] ?? literal[3]) : undefined;
256
+ }
257
+
258
+ /**
259
+ * The headings in rendered HTML that carry an id, for `norn-toc`, in page order.
260
+ * It reads a heading with an id, and a `norn-heading-anchor` that WebC has not compiled yet, as `headingAnchors` writes it in Markdown.
261
+ *
262
+ * @param {string} html
263
+ * @param {{ levels?: number[] }} [options] - Heading levels to list. Defaults to 2 and 3.
264
+ * @returns {{ id: string, text: string, level: number }[]}
265
+ */
266
+ export function headings(html, { levels = [2, 3] } = {}) {
267
+ if (typeof html !== "string") return [];
268
+ const found = [...html.matchAll(HEADING)].map((match) => ({ at: match.index, id: match[2], level: Number(match[1]), text: plain(match[3]) }));
269
+ for (const match of html.matchAll(ANCHOR)) {
270
+ const id = prop(match[1], "slug");
271
+ // A compiled anchor has no slug on its tag: the heading inside it is listed above.
272
+ if (id) found.push({ at: match.index, id, level: Number(prop(match[1], "level") ?? 2) || 2, text: plain(match[2]) });
273
+ }
274
+ return found
275
+ .filter((heading) => levels.includes(heading.level))
276
+ .sort((a, b) => a.at - b.at)
277
+ .map(({ id, level, text }) => ({ id, level, text }));
278
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@allons-y/webc-patterns",
3
- "version": "0.1.0-beta.0",
4
- "description": "Generic WebC page and docs patterns for Eleventy sites: head, header, footer, hero, card, side nav, docs shell, and pager.",
3
+ "version": "0.1.0-beta.1",
4
+ "description": "Generic WebC components and Eleventy layouts for page and docs sites, with the Norn plugin that registers them: header, footer, hero, cards, side nav, docs shell, and pager.",
5
5
  "license": "MPL-2.0",
6
6
  "author": "Cassondra Roberts <castastrophe@users.noreply.github.com> (https://allons-y.studio)",
7
7
  "homepage": "https://github.com/allonsy-studio/webc-resources/tree/main/packages/webc-patterns#readme",
@@ -20,17 +20,46 @@
20
20
  "type": "module",
21
21
  "exports": {
22
22
  ".": "./index.js",
23
+ "./markdown": "./markdown.js",
24
+ "./plugin": "./plugin.js",
25
+ "./project": "./project.js",
26
+ "./css/reset.css": "./css/reset.css",
27
+ "./css/base.css": "./css/base.css",
28
+ "./css/print.css": "./css/print.css",
29
+ "./schema/project.schema.json": "./schema/project.schema.json",
23
30
  "./package.json": "./package.json"
24
31
  },
25
32
  "files": [
26
33
  "components/",
34
+ "layouts/",
35
+ "fallback/",
36
+ "css/",
37
+ "lib/",
38
+ "schema/",
27
39
  "index.js",
40
+ "markdown.js",
41
+ "plugin.js",
42
+ "project.js",
28
43
  "LICENSE",
29
44
  "README.md"
30
45
  ],
46
+ "scripts": {
47
+ "prepack": "node ../../scripts/build-components.js webc-patterns"
48
+ },
49
+ "dependencies": {
50
+ "hosted-git-info": "^10.1.1",
51
+ "markdown-it": "^15.0.2",
52
+ "semver": "^7.8.5"
53
+ },
31
54
  "peerDependencies": {
32
55
  "@11ty/eleventy": "^3.0.0",
33
- "@11ty/eleventy-plugin-webc": "^0.11.0"
56
+ "@11ty/eleventy-plugin-webc": "^0.11.0",
57
+ "@allons-y/webc-code-sample": "^0.1.0-beta.0"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "@allons-y/webc-code-sample": {
61
+ "optional": true
62
+ }
34
63
  },
35
64
  "keywords": [
36
65
  "webc",
package/plugin.js ADDED
@@ -0,0 +1,279 @@
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 webc from "@11ty/eleventy-plugin-webc";
12
+
13
+ import { adjacent, componentStrings, dropEmptyBundleTags, patterns } from "./index.js";
14
+ import { readIcons } from "./lib/icons.js";
15
+ import { isInstalled } from "./lib/optional.js";
16
+ import { callouts, headingAnchors, headings, scrollTables, singleTitle, webcTags } from "./markdown.js";
17
+
18
+ const HERE = import.meta.dirname;
19
+ const posix = (file) => file.split(path.sep).join("/");
20
+
21
+ /** Absolute path to the optional reset, `css/reset.css`, in the `norn.reset` layer. Norn looks the same without it. */
22
+ export const reset = posix(path.join(HERE, "css", "reset.css"));
23
+
24
+ /** Absolute path to the base styles, `css/base.css`, that `norn-base` embeds. For a layout of the project's own. */
25
+ export const base = posix(path.join(HERE, "css", "base.css"));
26
+
27
+ /** Absolute path to the print styles, `css/print.css`, that `norn-base` embeds. For a layout of the project's own. */
28
+ export const print = posix(path.join(HERE, "css", "print.css"));
29
+
30
+ /** The places in `norn-base` a theme or project fills with markup, in page order. */
31
+ export const HOOKS = Object.freeze(["head", "banner", "cta", "credit"]);
32
+
33
+ /** The layouts the plugin ships. Every one but `norn-base` extends `norn-base`. */
34
+ export const LAYOUTS = Object.freeze(["norn-base", "norn-docs", "norn-docs-home", "norn-landing", "norn-workspace", "norn-workspaces-home"]);
35
+
36
+ /** The props `norn-base` and the layouts read, looked up on the page data by name when there is no `props` mapping. */
37
+ const PROPS = [
38
+ "lang",
39
+ "dir",
40
+ "title",
41
+ "name",
42
+ "tagline",
43
+ "description",
44
+ "url",
45
+ "ogTitle",
46
+ "ogImage",
47
+ "locale",
48
+ "themeColor",
49
+ "themeToggle",
50
+ "icon",
51
+ "touchIcon",
52
+ "logo",
53
+ "nav",
54
+ "footer",
55
+ "lede",
56
+ "install",
57
+ "repo",
58
+ "license",
59
+ "node",
60
+ "usage",
61
+ "features",
62
+ "packages",
63
+ "docs",
64
+ "prev",
65
+ "next",
66
+ "search",
67
+ "workspaces",
68
+ "workspace",
69
+ ];
70
+
71
+ /** The props that are lists: each defaults to `[]`, so a layout can loop over it. */
72
+ const LISTS = ["nav", "features", "packages", "docs", "workspaces"];
73
+
74
+ /** Icon types by file extension, for the icon link. */
75
+ const ICON_TYPES = { ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".webp": "image/webp" };
76
+
77
+ /** The plain `norn-code-block`, `<pre><code>`, registered in place of the code sample when it is not installed. */
78
+ const plainCodeBlock = posix(path.join(HERE, "fallback", "norn-code-block.webc"));
79
+
80
+ /**
81
+ * `@allons-y/webc-code-sample`, when it is installed: an optional peer. Without it the plugin registers `plainCodeBlock` under the same tag.
82
+ * Any other failure to load it is a real error and is thrown.
83
+ */
84
+ const codeSample = isInstalled("@allons-y/webc-code-sample")
85
+ ? await Promise.all([import("@allons-y/webc-code-sample"), import("@allons-y/webc-code-sample/markdown")]).then(([main, markdown]) => ({
86
+ codeBlock: main.codeBlock,
87
+ tokenRanges: main.tokenRanges,
88
+ highlightFences: markdown.highlightFences,
89
+ }))
90
+ : null;
91
+
92
+ /** Per-configuration state: the hook fills, and whether the plugin ran. */
93
+ const states = new WeakMap();
94
+
95
+ function state(config) {
96
+ if (!states.has(config)) states.set(config, { hooks: Object.fromEntries(HOOKS.map((name) => [name, []])), registered: false });
97
+ return states.get(config);
98
+ }
99
+
100
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
101
+ const list = (value) => (Array.isArray(value) ? value : []);
102
+ const defined = (object) => Object.fromEntries(Object.entries(isObject(object) ? object : {}).filter(([, value]) => value !== undefined));
103
+
104
+ /**
105
+ * Add markup to the hooks in `norn-base`: `head` (end of the head), `banner` (before the header), `cta` (end of `main`), and `credit` (the footer's last column).
106
+ * A fill is WebC markup, compiled with the page, so it may call components. Fills are appended in the order they are added, and no page data can change them.
107
+ * Themes call it from their plugin, and projects from their config: a favicon, a project stylesheet.
108
+ *
109
+ * @param {import("@11ty/eleventy").UserConfig} config
110
+ * @param {{ head?: string, banner?: string, cta?: string, credit?: string }} fills - Markup by hook name
111
+ * @throws {Error} On an unknown hook name, or a fill that is not a string
112
+ */
113
+ export function fillHooks(config, fills = {}) {
114
+ const { hooks } = state(config);
115
+ for (const [name, markup] of Object.entries(fills)) {
116
+ if (!HOOKS.includes(name)) throw new Error(`norn: no hook named "${name}". Hooks: ${HOOKS.join(", ")}`);
117
+ if (typeof markup !== "string") throw new Error(`norn: the ${name} hook takes a markup string.`);
118
+ hooks[name].push(markup);
119
+ }
120
+ }
121
+
122
+ /** The props a page reads off its own data when the project gives no mapping: the same-named fields. */
123
+ const sameNamed = (data) => Object.fromEntries(PROPS.map((key) => [key, data?.[key]]));
124
+
125
+ /**
126
+ * The layout props for one page, from the mapping or the same-named page data, over the theme's defaults.
127
+ * It runs against Eleventy's placeholder data too, so it never throws on missing data, and every list is a list.
128
+ *
129
+ * @param {object} data - The page data
130
+ * @param {(data: object) => object} map
131
+ * @param {object} defaults
132
+ */
133
+ function layoutProps(data, map, defaults) {
134
+ const props = { ...defined(defaults), ...defined(map(data)) };
135
+ props.lang = typeof props.lang === "string" && props.lang ? props.lang : typeof data?.page?.lang === "string" && data.page.lang ? data.page.lang : "en";
136
+ for (const key of LISTS) props[key] = list(props[key]);
137
+ const footer = defined(props.footer);
138
+ props.footer = { ...footer, badges: list(footer.badges), groups: list(footer.groups).map((group) => ({ ...defined(group), links: list(group?.links) })) };
139
+ if (typeof props.icon === "string") props.icon = { href: props.icon };
140
+ if (isObject(props.icon) && typeof props.icon.href === "string") {
141
+ props.icon = { ...props.icon, type: props.icon.type ?? ICON_TYPES[path.extname(props.icon.href).toLowerCase()] ?? "image/png" };
142
+ } else {
143
+ delete props.icon;
144
+ }
145
+ if (typeof props.logo === "string") props.logo = { src: props.logo };
146
+ if (!isObject(props.logo) || typeof props.logo.src !== "string") delete props.logo;
147
+ if (typeof props.ogImage === "string") props.ogImage = { src: props.ogImage };
148
+ if (!isObject(props.ogImage) || typeof props.ogImage.src !== "string" || !props.ogImage.src) delete props.ogImage;
149
+ return props;
150
+ }
151
+
152
+ /**
153
+ * Whether a shipped layout compiles the page content as WebC, so the components the Markdown add-ons write render: the `nornCompiles` helper.
154
+ * Content compiles exactly once, and only when it is Markdown output that no WebC has compiled, by `page.templateSyntax`.
155
+ * WebC output, a `templateEngineOverride` with `webc`, and a Nunjucks or Liquid page that renders through `{% renderTemplate "webc" %}` are already compiled.
156
+ * A project's own WebC layout as the page's layout compiles the content itself. A Nunjucks or Liquid layout in the chain, or a layout alias, does not.
157
+ * Every child layout sets `nornCompiled: true` in its front matter, which reaches `norn-base` in the data, so the frame leaves what the child compiled.
158
+ *
159
+ * @param {object} data - The page data, `$data`
160
+ * @param {string} layout - The shipped layout that asks, such as `norn-docs`
161
+ * @param {Record<string, string>} [aliases] - The project's layout aliases
162
+ * @returns {boolean}
163
+ */
164
+ export function compiles(data, layout, aliases = {}) {
165
+ const engines = String(data?.page?.templateSyntax ?? "").split(",");
166
+ if (!engines.includes("md") || engines.includes("webc")) return false;
167
+ const own = String(aliases[data.layout] ?? data.layout ?? "");
168
+ if (own.endsWith(".webc") && !LAYOUTS.includes(path.posix.basename(own, ".webc"))) return false;
169
+ return layout !== "norn-base" || !data.nornCompiled;
170
+ }
171
+
172
+ /** Read a compiled layout, with `norn-base`'s style placeholders filled from the package's css folder. */
173
+ function readLayout(name) {
174
+ const text = fs.readFileSync(path.join(HERE, "layouts", `${name}.webc`), "utf8");
175
+ return text.replace(/\/\* norn: css\/(base|print)\.css \*\//g, (_, file) => fs.readFileSync(path.join(HERE, "css", `${file}.css`), "utf8").trim());
176
+ }
177
+
178
+ /**
179
+ * The Norn plugin for Eleventy: the one `@11ty/eleventy-plugin-webc` registration, the layouts, the helpers, and the Markdown add-ons.
180
+ *
181
+ * @param {import("@11ty/eleventy").UserConfig} config
182
+ * @param {object} [options]
183
+ * @param {(data: object) => object} [options.props] - Maps a page's data to the layout props, added as `eleventyComputed.norn`. Without it, each prop is read off the same-named page data. It runs against Eleventy's placeholder data too: it must not throw on missing data.
184
+ * @param {boolean | string[]} [options.layouts] - `true` ships the six layouts, `false` none, or a list of layout names to leave out, so the project can supply its own
185
+ * @param {string[]} [options.components] - More component globs: a theme's, the project's
186
+ * @param {string[]} [options.exclude] - Packaged components to leave out, so the project can supply its own
187
+ * @param {Record<string, object>} [options.strings] - The `componentStrings` table, registered as `nornComponentStrings`
188
+ * @param {string | string[] | Record<string, string | { src: string, mirror?: boolean }>} [options.icons] - Project icons for `norn-icon`: a glob or list of globs of `.svg` files, each named for its file without the extension, or a record of names to a path or `{ src, mirror }`. Paths resolve against the working directory. A name that matches a built-in icon replaces it
189
+ * @param {boolean} [options.markdown] - Amend Markdown with the add-ons: component tags with WebC attributes kept as HTML, fenced code as `norn-code-block` when the code sample is installed, callouts, scrolling tables, one h1 on a layout that shows the title, and anchored headings. On by default
190
+ * @param {object} [options.defaults] - Prop values a theme sets, under the mapping's
191
+ * @throws {Error} On a bad option, a missing icon file, a second registration of the plugin or of `@11ty/eleventy-plugin-webc`, or, at build time, a project layout named like a shipped one or a page that sets the reserved `norn` key
192
+ */
193
+ export default function norn(config, options = {}) {
194
+ const { props, layouts = true, components = [], exclude = [], strings = {}, markdown = true, defaults = {}, icons } = options ?? {};
195
+ if (props !== undefined && typeof props !== "function") throw new Error("norn: `props` must be a function from page data to layout props.");
196
+ if (!Array.isArray(components) || components.some((glob) => typeof glob !== "string")) throw new Error("norn: `components` must be a list of globs.");
197
+ if (layouts !== true && layouts !== false && !Array.isArray(layouts)) throw new Error("norn: `layouts` must be true, false, or a list of layout names to leave out.");
198
+ const skipped = Array.isArray(layouts) ? layouts.map((name) => String(name).replace(/\.webc$/, "")) : [];
199
+ const unknown = skipped.filter((name) => !LAYOUTS.includes(name));
200
+ if (unknown.length) throw new Error(`norn: no layout named ${unknown.map((name) => `"${name}"`).join(", ")}. Layouts: ${LAYOUTS.join(", ")}`);
201
+
202
+ let projectIcons = readIcons(icons);
203
+
204
+ const own = state(config);
205
+ if (own.registered) throw new Error("norn: the plugin is added twice. A theme such as @allons-y/webc-oss-site adds it for you: add the theme in its place.");
206
+ own.registered = true;
207
+
208
+ if (config.plugins?.some((entry) => entry.plugin === webc)) {
209
+ throw new Error("norn: @11ty/eleventy-plugin-webc is registered twice. The Norn plugin registers it: remove the project's own `addPlugin`, and pass its component globs as `components`.");
210
+ }
211
+ try {
212
+ config.addPlugin(webc, { components: [patterns({ exclude }), codeSample ? codeSample.codeBlock : plainCodeBlock, ...components] });
213
+ } catch (error) {
214
+ if (/"webc" template syntax twice/.test(error.message)) {
215
+ throw new Error("norn: @11ty/eleventy-plugin-webc is registered twice. The Norn plugin registers it: remove the other `addPlugin`, and pass its component globs as `components`.", { cause: error });
216
+ }
217
+ throw error;
218
+ }
219
+
220
+ config.addJavaScriptFunction("nornAdjacent", adjacent);
221
+ config.addJavaScriptFunction("nornHeadings", headings);
222
+ config.addJavaScriptFunction("nornComponentStrings", componentStrings(strings));
223
+ config.addJavaScriptFunction("nornHook", (name) => {
224
+ if (!HOOKS.includes(name)) throw new Error(`norn: no hook named "${name}". Hooks: ${HOOKS.join(", ")}`);
225
+ return own.hooks[name].join("\n");
226
+ });
227
+ config.addJavaScriptFunction("nornIcons", () => projectIcons);
228
+ config.addJavaScriptFunction("nornCompiles", (data, layout) => compiles(data, layout, config.layoutAliases ?? {}));
229
+ if (icons !== undefined) {
230
+ config.on("eleventy.before", () => {
231
+ projectIcons = readIcons(icons);
232
+ });
233
+ for (const glob of [icons].flat()) if (typeof glob === "string") config.addWatchTarget?.(glob);
234
+ if (isObject(icons)) for (const value of Object.values(icons)) config.addWatchTarget?.(typeof value === "string" ? value : value.src);
235
+ }
236
+ if (codeSample) config.addJavaScriptFunction("nornTokenRanges", codeSample.tokenRanges);
237
+
238
+ if (markdown) {
239
+ config.amendLibrary("md", webcTags);
240
+ if (codeSample) config.amendLibrary("md", codeSample.highlightFences);
241
+ config.amendLibrary("md", scrollTables);
242
+ config.amendLibrary("md", callouts);
243
+ config.amendLibrary("md", singleTitle);
244
+ config.amendLibrary("md", headingAnchors);
245
+ }
246
+
247
+ config.addTransform("norn-empty-bundle-tags", dropEmptyBundleTags);
248
+
249
+ const map = props ?? sameNamed;
250
+ config.addGlobalData("eleventyComputed", { norn: (data) => layoutProps(data, map, defaults) });
251
+ // A preprocessor sees the data before computed data runs, so a page's own `norn` is still there to report.
252
+ config.addPreprocessor("norn-reserved", "*", (data) => {
253
+ if (data?.norn !== undefined) throw new Error(`norn: ${data.page?.inputPath} sets \`norn\`, a reserved key: the plugin computes it from \`props\`, which would replace it. Map the value in \`props\`, or rename the key.`);
254
+ });
255
+
256
+ if (layouts === false) return;
257
+ const shipped = LAYOUTS.filter((name) => !skipped.includes(name));
258
+ const dirs = config.directories;
259
+ const input = path.posix.normalize(dirs.input);
260
+ const strays = new Set();
261
+ for (const name of shipped) {
262
+ // Eleventy looks a layout up by its path relative to the input folder, and lists a virtual one outside it as a page too.
263
+ const file = path.posix.normalize(path.posix.join(dirs.layouts ?? dirs.includes, `${name}.webc`));
264
+ const key = file.startsWith(input) && input !== "./" ? file.slice(input.length) : file;
265
+ config.addTemplate(key, readLayout(name), {});
266
+ strays.add(`./${path.posix.join(input, key)}`);
267
+ }
268
+ config.addPreprocessor("norn-layouts", "webc", function () {
269
+ if (strays.has(this.inputPath)) return false;
270
+ });
271
+
272
+ config.on("eleventy.before", () => {
273
+ const folders = [...new Set([dirs.includes, dirs.layouts].filter(Boolean))];
274
+ const found = folders.flatMap((folder) => shipped.map((name) => path.join(folder, `${name}.webc`))).filter((file) => fs.existsSync(file));
275
+ if (found.length) {
276
+ throw new Error(`norn: ${found.map(posix).join(", ")} has the name of a shipped layout, which would silently replace the file. Rename it, or leave the shipped one out with \`layouts: [${found.map((file) => `"${path.basename(file, ".webc")}"`).join(", ")}]\`.`);
277
+ }
278
+ });
279
+ }
package/project.js ADDED
@@ -0,0 +1,15 @@
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
+ * Optional data builders for a project site: facts from package.json and project.json, one entry per workspace, the npm release check,
10
+ * and the accent for badges. A project maps what they return to the layout props through the Norn plugin's `props`.
11
+ */
12
+
13
+ export { accentHex, npmRelease } from "./lib/npm.js";
14
+ export { buildSite } from "./lib/site.js";
15
+ export { buildWorkspaces } from "./lib/workspaces.js";