vowel 0.8.0 → 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 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.8.0",
4
+ "version": "0.8.1",
5
5
  "engines": {
6
6
  "node": ">=26"
7
7
  },
@@ -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",
@@ -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
- const code = (node.children ?? []).find(child => is(child, "code")) ?? node
190
- const language = classes(code).find(name => name.startsWith("language-"))?.slice("language-".length)
191
- return [{ type: "code", lang: language ?? null, value: toText(code).replace(/\n$/, "") }]
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-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 },
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
- // Compiles the Svedit editor into a single inlineable bundle,
7
- // committed at ../bundle/index.js -
8
- // vowel/plugins/html/index.js reads that file's contents once at plugin
9
- // load time and injects it into every previewed page's
10
- // handlePreviewRequest, the same way it already inlines votive's
11
- // openSocket() reload client. Rebuild with `npm run build:editor` from
12
- // vowel's root after editing anything in this folder.
13
- export default {
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
+ }
@@ -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/main.js via rollup.config.js (`npm run
39
- // build:editor` from vowel's root) - the Svedit editor and reload client
40
- // for previewed pages, read once here and inlined into every previewed
41
- // page. See tasks/desktop-app-architecture.md, Part 3.
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
- }, "Vowel"),
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: it mounts only when the
863
- // dev server was started with `vowel --editor` (config.editor).
864
- // The client bundle is injected regardless, because it is also
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>`)
@@ -41,10 +41,13 @@ export function extendMarkdown(refractor) {
41
41
  markdown["front-matter-block"].lookbehind = false
42
42
 
43
43
  // Three or more backticks or tildes, closed by the same character at
44
- // least as many times. The parts stay in the order Prism's
45
- // after-tokenize hook expects (fence, language, newline, block,
46
- // newline, fence): it reads the language from them, and highlights
47
- // the block in it.
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.
48
51
  markdown.code[1] = {
49
52
  pattern: /^(([`~])\2{2,})[^\r\n]*(?:\r\n?|\n)(?:[\s\S]*?(?:\r\n?|\n))??\1\2*[ \t]*$/m,
50
53
  greedy: true,
@@ -54,10 +57,14 @@ export function extendMarkdown(refractor) {
54
57
  lookbehind: true
55
58
  },
56
59
  "code-language": {
57
- pattern: /^(`{3,}|~{3,})[^\s`~][^\r\n]*/,
60
+ pattern: /^(`{3,}|~{3,})[^\s`~"'][^\s]*/,
58
61
  lookbehind: true
59
62
  },
60
- punctuation: /`{3,}|~{3,}/
63
+ punctuation: /`{3,}|~{3,}/,
64
+ "code-meta": {
65
+ pattern: /^[ \t]*[^\s][^\r\n]*(?:\r\n?|\n)$/,
66
+ inside: { string: /"[^"]*"|'[^']*'/ }
67
+ }
61
68
  }
62
69
  }
63
70