vowel 0.7.8 → 0.8.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/config.js +5 -8
- package/package.json +4 -3
- package/plugins/atproto/body.js +20 -4
- package/plugins/html/bundle/index.js +4 -2
- package/plugins/html/bundle/live-reload.js +153 -0
- package/plugins/html/editor/ingest.js +7 -0
- package/plugins/html/editor/live-reload.js +8 -0
- package/plugins/html/editor/rollup.config.js +20 -8
- package/plugins/html/externalLinks.js +40 -0
- package/plugins/html/headingIds.js +35 -0
- package/plugins/html/highlighting.js +66 -0
- package/plugins/html/index.js +30 -14
- package/plugins/html/markdownGrammar.js +152 -0
- package/plugins/html/partials.js +71 -21
- package/plugins/html/socialLinks.js +4 -1
- package/plugins/html/writeRules.js +61 -18
- package/plugins/markdown/codeFence.js +56 -0
- package/plugins/markdown/index.js +12 -1
- package/plugins/markdown/typography.js +5 -5
- package/stylesheets/DefaultStyles.css +34 -9
- package/stylesheets/SyntaxHighlightingStyles.css +125 -232
- package/stylesheets/TypographyStyles.css +16 -7
- package/version.js +9 -0
package/config.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
|
-
import { readFileSync } from "node:fs"
|
|
3
2
|
import { resolveProjectFolder } from "votive"
|
|
4
3
|
import { systemDirectoryFor } from "./systemPaths.js"
|
|
5
4
|
import { secretRouter } from "./secretPaths.js"
|
|
5
|
+
import { VOWEL_VERSION } from "./version.js"
|
|
6
6
|
import vowelImagesPlugin from "./plugins/images/index.js"
|
|
7
7
|
import vowelStylesPlugin from "./plugins/styles/index.js"
|
|
8
8
|
import vowelReadMarkdownPlugin from "./plugins/markdown/index.js"
|
|
@@ -32,13 +32,6 @@ import vowelCheckPlugin from "./plugins/check/index.js"
|
|
|
32
32
|
* the values), and a hex digest is lowercase already.
|
|
33
33
|
* @param {string} sourcePath
|
|
34
34
|
*/
|
|
35
|
-
/**
|
|
36
|
-
* Vowel's part of the cache key (votive's cacheKey.js): a warm start
|
|
37
|
-
* reuses the build cache only if this same version of vowel made it, so
|
|
38
|
-
* an upgrade that changes a bundled stylesheet or how pages render is
|
|
39
|
-
* seen without a `--clear`.
|
|
40
|
-
*/
|
|
41
|
-
const VOWEL_VERSION = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")).version
|
|
42
35
|
|
|
43
36
|
function vowelRouter(sourcePath) {
|
|
44
37
|
return secretRouter(sourcePath).toLowerCase()
|
|
@@ -116,6 +109,10 @@ function createConfig(sourceFolder = ".", overrides = {}) {
|
|
|
116
109
|
databasePath: path.join(systemDirectory, ".votive.db"),
|
|
117
110
|
cacheDirectory: path.join(systemDirectory, ".cache"),
|
|
118
111
|
plugins: [...defaultPlugins, ...(extraPlugins ?? [])],
|
|
112
|
+
// Vowel's part of the cache key (votive's cacheKey.js): a warm start
|
|
113
|
+
// reuses the build cache only if this same version of vowel made it,
|
|
114
|
+
// so an upgrade that changes a bundled stylesheet or how pages
|
|
115
|
+
// render is seen without a `--clear`.
|
|
119
116
|
cacheKey: `vowel@${VOWEL_VERSION}`,
|
|
120
117
|
...rest
|
|
121
118
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vowel",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.8.1",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=26"
|
|
7
7
|
},
|
|
@@ -42,7 +42,6 @@
|
|
|
42
42
|
"hast-util-to-text": "^4.0.2",
|
|
43
43
|
"hastscript": "^9.0.1",
|
|
44
44
|
"lightningcss": "^1.33.0",
|
|
45
|
-
"lowlight": "^3.3.0",
|
|
46
45
|
"mdast-util-from-markdown": "^2.0.2",
|
|
47
46
|
"mdast-util-frontmatter": "^2.0.1",
|
|
48
47
|
"mdast-util-gfm-footnote": "^2.1.0",
|
|
@@ -58,6 +57,7 @@
|
|
|
58
57
|
"micromark-extension-gfm-strikethrough": "^2.1.0",
|
|
59
58
|
"micromark-extension-gfm-table": "^2.1.1",
|
|
60
59
|
"micromark-extension-highlight-mark": "^1.2.0",
|
|
60
|
+
"refractor": "^5.0.0",
|
|
61
61
|
"rehype-preset-minify": "^7.0.1",
|
|
62
62
|
"rehype-slug": "^6.0.0",
|
|
63
63
|
"rehype-stringify": "^10.0.1",
|
|
@@ -81,6 +81,7 @@
|
|
|
81
81
|
"secretPaths.js",
|
|
82
82
|
"systemPaths.js",
|
|
83
83
|
"utils.js",
|
|
84
|
+
"version.js",
|
|
84
85
|
"plugins",
|
|
85
86
|
"skills",
|
|
86
87
|
"stylesheets",
|
|
@@ -88,4 +89,4 @@
|
|
|
88
89
|
"!plugins/html/bundle.js",
|
|
89
90
|
"!plugins/html/editorClient.js"
|
|
90
91
|
]
|
|
91
|
-
}
|
|
92
|
+
}
|
package/plugins/atproto/body.js
CHANGED
|
@@ -6,6 +6,7 @@ import { gfmTaskListItemToMarkdown } from "mdast-util-gfm-task-list-item"
|
|
|
6
6
|
import { toText } from "hast-util-to-text"
|
|
7
7
|
import { emojiFor } from "../markdown/emoji.js"
|
|
8
8
|
import { alertLabel } from "../html/editor/directives.js"
|
|
9
|
+
import { fenceWithTitle } from "../markdown/codeFence.js"
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* A page's body as a record carries it, from the rendered
|
|
@@ -185,10 +186,13 @@ function block(node, base) {
|
|
|
185
186
|
if (tag === "picture") return [paragraph([node], base)]
|
|
186
187
|
if (tag === "aside" && hasClass(node, "alert")) return [alert(node, base)]
|
|
187
188
|
if (tag === "blockquote") return [{ type: "blockquote", children: blocks(node.children ?? [], base) }]
|
|
188
|
-
if (tag === "pre")
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
189
|
+
if (tag === "pre") return [codeBlock(node)]
|
|
190
|
+
if (tag === "figure") {
|
|
191
|
+
const [caption, pre, ...rest] = (node.children ?? []).filter(child => child.type === "element")
|
|
192
|
+
if (is(caption, "figcaption") && is(pre, "pre") && !rest.length) {
|
|
193
|
+
const code = codeBlock(pre)
|
|
194
|
+
return [{ ...code, ...fenceWithTitle(code.lang, toText(caption).trim()) }]
|
|
195
|
+
}
|
|
192
196
|
}
|
|
193
197
|
if (tag === "ul" || tag === "ol") return [list(node, base)]
|
|
194
198
|
if (tag === "table") return [table(node, base)]
|
|
@@ -196,6 +200,18 @@ function block(node, base) {
|
|
|
196
200
|
return blocks(node.children ?? [], base)
|
|
197
201
|
}
|
|
198
202
|
|
|
203
|
+
/**
|
|
204
|
+
* A `<pre>` as a fenced block, in the language its `<code>` is marked
|
|
205
|
+
* with. A titled block is a `<figure>` with the title as its caption
|
|
206
|
+
* (markdown/index.js), and the title goes back into the fence.
|
|
207
|
+
* @param {any} node
|
|
208
|
+
*/
|
|
209
|
+
function codeBlock(node) {
|
|
210
|
+
const code = (node.children ?? []).find(child => is(child, "code")) ?? node
|
|
211
|
+
const language = classes(code).find(name => name.startsWith("language-"))?.slice("language-".length)
|
|
212
|
+
return { type: "code", lang: language ?? null, meta: null, value: toText(code).replace(/\n$/, "") }
|
|
213
|
+
}
|
|
214
|
+
|
|
199
215
|
/**
|
|
200
216
|
* An alert, `<aside class="alert note">` with its heading first, back in
|
|
201
217
|
* GFM's form: `> [!NOTE]`, then the text on the next line. A heading the
|
|
@@ -19690,7 +19690,7 @@ ${fallback_html}`;
|
|
|
19690
19690
|
delegate(['click']);
|
|
19691
19691
|
|
|
19692
19692
|
const rampSettings = [
|
|
19693
|
-
{ key: "heading-size", variable: "--fontsize-
|
|
19693
|
+
{ key: "heading-size", variable: "--fontsize-scale", label: "Heading size", hint: "How much larger h1 is than body text, as a multiple of it", unit: "", min: 0.25, max: 4, step: 0.05, default: 1.5 },
|
|
19694
19694
|
{ key: "heading-size-decay", variable: "--fontsize-decay", label: "Size decay", hint: "Higher makes subheadings smaller", unit: "", min: 0.5, max: 6, step: 0.1, default: 3 },
|
|
19695
19695
|
{ key: "h1-letter-spacing", variable: "--letterspacing-h1", label: "h1 letter spacing", unit: "ch", min: -0.1, max: 0.1, step: 0.005, default: -0.01 },
|
|
19696
19696
|
{ key: "h6-letter-spacing", variable: "--letterspacing-h6", label: "h6 letter spacing", unit: "ch", min: -0.1, max: 0.1, step: 0.005, default: 0 },
|
|
@@ -19705,7 +19705,6 @@ ${fallback_html}`;
|
|
|
19705
19705
|
function rampValue(setting, value) {
|
|
19706
19706
|
const clamped = Math.min(setting.max, Math.max(setting.min, value));
|
|
19707
19707
|
const number = String(Number(clamped.toFixed(4)));
|
|
19708
|
-
if (setting.unit === "x") return `calc(${number} * var(--fontsize-root))`
|
|
19709
19708
|
return `${number}${setting.unit}`
|
|
19710
19709
|
}
|
|
19711
19710
|
function typographyVariables(theme) {
|
|
@@ -20080,6 +20079,9 @@ ${fallback_html}`;
|
|
|
20080
20079
|
const params = globParams(classes);
|
|
20081
20080
|
return params && globDirective(params)
|
|
20082
20081
|
}
|
|
20082
|
+
if (element.tagName === "A" && element.children.length === 1 && classList(element.firstElementChild).includes("reference")) {
|
|
20083
|
+
return element.getAttribute("href")
|
|
20084
|
+
}
|
|
20083
20085
|
if (element.tagName === "ARTICLE" && classes.includes("reference")) {
|
|
20084
20086
|
const link = element.querySelector("a[href]");
|
|
20085
20087
|
return link && link.getAttribute("href")
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const empty = { path: null, markdown: null, settings: { path: "settings.md", markdown: null }, editor: false };
|
|
5
|
+
let current = null;
|
|
6
|
+
function seed() {
|
|
7
|
+
if (current) return current
|
|
8
|
+
const element = document.getElementById("vowel-source");
|
|
9
|
+
try {
|
|
10
|
+
current = element ? { ...empty, ...JSON.parse(element.textContent) } : { ...empty };
|
|
11
|
+
} catch {
|
|
12
|
+
current = { ...empty };
|
|
13
|
+
}
|
|
14
|
+
return current
|
|
15
|
+
}
|
|
16
|
+
function updateFromTarget(target) {
|
|
17
|
+
const source = seed();
|
|
18
|
+
if (typeof target.source === "string") source.path = target.source;
|
|
19
|
+
if (typeof target.metadata?.markdown === "string") source.markdown = target.metadata.markdown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function openSocket() {
|
|
23
|
+
console.info("Socket opened");
|
|
24
|
+
const socket = new WebSocket(`ws://${window.location.host}`);
|
|
25
|
+
socket.addEventListener('open', () => {
|
|
26
|
+
socket.send('opened');
|
|
27
|
+
});
|
|
28
|
+
socket.addEventListener("close", () => {
|
|
29
|
+
console.info("Socket closed");
|
|
30
|
+
socket.close();
|
|
31
|
+
setTimeout(() => {
|
|
32
|
+
console.info("socket closed refresh");
|
|
33
|
+
location.reload();
|
|
34
|
+
}, 1000);
|
|
35
|
+
});
|
|
36
|
+
function isOpen() {
|
|
37
|
+
return socket.readyState === 1
|
|
38
|
+
}
|
|
39
|
+
socket.addEventListener('message', e => {
|
|
40
|
+
if (!isOpen()) return
|
|
41
|
+
const target = JSON.parse(e.data);
|
|
42
|
+
if (target.extension !== ".html") {
|
|
43
|
+
retryImages("/" + target.path);
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
const regex = new RegExp(window.location.pathname + "(index)?(\\.html)");
|
|
47
|
+
if (!("/" + target.path).match(regex)) return
|
|
48
|
+
updateFromTarget(target);
|
|
49
|
+
if (!target.data) {
|
|
50
|
+
location.reload();
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
const next = new DOMParser().parseFromString(target.data, "text/html");
|
|
54
|
+
patchHead(document.head, next.head);
|
|
55
|
+
patchBody(document.body, next.body);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
function patchHead(head, nextHead) {
|
|
59
|
+
const nextTitle = nextHead.querySelector("title");
|
|
60
|
+
if (nextTitle && document.title !== nextTitle.textContent) document.title = nextTitle.textContent;
|
|
61
|
+
const isTitle = element => element.tagName === "TITLE";
|
|
62
|
+
const remaining = new Map();
|
|
63
|
+
for (const element of head.children) {
|
|
64
|
+
if (isTitle(element)) continue
|
|
65
|
+
const key = element.outerHTML;
|
|
66
|
+
if (!remaining.has(key)) remaining.set(key, []);
|
|
67
|
+
remaining.get(key).push(element);
|
|
68
|
+
}
|
|
69
|
+
const keep = new Set();
|
|
70
|
+
const pending = [];
|
|
71
|
+
const place = (clone, before) => {
|
|
72
|
+
before ? head.insertBefore(clone, before) : head.appendChild(clone);
|
|
73
|
+
keep.add(clone);
|
|
74
|
+
};
|
|
75
|
+
for (const element of nextHead.children) {
|
|
76
|
+
if (isTitle(element)) continue
|
|
77
|
+
const matches = remaining.get(element.outerHTML);
|
|
78
|
+
const existing = matches && matches.shift();
|
|
79
|
+
if (existing) {
|
|
80
|
+
pending.forEach(clone => place(clone, existing));
|
|
81
|
+
pending.length = 0;
|
|
82
|
+
keep.add(existing);
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
pending.push(document.importNode(element, true));
|
|
86
|
+
}
|
|
87
|
+
pending.forEach(clone => place(clone, null));
|
|
88
|
+
for (const element of [...head.children]) {
|
|
89
|
+
if (isTitle(element) || keep.has(element)) continue
|
|
90
|
+
element.remove();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function patchBody(body, nextBody) {
|
|
94
|
+
const editing = document.documentElement.dataset.vowelEditing === "true";
|
|
95
|
+
const keyOf = element => element.id ? `${element.tagName}#${element.id}` : element.tagName;
|
|
96
|
+
const incoming = [...nextBody.children].filter(wasProduced);
|
|
97
|
+
const produced = new Set(incoming.map(keyOf));
|
|
98
|
+
const current = new Map();
|
|
99
|
+
for (const element of body.children) {
|
|
100
|
+
if (!wasProduced(element)) continue
|
|
101
|
+
const key = keyOf(element);
|
|
102
|
+
if (produced.has(key) && !current.has(key)) current.set(key, element);
|
|
103
|
+
}
|
|
104
|
+
const placed = new Set();
|
|
105
|
+
let cursor = null;
|
|
106
|
+
for (const element of incoming) {
|
|
107
|
+
const key = keyOf(element);
|
|
108
|
+
const existing = current.get(key);
|
|
109
|
+
if (existing && editing && existing.tagName === "MAIN") {
|
|
110
|
+
placed.add(existing);
|
|
111
|
+
cursor = existing;
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
const fresh = document.importNode(element, true);
|
|
115
|
+
if (existing) {
|
|
116
|
+
existing.replaceWith(fresh);
|
|
117
|
+
} else if (cursor) {
|
|
118
|
+
cursor.after(fresh);
|
|
119
|
+
} else {
|
|
120
|
+
body.prepend(fresh);
|
|
121
|
+
}
|
|
122
|
+
placed.add(fresh);
|
|
123
|
+
cursor = fresh;
|
|
124
|
+
}
|
|
125
|
+
for (const [, element] of current) {
|
|
126
|
+
if (!placed.has(element) && element.isConnected) element.remove();
|
|
127
|
+
}
|
|
128
|
+
for (const element of [...body.children]) {
|
|
129
|
+
if (!produced.has(keyOf(element)) && wasProduced(element)) element.remove();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function retryImages(url) {
|
|
133
|
+
const failed = [...document.images].filter(img => img.complete && img.naturalWidth === 0);
|
|
134
|
+
const elements = failed.map(img => img.parentElement?.tagName === "PICTURE" ? img.parentElement : img);
|
|
135
|
+
const naming = elements.filter(element => urlsOf(element).includes(url));
|
|
136
|
+
for (const element of naming) element.replaceWith(element.cloneNode(true));
|
|
137
|
+
}
|
|
138
|
+
function urlsOf(element) {
|
|
139
|
+
const nodes = [element, ...element.querySelectorAll("source, img")];
|
|
140
|
+
return nodes.flatMap(node => {
|
|
141
|
+
const candidates = (node.getAttribute("srcset") ?? "").split(",").map(candidate => candidate.trim().split(/\s+/)[0]);
|
|
142
|
+
return [node.getAttribute("src"), ...candidates].filter(Boolean)
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
function wasProduced(element) {
|
|
146
|
+
if (element.tagName === "SCRIPT") return false
|
|
147
|
+
if (element.hasAttribute("data-vowel-client")) return false
|
|
148
|
+
return ["HEADER", "MAIN", "ASIDE", "FOOTER", "NAV"].includes(element.tagName) || Boolean(element.id)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
openSocket();
|
|
152
|
+
|
|
153
|
+
})();
|
|
@@ -62,6 +62,13 @@ function expansionDirective(element) {
|
|
|
62
62
|
return params && globDirective(params)
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// A reference card is `<a href><article class=reference>` as the
|
|
66
|
+
// build writes it (writeRules.js), or the older article around its
|
|
67
|
+
// link.
|
|
68
|
+
if (element.tagName === "A" && element.children.length === 1 && classList(element.firstElementChild).includes("reference")) {
|
|
69
|
+
return element.getAttribute("href")
|
|
70
|
+
}
|
|
71
|
+
|
|
65
72
|
if (element.tagName === "ARTICLE" && classes.includes("reference")) {
|
|
66
73
|
const link = element.querySelector("a[href]")
|
|
67
74
|
return link && link.getAttribute("href")
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import openSocket from "./socket.js"
|
|
2
|
+
|
|
3
|
+
// The live-reload client alone, for every previewed page. The editor's
|
|
4
|
+
// bundle (main.js, which opens the same socket) replaces it only when
|
|
5
|
+
// the dev server was started with `vowel --editor`: without the flag a
|
|
6
|
+
// page carried Svelte, Svedit and the editor, 730 KB inline, to run
|
|
7
|
+
// this one call.
|
|
8
|
+
openSocket()
|
|
@@ -3,14 +3,24 @@ import resolve from "@rollup/plugin-node-resolve"
|
|
|
3
3
|
import css from "rollup-plugin-import-css"
|
|
4
4
|
import cleanup from "rollup-plugin-cleanup"
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// vowel's root after editing anything in
|
|
13
|
-
|
|
6
|
+
// Two inlineable bundles, both committed in ../bundle/:
|
|
7
|
+
// live-reload.js, the live-reload client alone, which every previewed
|
|
8
|
+
// page gets, and index.js, the Svedit editor with the same client in
|
|
9
|
+
// it, which replaces it under `vowel --editor`.
|
|
10
|
+
// vowel/plugins/html/index.js reads them and injects one into each
|
|
11
|
+
// previewed page in handlePreviewRequest. Rebuild with
|
|
12
|
+
// `npm run build:editor` from vowel's root after editing anything in
|
|
13
|
+
// this folder.
|
|
14
|
+
const liveReload = {
|
|
15
|
+
input: "live-reload.js",
|
|
16
|
+
output: {
|
|
17
|
+
file: "../bundle/live-reload.js",
|
|
18
|
+
format: "iife"
|
|
19
|
+
},
|
|
20
|
+
plugins: [cleanup()]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const editor = {
|
|
14
24
|
input: "main.js",
|
|
15
25
|
output: {
|
|
16
26
|
file: "../bundle/index.js",
|
|
@@ -33,3 +43,5 @@ export default {
|
|
|
33
43
|
cleanup()
|
|
34
44
|
]
|
|
35
45
|
}
|
|
46
|
+
|
|
47
|
+
export default [liveReload, editor]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A link to another site opens in a new tab (per Sam, Nov 7) and says
|
|
3
|
+
* it leaves: `rel=external`, the HTML link type for exactly that, and
|
|
4
|
+
* the CSS hook (`a[rel~=external]`). `noopener noreferrer` as link
|
|
5
|
+
* previews already had: the new page can't reach this one through
|
|
6
|
+
* `window.opener`, and isn't told where the visitor came from.
|
|
7
|
+
*
|
|
8
|
+
* Another site is an http(s) url whose host isn't the site's own
|
|
9
|
+
* `domain`, `www.` or not. A path, an anchor, `mailto:` and the like are
|
|
10
|
+
* not links to another site.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const EXTERNAL_REL = ["external", "noopener", "noreferrer"]
|
|
14
|
+
|
|
15
|
+
/** @param {string} host */
|
|
16
|
+
const bareHost = (host) => host.toLowerCase().replace(/^www\./, "")
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {unknown} href
|
|
20
|
+
* @param {unknown} domain - the site's `domain` setting, if any
|
|
21
|
+
*/
|
|
22
|
+
export function isExternalURL(href, domain) {
|
|
23
|
+
if (typeof href !== "string" || !/^(https?:)?\/\//i.test(href)) return false
|
|
24
|
+
const url = URL.parse(href, "https://placeholder.invalid")
|
|
25
|
+
if (!url) return false
|
|
26
|
+
if (typeof domain !== "string" || !domain) return true
|
|
27
|
+
const own = URL.parse(/^[a-z]+:\/\//i.test(domain) ? domain : `https://${domain}`)
|
|
28
|
+
return !own || bareHost(url.hostname) !== bareHost(own.hostname)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* An `<a>`'s properties with the new tab and the rel tokens added, any
|
|
33
|
+
* rel it had kept first (`me`, on a social link).
|
|
34
|
+
* @param {Record<string, any>} properties
|
|
35
|
+
*/
|
|
36
|
+
export function externalProperties(properties) {
|
|
37
|
+
const had = [properties.rel ?? []].flat().flatMap(value => String(value).split(/\s+/)).filter(Boolean)
|
|
38
|
+
const rel = [...new Set([...had, ...EXTERNAL_REL])]
|
|
39
|
+
return { ...properties, target: "_blank", rel }
|
|
40
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A heading's id is `heading_` and its slug: `## Kerning` is
|
|
3
|
+
* `#heading_kerning` (per Sam, Nov 7). Unprefixed, a heading called
|
|
4
|
+
* "Content", "Logo" or "Backlinks" took the id of one of the page's own
|
|
5
|
+
* elements (`section#content`, `img#logo`, `#backlinks`). `_` because
|
|
6
|
+
* what follows is the author's, as in `fm_date`.
|
|
7
|
+
*
|
|
8
|
+
* A link written in a page, `[x](#kerning)` or `./post.md#kerning`,
|
|
9
|
+
* follows: its anchor is a heading's unless it names one of the ids a
|
|
10
|
+
* page has besides its headings, which an author can't add to (raw
|
|
11
|
+
* HTML isn't rendered): Vowel's own, and GFM's footnotes'.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const HEADING_ID_PREFIX = "heading_"
|
|
15
|
+
|
|
16
|
+
const PAGE_IDS = new Set(["content", "backlinks", "logo", "wordmark", "title", "copyright", "shoutout", "footnote-label", "vowel-source"])
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The anchor an author wrote, as the id it means.
|
|
20
|
+
* @param {string} fragment - without the `#`
|
|
21
|
+
*/
|
|
22
|
+
export function headingAnchor(fragment) {
|
|
23
|
+
if (!fragment || PAGE_IDS.has(fragment) || fragment.startsWith("user-content-") || fragment.startsWith(HEADING_ID_PREFIX)) return fragment
|
|
24
|
+
return HEADING_ID_PREFIX + fragment
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A url's `#anchor`, if it has one, as `headingAnchor` reads it.
|
|
29
|
+
* @param {string} suffix - `?query#anchor`, `#anchor`, or ""
|
|
30
|
+
*/
|
|
31
|
+
export function withHeadingAnchor(suffix) {
|
|
32
|
+
const at = suffix.indexOf("#")
|
|
33
|
+
if (at === -1) return suffix
|
|
34
|
+
return suffix.slice(0, at + 1) + headingAnchor(suffix.slice(at + 1))
|
|
35
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { refractor } from "refractor"
|
|
2
|
+
import graphql from "refractor/graphql"
|
|
3
|
+
import jsx from "refractor/jsx"
|
|
4
|
+
import shellSession from "refractor/shell-session"
|
|
5
|
+
import toml from "refractor/toml"
|
|
6
|
+
import tsx from "refractor/tsx"
|
|
7
|
+
import wasm from "refractor/wasm"
|
|
8
|
+
import { extendMarkdown } from "./markdownGrammar.js"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Syntax highlighting for fenced code: Prism's grammars, through
|
|
12
|
+
* refractor, which returns a hast tree. Prism replaced highlight.js
|
|
13
|
+
* (vowel 0.8) because highlight.js's markdown grammar knew neither
|
|
14
|
+
* frontmatter, comments nor GFM, and Vowel's code blocks are mostly
|
|
15
|
+
* Vowel's own markup; Prism is as fast (markdownGrammar.js).
|
|
16
|
+
*
|
|
17
|
+
* refractor's common set is 62 languages. These are added so that every
|
|
18
|
+
* name highlight.js answered to still highlights: the languages it had
|
|
19
|
+
* and refractor's common set doesn't, and the other spellings of the
|
|
20
|
+
* ones it does.
|
|
21
|
+
*/
|
|
22
|
+
refractor.register(graphql)
|
|
23
|
+
refractor.register(jsx)
|
|
24
|
+
refractor.register(shellSession)
|
|
25
|
+
refractor.register(toml)
|
|
26
|
+
refractor.register(tsx)
|
|
27
|
+
refractor.register(wasm)
|
|
28
|
+
|
|
29
|
+
refractor.alias({
|
|
30
|
+
bash: ["zsh"],
|
|
31
|
+
c: ["h"],
|
|
32
|
+
cpp: ["cc", "c++", "h++", "hpp", "hh", "hxx", "cxx"],
|
|
33
|
+
csharp: ["c#"],
|
|
34
|
+
diff: ["patch"],
|
|
35
|
+
go: ["golang"],
|
|
36
|
+
graphql: ["gql"],
|
|
37
|
+
javascript: ["mjs", "cjs"],
|
|
38
|
+
json: ["jsonc", "json5"],
|
|
39
|
+
makefile: ["mk", "mak", "make"],
|
|
40
|
+
markdown: ["mkdown", "mkd"],
|
|
41
|
+
markup: ["xhtml", "xsd", "xsl", "plist"],
|
|
42
|
+
"objectivec": ["mm", "obj-c"],
|
|
43
|
+
perl: ["pl", "pm"],
|
|
44
|
+
python: ["ipython"],
|
|
45
|
+
ruby: ["gemspec", "podspec", "irb"],
|
|
46
|
+
rust: ["rs"],
|
|
47
|
+
"shell-session": ["console"],
|
|
48
|
+
typescript: ["mts", "cts"],
|
|
49
|
+
vbnet: ["vb"]
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
extendMarkdown(refractor)
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `code` highlighted as `language`, as hast, or undefined for a
|
|
56
|
+
* language Prism doesn't know. Matched case-insensitively, as a fence's
|
|
57
|
+
* language was by highlight.js.
|
|
58
|
+
* @param {string} code
|
|
59
|
+
* @param {string} language
|
|
60
|
+
* @returns {import("hast").Root | undefined}
|
|
61
|
+
*/
|
|
62
|
+
export function highlightCode(code, language) {
|
|
63
|
+
const name = language.toLowerCase()
|
|
64
|
+
if (!refractor.registered(name)) return undefined
|
|
65
|
+
return refractor.highlight(code, name)
|
|
66
|
+
}
|
package/plugins/html/index.js
CHANGED
|
@@ -26,7 +26,10 @@ import { pageRecordURI } from "../atproto/record.js"
|
|
|
26
26
|
import { STATUSES } from "../markdown/frontmatter.js"
|
|
27
27
|
import { themeStylesheets, isVowelStylesheet } from "../styles/theme.js"
|
|
28
28
|
import { displayPath } from "../../secretPaths.js"
|
|
29
|
+
import { VOWEL_VERSION } from "../../version.js"
|
|
29
30
|
import { socialLinksNav } from "./socialLinks.js"
|
|
31
|
+
import { isExternalURL, externalProperties } from "./externalLinks.js"
|
|
32
|
+
import { HEADING_ID_PREFIX } from "./headingIds.js"
|
|
30
33
|
import { writeWalk } from "./writeRules.js"
|
|
31
34
|
import { ASIDE_PARTIAL, navPartialPath, backlinksPartialPath, createPartialStubs, expandPartial, withCurrent, partialsProcessor } from "./partials.js"
|
|
32
35
|
|
|
@@ -35,10 +38,13 @@ import { ASIDE_PARTIAL, navPartialPath, backlinksPartialPath, createPartialStubs
|
|
|
35
38
|
|
|
36
39
|
const VOWEL_DIR = path.normalize(path.join(import.meta.dirname, "../../"))
|
|
37
40
|
|
|
38
|
-
// Compiled from ./editor
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
41
|
+
// Compiled from ./editor via rollup.config.js (`npm run build:editor`
|
|
42
|
+
// from vowel's root), read once here and inlined into previewed pages:
|
|
43
|
+
// the live-reload client alone (live-reload.js, 6 KB) on every one, or
|
|
44
|
+
// under `vowel --editor` the Svedit editor, which carries the same
|
|
45
|
+
// client (main.js, 730 KB). See tasks/desktop-app-architecture.md,
|
|
46
|
+
// Part 3.
|
|
47
|
+
const liveReloadScript = readFileSync(path.join(import.meta.dirname, "bundle/live-reload.js"), "utf-8")
|
|
42
48
|
const editorClientScript = readFileSync(path.join(import.meta.dirname, "bundle/index.js"), "utf-8")
|
|
43
49
|
|
|
44
50
|
/**
|
|
@@ -501,6 +507,11 @@ function writeFile(target, { settings, api, config }) {
|
|
|
501
507
|
name: "viewport",
|
|
502
508
|
content: "width=device-width, initial-scale=1.0"
|
|
503
509
|
}),
|
|
510
|
+
// What made the page, as WordPress and Hugo say it: name and version.
|
|
511
|
+
h("meta", {
|
|
512
|
+
name: "generator",
|
|
513
|
+
content: `Vowel ${VOWEL_VERSION}`
|
|
514
|
+
}),
|
|
504
515
|
h("meta", {
|
|
505
516
|
"http-equiv": "X-UA-Compatible",
|
|
506
517
|
content: "ie-edge"
|
|
@@ -680,8 +691,10 @@ function writeFile(target, { settings, api, config }) {
|
|
|
680
691
|
])
|
|
681
692
|
|
|
682
693
|
|
|
694
|
+
// Every heading's id is prefixed (headingIds.js), so none can be the
|
|
695
|
+
// page's own: a heading called "Content" was section#content's.
|
|
683
696
|
const slugger = unified()
|
|
684
|
-
.use(slug)
|
|
697
|
+
.use(slug, { prefix: HEADING_ID_PREFIX })
|
|
685
698
|
.use(toc, {
|
|
686
699
|
// No class per level, item and link: the nesting is the <ol>
|
|
687
700
|
// structure, and `nav[aria-label=Contents]` is the hook. An empty
|
|
@@ -750,7 +763,7 @@ function writeFile(target, { settings, api, config }) {
|
|
|
750
763
|
// The aside tree is one partial, the same on every page. Without a
|
|
751
764
|
// nav worth rendering there is no aside at all.
|
|
752
765
|
const asidePartial = api.target(ASIDE_PARTIAL)
|
|
753
|
-
const asideNav = navWithChoices(asidePartial ? asidePartial.metadata.hast : null)
|
|
766
|
+
const asideNav = navWithChoices(asidePartial ? withCurrent(asidePartial.metadata.hast, metadata.prettyURL) : null)
|
|
754
767
|
const treeAside = asideNav ? h("aside", asideNav) : null
|
|
755
768
|
|
|
756
769
|
const treeFooter = h('footer', [
|
|
@@ -758,9 +771,9 @@ function writeFile(target, { settings, api, config }) {
|
|
|
758
771
|
h('section#copyright', `© ${new Date().getFullYear()}`),
|
|
759
772
|
h('section#shoutout', [
|
|
760
773
|
"Made with ",
|
|
761
|
-
h('a',
|
|
762
|
-
href: "https://vowel.cc"
|
|
763
|
-
|
|
774
|
+
h('a', isExternalURL("https://vowel.cc", settings.lastNonNull("fm_domain"))
|
|
775
|
+
? externalProperties({ href: "https://vowel.cc" })
|
|
776
|
+
: { href: "https://vowel.cc" }, "Vowel"),
|
|
764
777
|
])
|
|
765
778
|
])
|
|
766
779
|
|
|
@@ -829,7 +842,7 @@ function makeTable(classes, properties, targets, api) {
|
|
|
829
842
|
|
|
830
843
|
// In a <figure>, like a markdown table (markdown/index.js). The
|
|
831
844
|
// directive's parameters stay on the <table>, where the editor reads them.
|
|
832
|
-
return h("figure", h("table", { class: classes }, [
|
|
845
|
+
return h("figure.table", h("table", { class: classes }, [
|
|
833
846
|
h("thead", h("tr", properties.map(name => h("th", name)))),
|
|
834
847
|
h("tbody", targets.map(target => h("tr", properties.map(name => cell(target, name)))))
|
|
835
848
|
]))
|
|
@@ -859,10 +872,9 @@ function previewSource(target, settings, config) {
|
|
|
859
872
|
path: target?.source ?? null,
|
|
860
873
|
markdown: target?.metadata?.markdown ?? null,
|
|
861
874
|
settings: { path: "settings.md", markdown: rootSettings?.at(-1) ?? null },
|
|
862
|
-
// The in-page editor is not part of 1.0:
|
|
863
|
-
// dev server was started with
|
|
864
|
-
//
|
|
865
|
-
// the live-reload client.
|
|
875
|
+
// The in-page editor is not part of 1.0: its bundle, and this, are
|
|
876
|
+
// in the page only when the dev server was started with
|
|
877
|
+
// `vowel --editor` (config.editor).
|
|
866
878
|
editor: config?.editor === true
|
|
867
879
|
}
|
|
868
880
|
}
|
|
@@ -870,6 +882,10 @@ function previewSource(target, settings, config) {
|
|
|
870
882
|
function handlePreviewRequest(body, { target, settings, config } = {}) {
|
|
871
883
|
const html = body.toString("utf-8")
|
|
872
884
|
const fileSplit = html.split("</body>")
|
|
885
|
+
if (config?.editor !== true) {
|
|
886
|
+
fileSplit.splice(1, 0, `<script type="module">${liveReloadScript}</script>`)
|
|
887
|
+
return fileSplit.join("")
|
|
888
|
+
}
|
|
873
889
|
// "<" escaped so a "</script>" inside the markdown cannot end the tag.
|
|
874
890
|
const source = JSON.stringify(previewSource(target, settings, config)).replaceAll("<", "\\u003c")
|
|
875
891
|
fileSplit.splice(1, 0, `<script type="application/json" id="vowel-source">${source}</script><script type="module">${editorClientScript}</script>`)
|