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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prism's markdown grammar, made to read what Vowel reads: GitHub
|
|
3
|
+
* Flavored Markdown, then Vowel's own syntax on top (per Sam, Nov 6:
|
|
4
|
+
* Vowel is a GFM superset, so its code blocks highlight as GFM).
|
|
5
|
+
*
|
|
6
|
+
* Prism's grammar is close. These change what it gets wrong, measured
|
|
7
|
+
* against micromark, which Vowel parses pages with:
|
|
8
|
+
*
|
|
9
|
+
* - frontmatter only on the first line (Prism allowed blank lines
|
|
10
|
+
* before it);
|
|
11
|
+
* - fences of `~~~` as well as ```, and longer fences (a `~~~` fence was
|
|
12
|
+
* strikethrough, and a ```` fence ended at the first ``` inside it);
|
|
13
|
+
* - table delimiter cells of one dash (Prism wanted three);
|
|
14
|
+
* - task list items, footnotes and autolinks, which it didn't know.
|
|
15
|
+
*
|
|
16
|
+
* And these add Vowel's syntax, with the patterns `readRules.js` reads
|
|
17
|
+
* it with: alerts, wikilinks, hashtags, `:shortcodes:`, `==highlights==`
|
|
18
|
+
* and listing lines (`/blog/*`).
|
|
19
|
+
*
|
|
20
|
+
* Prism grammars are objects that are changed in place: `insertBefore`
|
|
21
|
+
* is how a rule is added, and the order of rules is the order they are
|
|
22
|
+
* tried in. A token that is cut out of a line hides it from the rules
|
|
23
|
+
* after it: that is why a task item takes its list marker with it.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Prism's table patterns with one dash or more per delimiter cell.
|
|
28
|
+
* @param {RegExp} pattern
|
|
29
|
+
*/
|
|
30
|
+
const oneDash = (pattern) => new RegExp(pattern.source.replaceAll("-{3,}", "-+"), pattern.flags)
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Extends the markdown grammar of this refractor, once.
|
|
34
|
+
* @param {import("refractor/core").Refractor} refractor - with markdown registered
|
|
35
|
+
*/
|
|
36
|
+
export function extendMarkdown(refractor) {
|
|
37
|
+
const languages = refractor.languages
|
|
38
|
+
const markdown = languages.markdown
|
|
39
|
+
|
|
40
|
+
markdown["front-matter-block"].pattern = /^---(?!.)[\s\S]*?[\r\n]---(?!.)/
|
|
41
|
+
markdown["front-matter-block"].lookbehind = false
|
|
42
|
+
|
|
43
|
+
// Three or more backticks or tildes, closed by the same character at
|
|
44
|
+
// least as many times. Prism's after-tokenize hook reads the language
|
|
45
|
+
// from the parts and highlights the block in it, and it finds them by
|
|
46
|
+
// position: the language second, the block fourth. So the language is
|
|
47
|
+
// the first word only, never a quoted title (codeFence.js), and the
|
|
48
|
+
// rest of the line, a title and its newline, is one token in third
|
|
49
|
+
// place, where the newline would be: fence, language, meta, block,
|
|
50
|
+
// newline, fence.
|
|
51
|
+
markdown.code[1] = {
|
|
52
|
+
pattern: /^(([`~])\2{2,})[^\r\n]*(?:\r\n?|\n)(?:[\s\S]*?(?:\r\n?|\n))??\1\2*[ \t]*$/m,
|
|
53
|
+
greedy: true,
|
|
54
|
+
inside: {
|
|
55
|
+
"code-block": {
|
|
56
|
+
pattern: /^((?:`{3,}|~{3,})[^\r\n]*(?:\r\n?|\n))[\s\S]+(?=(?:\r\n?|\n)(?:`{3,}|~{3,})[ \t]*$)/,
|
|
57
|
+
lookbehind: true
|
|
58
|
+
},
|
|
59
|
+
"code-language": {
|
|
60
|
+
pattern: /^(`{3,}|~{3,})[^\s`~"'][^\s]*/,
|
|
61
|
+
lookbehind: true
|
|
62
|
+
},
|
|
63
|
+
punctuation: /`{3,}|~{3,}/,
|
|
64
|
+
"code-meta": {
|
|
65
|
+
pattern: /^[ \t]*[^\s][^\r\n]*(?:\r\n?|\n)$/,
|
|
66
|
+
inside: { string: /"[^"]*"|'[^']*'/ }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const table = markdown.table
|
|
72
|
+
const rows = table.inside["table-data-rows"]
|
|
73
|
+
const line = table.inside["table-line"]
|
|
74
|
+
table.pattern = oneDash(table.pattern)
|
|
75
|
+
rows.pattern = oneDash(rows.pattern)
|
|
76
|
+
line.pattern = oneDash(line.pattern)
|
|
77
|
+
line.inside.punctuation = oneDash(line.inside.punctuation)
|
|
78
|
+
|
|
79
|
+
languages.insertBefore("markdown", "blockquote", {
|
|
80
|
+
// `> [!NOTE]`, any word, at the start of a quote.
|
|
81
|
+
alert: {
|
|
82
|
+
pattern: /(^>[ \t]*)\[![A-Za-z]+\]/m,
|
|
83
|
+
lookbehind: true,
|
|
84
|
+
alias: "important"
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
languages.insertBefore("markdown", "list", {
|
|
89
|
+
// `- [x]` and `1. [ ]`.
|
|
90
|
+
task: {
|
|
91
|
+
pattern: /^[ \t]*(?:[*+-]|\d+[.)])[ \t]+\[[ xX]\](?=[ \t])/m,
|
|
92
|
+
inside: {
|
|
93
|
+
list: { pattern: /^[ \t]*(?:[*+-]|\d+[.)])/, alias: "punctuation" },
|
|
94
|
+
checkbox: { pattern: /\[[ xX]\]/, alias: "keyword" }
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
// A paragraph that is only a path: a listing (`/blog/*`, `./drafts/**`)
|
|
98
|
+
// or a reference to a page (`/blog/post`).
|
|
99
|
+
directive: {
|
|
100
|
+
pattern: /(^|\n[ \t]*\n)(?:\/|\.\.?\/)\S*(?=[ \t]*(?:\n[ \t]*\n|\n?$))/,
|
|
101
|
+
lookbehind: true,
|
|
102
|
+
alias: "function"
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
languages.insertBefore("markdown", "url-reference", {
|
|
107
|
+
// `[^1]: The source.`, which Prism read as a link definition with
|
|
108
|
+
// the first word as its url.
|
|
109
|
+
"footnote-definition": {
|
|
110
|
+
pattern: /^[ \t]*\[\^[^\]\s]+\]:/m,
|
|
111
|
+
alias: "url"
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
languages.insertBefore("markdown", "url", {
|
|
116
|
+
// `[[Name]]`, `[[Name#Section]]`, `[[Name|words]]` and `![[Name]]`.
|
|
117
|
+
wikilink: {
|
|
118
|
+
pattern: /!?\[\[[^\]|#\r\n]+(?:#[^\]|\r\n]+)?(?:\|[^\]\r\n]+)?\]\]/,
|
|
119
|
+
greedy: true,
|
|
120
|
+
alias: "url",
|
|
121
|
+
inside: { punctuation: /^!?\[\[|\]\]$|[|#]/ }
|
|
122
|
+
},
|
|
123
|
+
// `[^1]`
|
|
124
|
+
footnote: {
|
|
125
|
+
pattern: /\[\^[^\]\s]+\]/,
|
|
126
|
+
alias: "url"
|
|
127
|
+
},
|
|
128
|
+
// `<https://vowel.cc>`, and a bare `https://vowel.cc` without the
|
|
129
|
+
// punctuation that ends a sentence.
|
|
130
|
+
autolink: {
|
|
131
|
+
pattern: /<(?:https?|mailto):[^\s>]+>|\bhttps?:\/\/[^\s<]*[^\s<.,:;"')\]]/,
|
|
132
|
+
alias: "url"
|
|
133
|
+
},
|
|
134
|
+
// `#tag` after the start of a line or a space: never a heading's
|
|
135
|
+
// `# `, a salt's `##` or the `#` in `a#b`.
|
|
136
|
+
hashtag: {
|
|
137
|
+
pattern: /(^|[ \t])#[\w\-/]+\b/m,
|
|
138
|
+
lookbehind: true,
|
|
139
|
+
alias: "tag"
|
|
140
|
+
},
|
|
141
|
+
// `:name:` and `:folder/name:`, an emoji or an icon.
|
|
142
|
+
shortcode: {
|
|
143
|
+
pattern: /:[a-z0-9_+-]+(?:\/[a-z0-9_+-]+)?:/,
|
|
144
|
+
alias: "symbol"
|
|
145
|
+
},
|
|
146
|
+
// `==highlighted==`
|
|
147
|
+
mark: {
|
|
148
|
+
pattern: /==(?=\S)[^=\r\n]*\S==/,
|
|
149
|
+
alias: "important"
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
}
|
package/plugins/html/partials.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
2
|
import { h } from "hastscript"
|
|
3
|
-
import { isListedPage } from "../../utils.js"
|
|
3
|
+
import { isListedPage, pageClasses } from "../../utils.js"
|
|
4
4
|
import { isVowelStylesheet } from "../styles/theme.js"
|
|
5
5
|
|
|
6
6
|
/** The one stylesheet name linked without being asked for. */
|
|
@@ -96,8 +96,26 @@ function chain(folder) {
|
|
|
96
96
|
return ["", ...segments.map((_, i) => segments.slice(0, i + 1).join(path.sep))]
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
/**
|
|
100
|
-
|
|
99
|
+
/**
|
|
100
|
+
* The class a nav's `<li>` carries (per Sam, Nov 7): the page class of
|
|
101
|
+
* the page it links to, the same as that page's `<body>` and cards
|
|
102
|
+
* (`page_blog_hello-world`).
|
|
103
|
+
* @param {any} page
|
|
104
|
+
*/
|
|
105
|
+
const pageClass = (page) => pageClasses(page.metadata.prettyURL, page.source).at(-1)
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The class a nav's `<ul>` carries: the folder its pages are in, as
|
|
109
|
+
* their own classes name it (`folder_blog`), or null for the root's.
|
|
110
|
+
* @param {any[]} pages - one folder's
|
|
111
|
+
*/
|
|
112
|
+
const folderClass = (pages) => {
|
|
113
|
+
const classes = pages.length ? pageClasses(pages[0].metadata.prettyURL, pages[0].source) : []
|
|
114
|
+
return classes.length > 1 ? classes.at(-2) : null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A nav entry: where it goes, what it says, and its page's class. */
|
|
118
|
+
const item = (page) => ({ url: page.metadata.prettyURL, label: page.metadata.breadcrumb, page: pageClass(page) })
|
|
101
119
|
|
|
102
120
|
/**
|
|
103
121
|
* The aside tree: top-level pages with no date, each with the pages of
|
|
@@ -118,16 +136,14 @@ function asideTree(pages) {
|
|
|
118
136
|
byFolder.get(url).push(page)
|
|
119
137
|
}
|
|
120
138
|
// A folder with too many pages for a menu shows its own entry only (asMenu).
|
|
121
|
-
const children = (parent) =>
|
|
122
|
-
const pages = byFolder.get(parent.metadata.prettyURL) ?? []
|
|
123
|
-
return asMenu(pages).map(node)
|
|
124
|
-
}
|
|
139
|
+
const children = (parent) => asMenu(byFolder.get(parent.metadata.prettyURL) ?? [])
|
|
125
140
|
|
|
126
141
|
function node(page) {
|
|
127
142
|
const below = children(page)
|
|
128
143
|
// The aside links the file, not the pretty url - kept as it was
|
|
129
144
|
// (see the note in the commit).
|
|
130
|
-
|
|
145
|
+
const entry = { url: "/" + page.path, label: page.metadata.breadcrumb, page: pageClass(page) }
|
|
146
|
+
return below.length ? { ...entry, children: below.map(node), folder: folderClass(below) } : entry
|
|
131
147
|
}
|
|
132
148
|
|
|
133
149
|
const top = undated
|
|
@@ -241,7 +257,8 @@ function createPartialStubs(api) {
|
|
|
241
257
|
// it, a folder with too many pages shows none of them (asMenu).
|
|
242
258
|
levels: chain(folder).map(level => {
|
|
243
259
|
const list = lists.get(level) ?? []
|
|
244
|
-
|
|
260
|
+
const shown = level ? asMenu(list) : list
|
|
261
|
+
return { folder: folderClass(shown), items: shown.map(item) }
|
|
245
262
|
}),
|
|
246
263
|
sheets: chain(folder).flatMap(level => sheets.get(level) ?? [])
|
|
247
264
|
}
|
|
@@ -255,20 +272,30 @@ function expandPartial({ params }) {
|
|
|
255
272
|
return { text: JSON.stringify(params) }
|
|
256
273
|
}
|
|
257
274
|
|
|
258
|
-
/**
|
|
275
|
+
/**
|
|
276
|
+
* The header nav: one <ul> per level that has entries, root first.
|
|
277
|
+
* Labelled "Local" and the aside "Global", after the settings that
|
|
278
|
+
* leave a page out of each (`local_nav_item`, `global_nav_item`), so a
|
|
279
|
+
* screen reader can tell the page's navs apart. The labels are CSS
|
|
280
|
+
* hooks too, like `nav[aria-label=Breadcrumbs]`: renaming one breaks
|
|
281
|
+
* an author's stylesheet.
|
|
282
|
+
*/
|
|
259
283
|
function renderNav({ levels }) {
|
|
260
|
-
return h("nav", levels
|
|
261
|
-
.filter(level => level.length)
|
|
262
|
-
.map(level => h("ul", level.map(({ url, label }) => h("li", h("a", { href: url }, label)))))
|
|
284
|
+
return h("nav", { "aria-label": "Local" }, levels
|
|
285
|
+
.filter(level => level.items.length)
|
|
286
|
+
.map(level => h("ul", classed(level.folder), level.items.map(({ url, label, page }) => h("li", classed(page), h("a", { href: url }, label)))))
|
|
263
287
|
)
|
|
264
288
|
}
|
|
265
289
|
|
|
290
|
+
/** One class, or none. @param {string | null | undefined} name */
|
|
291
|
+
const classed = (name) => name ? { className: [name] } : {}
|
|
292
|
+
|
|
266
293
|
/** The aside tree. */
|
|
267
294
|
function renderAside({ items }) {
|
|
268
|
-
const li = ({ url, label, children }) => children
|
|
269
|
-
? h("li", [h("a", { href: url }, label), h("ul", children.map(li))])
|
|
270
|
-
: h("li", h("a", { href: url }, label))
|
|
271
|
-
return h("nav", h("ul", items.map(li)))
|
|
295
|
+
const li = ({ url, label, page, children, folder }) => children
|
|
296
|
+
? h("li", classed(page), [h("a", { href: url }, label), h("ul", classed(folder), children.map(li))])
|
|
297
|
+
: h("li", classed(page), h("a", { href: url }, label))
|
|
298
|
+
return h("nav", { "aria-label": "Global" }, h("ul", items.map(li)))
|
|
272
299
|
}
|
|
273
300
|
|
|
274
301
|
/** "Linked from", outside section#content: it is generated, and the editor must not see it. */
|
|
@@ -294,16 +321,39 @@ function readPartial(source) {
|
|
|
294
321
|
}
|
|
295
322
|
|
|
296
323
|
/**
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
324
|
+
* A nav link's href as the page url it names: the aside links files
|
|
325
|
+
* (`/blog.html`, and `/index.html` for the home page), the header nav
|
|
326
|
+
* pretty urls.
|
|
327
|
+
* @param {string} href
|
|
328
|
+
*/
|
|
329
|
+
const linkedURL = (href) => href === "/index.html" ? "/" : href.replace(/\.html$/, "")
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The urls of the folders above a page, not the root: `/blog` and
|
|
333
|
+
* `/blog/2020` for `/blog/2020/post`. Each is its folder's index page.
|
|
334
|
+
* @param {string} url
|
|
335
|
+
*/
|
|
336
|
+
function ancestorURLs(url) {
|
|
337
|
+
const segments = url.split("/").filter(Boolean)
|
|
338
|
+
return segments.slice(0, -1).map((segment, index) => "/" + segments.slice(0, index + 1).join("/"))
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The page's copy of a nav, with the current page marked
|
|
343
|
+
* `aria-current=page` and the folders above it `aria-current=true`
|
|
344
|
+
* (per Sam, Nov 7), the home page aside, since every page is below it.
|
|
345
|
+
* A copy: the tracked read hands back a fresh parse, but a rule that
|
|
346
|
+
* relies on that would be fragile, and the walk mutates.
|
|
300
347
|
* @param {any} hast
|
|
301
348
|
* @param {string} url - the page's prettyURL
|
|
302
349
|
*/
|
|
303
350
|
function withCurrent(hast, url) {
|
|
304
351
|
const copy = structuredClone(hast)
|
|
352
|
+
const ancestors = new Set(ancestorURLs(url))
|
|
305
353
|
const walk = (node) => {
|
|
306
|
-
|
|
354
|
+
const href = node.type === "element" && node.tagName === "a" && typeof node.properties?.href === "string" ? linkedURL(node.properties.href) : null
|
|
355
|
+
if (href === url) node.properties.ariaCurrent = "page"
|
|
356
|
+
else if (href !== null && ancestors.has(href)) node.properties.ariaCurrent = "true"
|
|
307
357
|
node.children?.forEach(walk)
|
|
308
358
|
}
|
|
309
359
|
walk(copy)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { h } from "hastscript"
|
|
2
2
|
import { imageSizes } from "../../utils.js"
|
|
3
3
|
import { imageURL } from "./image.js"
|
|
4
|
+
import { isExternalURL, externalProperties } from "./externalLinks.js"
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Social links, from the `social_links` setting:
|
|
@@ -52,6 +53,7 @@ function socialLinks(settings) {
|
|
|
52
53
|
function socialLinksNav(settings, api) {
|
|
53
54
|
const links = socialLinks(settings)
|
|
54
55
|
if (!links.length) return null
|
|
56
|
+
const domain = settings?.lastNonNull?.("fm_domain")
|
|
55
57
|
|
|
56
58
|
return h("nav.social-links", { "aria-label": "Social links" },
|
|
57
59
|
h("ul", links.map(({ label, url, icon }) => {
|
|
@@ -61,7 +63,8 @@ function socialLinksNav(settings, api) {
|
|
|
61
63
|
// published (image.js).
|
|
62
64
|
const iconURL = icon ? imageURL(String(icon), api, imageSizes[0]) : null
|
|
63
65
|
const image = iconURL && h("img", { src: iconURL, alt: text, style: `--url-icon: url("${iconURL}")` })
|
|
64
|
-
|
|
66
|
+
const properties = { href: url, rel: "me" }
|
|
67
|
+
return h("li", h("a", isExternalURL(url, domain) ? externalProperties(properties) : properties, image || text))
|
|
65
68
|
}))
|
|
66
69
|
)
|
|
67
70
|
}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
2
|
import { h } from "hastscript"
|
|
3
|
-
import { common, createLowlight } from "lowlight"
|
|
4
3
|
import { toText } from "hast-util-to-text"
|
|
5
4
|
import GithubSlugger from "github-slugger"
|
|
6
5
|
import { createVisitor, SKIP } from "../markdown/visitor.js"
|
|
7
6
|
import { showPages, isPageWithoutFile, imageSizes, sourceLocation } from "../../utils.js"
|
|
8
7
|
import { globClasses, alertLabel } from "./editor/directives.js"
|
|
9
8
|
import createDynamicImage, { derivativeURL } from "./image.js"
|
|
9
|
+
import { highlightCode } from "./highlighting.js"
|
|
10
10
|
import { displayPath } from "../../secretPaths.js"
|
|
11
|
+
import { isExternalURL, externalProperties } from "./externalLinks.js"
|
|
12
|
+
import { HEADING_ID_PREFIX, headingAnchor, withHeadingAnchor } from "./headingIds.js"
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* The page's source, at the node a warning is about (utils.js). Nodes
|
|
@@ -31,7 +33,6 @@ const at = (node, target) => sourceLocation(target.source ?? target.path, node?.
|
|
|
31
33
|
* write can link the highlight stylesheet only on pages that need it.
|
|
32
34
|
*/
|
|
33
35
|
|
|
34
|
-
const lowlight = createLowlight(common)
|
|
35
36
|
|
|
36
37
|
/** @param {any} node */
|
|
37
38
|
const isElement = (node) => node?.type === "element"
|
|
@@ -149,8 +150,39 @@ const internalLink = {
|
|
|
149
150
|
return
|
|
150
151
|
}
|
|
151
152
|
// A link to a png or jpeg opens its largest derivative: the
|
|
152
|
-
// original is not published (image.js).
|
|
153
|
-
|
|
153
|
+
// original is not published (image.js). A page's anchor is a
|
|
154
|
+
// heading's (headingIds.js); a file's, like a pdf's `#page=2`, isn't.
|
|
155
|
+
const anchored = found.extension === ".html" ? withHeadingAnchor(suffix) : suffix
|
|
156
|
+
node.properties.href = (derivativeURL(found, imageSizes.at(-1)) ?? urlOf(found)) + anchored
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* `[x](#kerning)`, an anchor on this page: a heading's, so `#heading_kerning`
|
|
162
|
+
* (headingIds.js). In place, so the walk goes on into its text.
|
|
163
|
+
* @type {import("../markdown/visitor.js").BlockRule}
|
|
164
|
+
*/
|
|
165
|
+
const anchorLink = {
|
|
166
|
+
name: "anchorLink",
|
|
167
|
+
test: (node) => isElement(node) && node.tagName === "a" && typeof node.properties?.href === "string" && node.properties.href.startsWith("#"),
|
|
168
|
+
transform: (node) => {
|
|
169
|
+
node.properties.href = "#" + headingAnchor(node.properties.href.slice(1))
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A link to another site opens in a new tab (externalLinks.js). Any
|
|
175
|
+
* absolute link matches; one to the site's own domain is left as it
|
|
176
|
+
* is, which a test can't tell, since only the transform has the
|
|
177
|
+
* settings. In place, so the walk goes on into its text.
|
|
178
|
+
* @type {import("../markdown/visitor.js").BlockRule}
|
|
179
|
+
*/
|
|
180
|
+
const externalLink = {
|
|
181
|
+
name: "externalLink",
|
|
182
|
+
test: (node) => isElement(node) && node.tagName === "a" && /^(https?:)?\/\//i.test(node.properties?.href ?? ""),
|
|
183
|
+
transform: (node, index, parent, { settings }) => {
|
|
184
|
+
if (!isExternalURL(node.properties.href, settings?.lastNonNull?.("fm_domain"))) return
|
|
185
|
+
node.properties = externalProperties(node.properties)
|
|
154
186
|
}
|
|
155
187
|
}
|
|
156
188
|
|
|
@@ -248,7 +280,7 @@ const wikilink = {
|
|
|
248
280
|
return SKIP
|
|
249
281
|
}
|
|
250
282
|
const url = found.metadata?.prettyURL ?? `/${found.path}`
|
|
251
|
-
const anchor = section ? `#${new GithubSlugger().slug(section)}` : ""
|
|
283
|
+
const anchor = section ? `#${HEADING_ID_PREFIX}${new GithubSlugger().slug(section)}` : ""
|
|
252
284
|
node.properties.href = url + anchor
|
|
253
285
|
return SKIP
|
|
254
286
|
}
|
|
@@ -276,9 +308,10 @@ const icon = {
|
|
|
276
308
|
}
|
|
277
309
|
|
|
278
310
|
/**
|
|
279
|
-
* A fenced block with a language
|
|
280
|
-
*
|
|
281
|
-
*
|
|
311
|
+
* A fenced block with a language, highlighted by Prism
|
|
312
|
+
* (highlighting.js): per-token spans in the <code>, and the language
|
|
313
|
+
* class on the <pre> as well as the <code>, where Prism's themes look
|
|
314
|
+
* for it. A language Prism doesn't know is left alone.
|
|
282
315
|
* @type {import("../markdown/visitor.js").BlockRule}
|
|
283
316
|
*/
|
|
284
317
|
const highlight = {
|
|
@@ -288,14 +321,10 @@ const highlight = {
|
|
|
288
321
|
const code = node.children[0]
|
|
289
322
|
const language = (code.properties?.className ?? []).map(String).find(name => name.startsWith("language-"))?.slice("language-".length)
|
|
290
323
|
if (!language) return SKIP
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (/Unknown language/.test(error.message)) return SKIP
|
|
296
|
-
throw error
|
|
297
|
-
}
|
|
298
|
-
code.properties.className = ["hljs", ...code.properties.className.filter(name => name !== "hljs")]
|
|
324
|
+
const result = highlightCode(toText(code, { whitespace: "pre" }), language)
|
|
325
|
+
if (!result) return SKIP
|
|
326
|
+
const preClasses = (node.properties?.className ?? []).map(String).filter(name => !name.startsWith("language-"))
|
|
327
|
+
node.properties = { ...node.properties, className: [...preClasses, `language-${language}`] }
|
|
299
328
|
if (result.children.length) code.children = result.children
|
|
300
329
|
context.highlighted = true
|
|
301
330
|
return SKIP
|
|
@@ -327,6 +356,7 @@ const captionedImage = {
|
|
|
327
356
|
transform: (node) => {
|
|
328
357
|
const [img, ...caption] = node.children
|
|
329
358
|
node.tagName = "figure"
|
|
359
|
+
node.properties = { ...node.properties, className: [...(node.properties?.className ?? []), "image"] }
|
|
330
360
|
node.children = [img, h("figcaption", caption)]
|
|
331
361
|
}
|
|
332
362
|
}
|
|
@@ -423,7 +453,7 @@ const directive = {
|
|
|
423
453
|
// Lowercased: every vowel target path is (config.js's router).
|
|
424
454
|
const found = api.target(path.relative("/", path.format(info)).toLowerCase())
|
|
425
455
|
if (!found) return SKIP
|
|
426
|
-
return makeHeader(found.metadata, cardLink(found), api, config, found.source)
|
|
456
|
+
return asReference(makeHeader(found.metadata, cardLink(found), api, config, found.source))
|
|
427
457
|
}
|
|
428
458
|
|
|
429
459
|
const folder = path.relative("/", dir)
|
|
@@ -459,6 +489,19 @@ const directive = {
|
|
|
459
489
|
*/
|
|
460
490
|
const cardLink = (found) => isPageWithoutFile(found) ? null : found.metadata.prettyURL
|
|
461
491
|
|
|
492
|
+
/**
|
|
493
|
+
* A reference card's <article> marked `reference`, which a listing's
|
|
494
|
+
* cards aren't: `<a><article class=reference>`, or the article alone for
|
|
495
|
+
* a page with no file. The editor's ingest reads it to put `/blog/post`
|
|
496
|
+
* back.
|
|
497
|
+
* @param {any} card - makeHeader's result
|
|
498
|
+
*/
|
|
499
|
+
function asReference(card) {
|
|
500
|
+
const article = card.tagName === "article" ? card : card.children[0]
|
|
501
|
+
article.properties.className = [...(article.properties.className ?? []), "reference"]
|
|
502
|
+
return card
|
|
503
|
+
}
|
|
504
|
+
|
|
462
505
|
/**
|
|
463
506
|
* A relative directive path as the absolute published path it names.
|
|
464
507
|
* The glob's last segment (`*`, `**`) rides along untouched: the
|
|
@@ -573,7 +616,7 @@ function trimText(nodes) {
|
|
|
573
616
|
const skipped = new Set(["pre", "code", "script", "style"])
|
|
574
617
|
|
|
575
618
|
const writeWalk = createVisitor({
|
|
576
|
-
block: [internalLink, wikilink, icon, highlight, captionedImage, image, directive, preview, alert],
|
|
619
|
+
block: [internalLink, anchorLink, externalLink, wikilink, icon, highlight, captionedImage, image, directive, preview, alert],
|
|
577
620
|
skip: (node) => isElement(node) && skipped.has(node.tagName)
|
|
578
621
|
})
|
|
579
622
|
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fenced code block's info string: its language, then its meta, which
|
|
3
|
+
* can carry a title (per Sam, Nov 6):
|
|
4
|
+
*
|
|
5
|
+
* ```md "Hello World.md"
|
|
6
|
+
* ```js title="app.js"
|
|
7
|
+
*
|
|
8
|
+
* The title is `title="..."` when the meta has one, else the first
|
|
9
|
+
* quoted string standing on its own, in double or single quotes. The
|
|
10
|
+
* rest of the meta is the author's, as frontmatter keys vowel doesn't
|
|
11
|
+
* read are. GitHub ignores the meta, so a titled block still renders
|
|
12
|
+
* there, without its title. `title="..."` is Docusaurus', Astro's and
|
|
13
|
+
* rehype-pretty-code's spelling; the bare string is Vowel's short form,
|
|
14
|
+
* which rules out Expressive Code's meaning for one (mark this text).
|
|
15
|
+
*
|
|
16
|
+
* Free of node builtins: the editor may need it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const KEYED = /(?:^|\s)title=(?:"([^"]*)"|'([^']*)')(?=\s|$)/
|
|
20
|
+
const BARE = /(?:^|\s)(?:"([^"]*)"|'([^']*)')(?=\s|$)/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The fence as micromark split it, at the first whitespace, read again.
|
|
24
|
+
* A quoted first word is not a language: in ```` ```"notes.txt" ````
|
|
25
|
+
* micromark's language is `"notes.txt"`, and the block has none.
|
|
26
|
+
* @param {string | null | undefined} lang - mdast's `code.lang`
|
|
27
|
+
* @param {string | null | undefined} meta - mdast's `code.meta`
|
|
28
|
+
* @returns {{ language: string | null, meta: string | null, title: string | undefined }}
|
|
29
|
+
*/
|
|
30
|
+
export function parseFence(lang, meta) {
|
|
31
|
+
const quoted = /^["']/.test(lang ?? "")
|
|
32
|
+
const language = lang && !quoted ? lang : null
|
|
33
|
+
const rest = (quoted ? [lang, meta].filter(Boolean).join(" ") : meta) || null
|
|
34
|
+
const match = rest ? KEYED.exec(rest) ?? BARE.exec(rest) : null
|
|
35
|
+
const title = (match?.[1] ?? match?.[2])?.trim() || undefined
|
|
36
|
+
return { language, meta: rest, title }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A title written back into a fence, as mdast's `lang` and `meta`:
|
|
41
|
+
* `title="..."` after a language, the spelling other tools read, and
|
|
42
|
+
* the bare string without one, since a `title=` would be read as the
|
|
43
|
+
* language. Split at the first space, as micromark splits it: a `lang`
|
|
44
|
+
* with a space in it is written with the space escaped. A title with a
|
|
45
|
+
* double quote in it is in single quotes.
|
|
46
|
+
* @param {string | null} language
|
|
47
|
+
* @param {string} title
|
|
48
|
+
* @returns {{ lang: string | null, meta: string | null }}
|
|
49
|
+
*/
|
|
50
|
+
export function fenceWithTitle(language, title) {
|
|
51
|
+
const quote = title.includes('"') ? "'" : '"'
|
|
52
|
+
const quotedTitle = `${quote}${title}${quote}`
|
|
53
|
+
if (language) return { lang: language, meta: `title=${quotedTitle}` }
|
|
54
|
+
const [first, ...rest] = quotedTitle.split(/\s+/)
|
|
55
|
+
return { lang: first, meta: rest.join(" ") || null }
|
|
56
|
+
}
|
|
@@ -20,6 +20,7 @@ import { toString as hastToString } from 'hast-util-to-string'
|
|
|
20
20
|
import getMetadata, { imageFromPage, reservedProperties } from "./metadata.js"
|
|
21
21
|
import { isSecretPath, secretRouter, displayPath } from "./../../secretPaths.js"
|
|
22
22
|
import { collectLinks } from "./links.js"
|
|
23
|
+
import { parseFence } from "./codeFence.js"
|
|
23
24
|
import { readWalk } from "./readRules.js"
|
|
24
25
|
import { h } from "hastscript"
|
|
25
26
|
import { mirrorStubs, mirrorWriter, llmsStubs } from "./mirror.js"
|
|
@@ -146,8 +147,18 @@ function readFile(source, { api, config }) {
|
|
|
146
147
|
// is too wide, so the table itself can be a real table at full width
|
|
147
148
|
// (a table that scrolls has to be display: block, which sizes its
|
|
148
149
|
// columns to their content), and a caption has somewhere to go.
|
|
150
|
+
// A code block with a title (codeFence.js) is in one too, the title
|
|
151
|
+
// as its caption. One without stays a bare <pre>.
|
|
149
152
|
handlers: {
|
|
150
|
-
table: (state, node) => h("figure", [defaultHandlers.table(state, node)])
|
|
153
|
+
table: (state, node) => h("figure.table", [defaultHandlers.table(state, node)]),
|
|
154
|
+
code: (state, node) => {
|
|
155
|
+
const fence = parseFence(node.lang, node.meta)
|
|
156
|
+
const pre = defaultHandlers.code(state, { ...node, lang: fence.language, meta: fence.meta })
|
|
157
|
+
if (!fence.title) return pre
|
|
158
|
+
const figure = h("figure.code", [h("figcaption", fence.title), pre])
|
|
159
|
+
state.patch(node, figure)
|
|
160
|
+
return figure
|
|
161
|
+
}
|
|
151
162
|
},
|
|
152
163
|
// `state.all(node)` converts the children to hast; handing over
|
|
153
164
|
// `node.children` gives the HTML compiler raw *mdast* instead. Plain
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* @property {string} variable - the custom property in TypographyStyles.css
|
|
21
21
|
* @property {string} label
|
|
22
22
|
* @property {string} [hint]
|
|
23
|
-
* @property {"
|
|
23
|
+
* @property {"ch" | ""} unit
|
|
24
24
|
* @property {number} min
|
|
25
25
|
* @property {number} max
|
|
26
26
|
* @property {number} step
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
|
|
30
30
|
/** @type {TypographySetting[]} */
|
|
31
31
|
export const rampSettings = [
|
|
32
|
-
{ key: "heading-size", variable: "--fontsize-
|
|
32
|
+
{ 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 },
|
|
33
33
|
{ 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 },
|
|
34
34
|
{ 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 },
|
|
35
35
|
{ key: "h6-letter-spacing", variable: "--letterspacing-h6", label: "h6 letter spacing", unit: "ch", min: -0.1, max: 0.1, step: 0.005, default: 0 },
|
|
@@ -49,15 +49,15 @@ function themeSettings(theme) {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
* A ramp setting's value as CSS, clamped to its range.
|
|
53
|
-
*
|
|
52
|
+
* A ramp setting's value as CSS, clamped to its range. `heading-size` is
|
|
53
|
+
* a plain number, `--fontsize-scale`, which the sheet multiplies by the
|
|
54
|
+
* root size, so headings follow the root's clamp() up and down.
|
|
54
55
|
* @param {TypographySetting} setting
|
|
55
56
|
* @param {number} value
|
|
56
57
|
*/
|
|
57
58
|
function rampValue(setting, value) {
|
|
58
59
|
const clamped = Math.min(setting.max, Math.max(setting.min, value))
|
|
59
60
|
const number = String(Number(clamped.toFixed(4)))
|
|
60
|
-
if (setting.unit === "x") return `calc(${number} * var(--fontsize-root))`
|
|
61
61
|
return `${number}${setting.unit}`
|
|
62
62
|
}
|
|
63
63
|
|