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.
@@ -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
+ }
@@ -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
- /** A nav entry: where it goes and what it says. */
100
- const item = (page) => ({ url: page.metadata.prettyURL, label: page.metadata.breadcrumb })
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
- return { url: "/" + page.path, label: page.metadata.breadcrumb, ...(below.length ? { children: below } : {}) }
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
- return (level ? asMenu(list) : list).map(item)
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
- /** The header nav: one <ul> per level that has entries, root first. */
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
- * The page's copy of a nav, with the current page marked. A copy: the
298
- * tracked read hands back a fresh parse, but a rule that relies on that
299
- * would be fragile, and the walk mutates.
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
- if (node.type === "element" && node.tagName === "a" && node.properties?.href === url) node.properties.ariaCurrent = "page"
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
- return h("li", h("a", { href: url, rel: "me" }, image || text))
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
- node.properties.href = (derivativeURL(found, imageSizes.at(-1)) ?? urlOf(found)) + suffix
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: what rehype-highlight did, without
280
- * its walk. hljs plus per-token spans on the <code>; the language class
281
- * is left for the stylesheet. An unregistered language is left alone.
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
- let result
292
- try {
293
- result = lowlight.highlight(language, toText(code, { whitespace: "pre" }))
294
- } catch (error) {
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 {"x" | "ch" | ""} unit - "x" is a multiple of --fontsize-root
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-delta", label: "Heading size", hint: "How much larger h1 is than body text", unit: "x", min: 0.25, max: 4, step: 0.05, default: 2 },
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. "x" is a multiple
53
- * of the root size, so headings follow the root's clamp() up and down.
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