vowel 0.6.4 → 0.7.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/README.md +1 -1
  2. package/bin.js +47 -27
  3. package/cli.js +15 -0
  4. package/config.js +93 -26
  5. package/extractDate.js +70 -6
  6. package/index.js +14 -5
  7. package/package.json +39 -8
  8. package/plugins/assets/index.js +65 -0
  9. package/plugins/atproto/index.js +132 -0
  10. package/plugins/atproto/password.js +71 -0
  11. package/plugins/atproto/publish.js +406 -0
  12. package/plugins/atproto/record.js +144 -0
  13. package/plugins/atproto/rkey.js +54 -0
  14. package/plugins/deploy-cloudflare/index.js +30 -16
  15. package/plugins/fonts/index.js +18 -21
  16. package/plugins/html/assets/bundle-BeK1Hptv.css +21 -0
  17. package/plugins/html/bundle/assets/index-BeK1Hptv.css +21 -0
  18. package/plugins/html/bundle/index.js +21145 -0
  19. package/plugins/html/editor/EditButton.svelte +152 -0
  20. package/plugins/html/editor/Editor.svelte +51 -0
  21. package/plugins/html/editor/Overlays.svelte +6 -0
  22. package/plugins/html/editor/SettingsPanel.svelte +397 -0
  23. package/plugins/html/editor/Toolbar.svelte +644 -0
  24. package/plugins/html/editor/create-session.js +250 -0
  25. package/plugins/html/editor/directives.js +128 -0
  26. package/plugins/html/editor/frontmatter.js +97 -0
  27. package/plugins/html/editor/ingest.js +339 -0
  28. package/plugins/html/editor/main.js +94 -0
  29. package/plugins/html/editor/nodes/Alert.svelte +21 -0
  30. package/plugins/html/editor/nodes/Blockquote.svelte +9 -0
  31. package/plugins/html/editor/nodes/CodeBlock.svelte +19 -0
  32. package/plugins/html/editor/nodes/Embed.svelte +19 -0
  33. package/plugins/html/editor/nodes/Emphasis.svelte +11 -0
  34. package/plugins/html/editor/nodes/Heading.svelte +15 -0
  35. package/plugins/html/editor/nodes/Highlight.svelte +11 -0
  36. package/plugins/html/editor/nodes/Image.svelte +18 -0
  37. package/plugins/html/editor/nodes/InlineCode.svelte +11 -0
  38. package/plugins/html/editor/nodes/Link.svelte +17 -0
  39. package/plugins/html/editor/nodes/List.svelte +15 -0
  40. package/plugins/html/editor/nodes/ListItem.svelte +9 -0
  41. package/plugins/html/editor/nodes/Page.svelte +21 -0
  42. package/plugins/html/editor/nodes/Paragraph.svelte +9 -0
  43. package/plugins/html/editor/nodes/Strikethrough.svelte +11 -0
  44. package/plugins/html/editor/nodes/Strong.svelte +11 -0
  45. package/plugins/html/editor/nodes/ThematicBreak.svelte +9 -0
  46. package/plugins/html/editor/nodes/index.js +18 -0
  47. package/plugins/html/editor/rollup.config.js +35 -0
  48. package/plugins/html/editor/serialize.js +157 -0
  49. package/plugins/html/editor/settings-file.js +83 -0
  50. package/plugins/html/editor/socket.js +202 -0
  51. package/plugins/html/editor/source.js +43 -0
  52. package/plugins/html/image.js +31 -5
  53. package/plugins/html/index.js +430 -458
  54. package/plugins/html/partials.js +299 -0
  55. package/plugins/html/socialLinks.js +67 -0
  56. package/plugins/html/writeRules.js +377 -0
  57. package/plugins/icons/index.js +2 -6
  58. package/plugins/images/index.js +78 -55
  59. package/plugins/markdown/brandColors.js +10 -0
  60. package/plugins/markdown/colorScheme.js +64 -0
  61. package/plugins/markdown/emoji.js +30 -0
  62. package/plugins/markdown/frontmatter.js +253 -0
  63. package/plugins/markdown/index.js +276 -334
  64. package/plugins/markdown/links.js +97 -0
  65. package/plugins/markdown/metadata.js +229 -68
  66. package/plugins/markdown/readRules.js +143 -0
  67. package/plugins/markdown/typography.js +109 -0
  68. package/plugins/markdown/visitor.js +143 -0
  69. package/plugins/publish/index.js +34 -0
  70. package/plugins/robots/agents.js +79 -0
  71. package/plugins/robots/index.js +30 -10
  72. package/plugins/robots/robots.js +48 -0
  73. package/plugins/styles/index.js +111 -18
  74. package/plugins/styles/theme.js +116 -0
  75. package/plugins/urls/index.js +49 -62
  76. package/plugins/urls/urlMetadata.js +246 -0
  77. package/plugins/vectors/index.js +5 -9
  78. package/plugins/xml/entryContent.js +103 -0
  79. package/plugins/xml/index.js +105 -43
  80. package/secretPaths.js +170 -0
  81. package/stylesheets/DefaultStyles.css +367 -437
  82. package/stylesheets/ResetStyles.css +2 -38
  83. package/stylesheets/SyntaxHighlightingStyles.css +36 -4
  84. package/stylesheets/TypographyStyles.css +386 -128
  85. package/systemPaths.js +48 -0
  86. package/utils.js +56 -5
  87. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -73
  88. package/.github/ISSUE_TEMPLATE/config.yml +0 -7
  89. package/docs-source/.cache.json +0 -9
  90. package/docs-source/.obsidian/app.json +0 -3
  91. package/docs-source/.obsidian/appearance.json +0 -3
  92. package/docs-source/.obsidian/core-plugins-migration.json +0 -30
  93. package/docs-source/.obsidian/core-plugins.json +0 -20
  94. package/docs-source/.obsidian/workspace.json +0 -168
  95. package/docs-source/.stackblitzrc +0 -3
  96. package/docs-source/.votive.db +0 -0
  97. package/docs-source/about.md +0 -3
  98. package/docs-source/blog/home.md +0 -5
  99. package/docs-source/blog/url-ui.md +0 -21
  100. package/docs-source/blog.md +0 -5
  101. package/docs-source/docs/.votive.db +0 -0
  102. package/docs-source/docs/deploy.md +0 -67
  103. package/docs-source/docs/file-structure.md +0 -31
  104. package/docs-source/docs/folder-settings.md +0 -23
  105. package/docs-source/docs/home.md +0 -55
  106. package/docs-source/docs/images.md +0 -10
  107. package/docs-source/docs/items.md +0 -13
  108. package/docs-source/docs/pages.md +0 -141
  109. package/docs-source/docs/settings.md +0 -4
  110. package/docs-source/docs/styling.md +0 -34
  111. package/docs-source/docs/taxonomies.md +0 -37
  112. package/docs-source/features/cards.md +0 -7
  113. package/docs-source/features/editing.md +0 -7
  114. package/docs-source/features/emoji.md +0 -7
  115. package/docs-source/features/frontmatter.md +0 -7
  116. package/docs-source/features/lists.md +0 -7
  117. package/docs-source/features/navigation.md +0 -7
  118. package/docs-source/features/rich-previews.md +0 -7
  119. package/docs-source/features/robots.md +0 -7
  120. package/docs-source/features/rss.md +0 -7
  121. package/docs-source/features/sitemap.md +0 -7
  122. package/docs-source/features/speed.md +0 -7
  123. package/docs-source/features/static.md +0 -7
  124. package/docs-source/features/taxonomies.md +0 -7
  125. package/docs-source/home.md +0 -42
  126. package/docs-source/output/about.html +0 -1
  127. package/docs-source/output/blog/url-ui.html +0 -8
  128. package/docs-source/output/blog.html +0 -1
  129. package/docs-source/output/default.css +0 -1
  130. package/docs-source/output/docs/deploy.html +0 -34
  131. package/docs-source/output/docs/file-structure.html +0 -24
  132. package/docs-source/output/docs/folder-settings.html +0 -20
  133. package/docs-source/output/docs/images.html +0 -2
  134. package/docs-source/output/docs/items.html +0 -6
  135. package/docs-source/output/docs/pages.html +0 -101
  136. package/docs-source/output/docs/styling.html +0 -18
  137. package/docs-source/output/docs/taxonomies.html +0 -20
  138. package/docs-source/output/docs.html +0 -28
  139. package/docs-source/output/features/cards.html +0 -1
  140. package/docs-source/output/features/editing.html +0 -1
  141. package/docs-source/output/features/emoji.html +0 -1
  142. package/docs-source/output/features/frontmatter.html +0 -1
  143. package/docs-source/output/features/lists.html +0 -1
  144. package/docs-source/output/features/navigation.html +0 -1
  145. package/docs-source/output/features/rich-previews.html +0 -1
  146. package/docs-source/output/features/robots.html +0 -1
  147. package/docs-source/output/features/rss.html +0 -1
  148. package/docs-source/output/features/sitemap.html +0 -1
  149. package/docs-source/output/features/speed.html +0 -1
  150. package/docs-source/output/features/static.html +0 -1
  151. package/docs-source/output/features/taxonomies.html +0 -1
  152. package/docs-source/output/features.html +0 -1
  153. package/docs-source/output/feed.xml +0 -1
  154. package/docs-source/output/index.html +0 -21
  155. package/docs-source/output/reset.css +0 -1
  156. package/docs-source/output/roadmap.html +0 -87
  157. package/docs-source/output/robots.txt +0 -14
  158. package/docs-source/output/sitemap.xml +0 -16
  159. package/docs-source/output/typography.css +0 -1
  160. package/docs-source/roadmap.md +0 -98
  161. package/docs-source/settings.md +0 -13
  162. package/getMetadata.js +0 -41
  163. package/jsconfig.json +0 -10
  164. package/plugins/markdown/robots.js +0 -18
  165. package/regex.js +0 -36
  166. package/stylesheets/brand-colors.css +0 -119
@@ -0,0 +1,9 @@
1
+ <script>
2
+ import { Node } from "svedit"
3
+
4
+ let { path } = $props()
5
+ </script>
6
+
7
+ <Node {path}>
8
+ <hr contenteditable="false" />
9
+ </Node>
@@ -0,0 +1,18 @@
1
+ export { default as Page } from "./Page.svelte"
2
+ export { default as Paragraph } from "./Paragraph.svelte"
3
+ export { default as Heading } from "./Heading.svelte"
4
+ export { default as List } from "./List.svelte"
5
+ export { default as ListItem } from "./ListItem.svelte"
6
+ export { default as Blockquote } from "./Blockquote.svelte"
7
+ export { default as Alert } from "./Alert.svelte"
8
+ export { default as CodeBlock } from "./CodeBlock.svelte"
9
+ export { default as ThematicBreak } from "./ThematicBreak.svelte"
10
+ export { default as Image } from "./Image.svelte"
11
+ export { default as Embed } from "./Embed.svelte"
12
+
13
+ export { default as Strong } from "./Strong.svelte"
14
+ export { default as Emphasis } from "./Emphasis.svelte"
15
+ export { default as InlineCode } from "./InlineCode.svelte"
16
+ export { default as Highlight } from "./Highlight.svelte"
17
+ export { default as Strikethrough } from "./Strikethrough.svelte"
18
+ export { default as Link } from "./Link.svelte"
@@ -0,0 +1,35 @@
1
+ import svelte from "rollup-plugin-svelte"
2
+ import resolve from "@rollup/plugin-node-resolve"
3
+ import css from "rollup-plugin-import-css"
4
+ import cleanup from "rollup-plugin-cleanup"
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 {
14
+ input: "main.js",
15
+ output: {
16
+ file: "../bundle/index.js",
17
+ format: "iife"
18
+ },
19
+ plugins: [
20
+ // emitCss: false - inline styles into the JS output instead of
21
+ // emitting a separate CSS asset, since this bundle gets injected as
22
+ // one self-contained inline <script>, not linked as a file.
23
+ svelte({ emitCss: false }),
24
+ resolve({
25
+ browser: true,
26
+ exportConditions: ["svelte"],
27
+ // The default extensions must be listed alongside .svelte: passing
28
+ // only .svelte overrides them, and bare package imports (e.g.
29
+ // mdast-util-to-markdown) then silently resolve to nothing.
30
+ extensions: [".svelte", ".mjs", ".js", ".json", ".node"]
31
+ }),
32
+ css(),
33
+ cleanup()
34
+ ]
35
+ }
@@ -0,0 +1,157 @@
1
+ // Svedit document -> markdown.
2
+ //
3
+ // The document was built from HTML (see ingest.js), so this is the only
4
+ // place markdown is produced. mdast-util-to-markdown does the escaping,
5
+ // which is the reason not to hand-roll it: a paragraph containing a
6
+ // literal "*" has to come back out as "\*".
7
+
8
+ import { toMarkdown } from "mdast-util-to-markdown"
9
+ import { stringify as stringifyYAML } from "yaml"
10
+ import { gfmStrikethroughToMarkdown } from "mdast-util-gfm-strikethrough"
11
+ import { highlightMarkToMarkdown } from "mdast-util-highlight-mark"
12
+
13
+ const MARK_BUILDERS = {
14
+ strong: (children) => ({ type: "strong", children }),
15
+ emphasis: (children) => ({ type: "emphasis", children }),
16
+ highlight: (children) => ({ type: "highlight", children }),
17
+ strikethrough: (children) => ({ type: "delete", children }),
18
+ link: (children, node) => ({ type: "link", url: node.href || "", children }),
19
+ inline_code: (children) => ({ type: "inlineCode", value: plainText(children) })
20
+ }
21
+
22
+ /** @param {object[]} children */
23
+ function plainText(children) {
24
+ return children.map(child => child.value ?? plainText(child.children || [])).join("")
25
+ }
26
+
27
+ /**
28
+ * Offset ranges back to nested inline mdast. Marks are mutually exclusive
29
+ * and non-overlapping, so a single ordered pass over the string is enough.
30
+ *
31
+ * @param {{content: string, marks: object[]}} value
32
+ * @param {Record<string, object>} nodes
33
+ */
34
+ function inlineFrom(value, nodes) {
35
+ const content = value.content || ""
36
+ const marks = [...(value.marks || [])].sort((left, right) => {
37
+ return left.start_offset - right.start_offset
38
+ })
39
+
40
+ const { children, cursor } = marks.reduce((accumulated, mark) => {
41
+ const markNode = nodes[mark.node_id]
42
+ const builder = markNode && MARK_BUILDERS[markNode.type]
43
+ if (!builder) return accumulated
44
+
45
+ const before = content.slice(accumulated.cursor, mark.start_offset)
46
+ const inner = content.slice(mark.start_offset, mark.end_offset)
47
+ const leading = before ? [{ type: "text", value: before }] : []
48
+
49
+ return {
50
+ cursor: mark.end_offset,
51
+ children: [
52
+ ...accumulated.children,
53
+ ...leading,
54
+ builder([{ type: "text", value: inner }], markNode)
55
+ ]
56
+ }
57
+ }, { children: [], cursor: 0 })
58
+
59
+ const trailing = content.slice(cursor)
60
+ return trailing ? [...children, { type: "text", value: trailing }] : children
61
+ }
62
+
63
+ /**
64
+ * @param {string} id
65
+ * @param {Record<string, object>} nodes
66
+ */
67
+ function blockFrom(id, nodes) {
68
+ const node = nodes[id]
69
+ if (!node) return null
70
+
71
+ if (node.type === "paragraph") {
72
+ return { type: "paragraph", children: inlineFrom(node.content, nodes) }
73
+ }
74
+
75
+ if (node.type === "heading") {
76
+ return { type: "heading", depth: node.level || 2, children: inlineFrom(node.content, nodes) }
77
+ }
78
+
79
+ if (node.type === "list") {
80
+ const children = node.items.nodes.map(itemId => ({
81
+ type: "listItem",
82
+ spread: false,
83
+ children: [{ type: "paragraph", children: inlineFrom(nodes[itemId].content, nodes) }]
84
+ }))
85
+ return { type: "list", ordered: Boolean(node.ordered), spread: false, children }
86
+ }
87
+
88
+ if (node.type === "blockquote") {
89
+ return { type: "blockquote", children: node.body.nodes.map(child => blockFrom(child, nodes)) }
90
+ }
91
+
92
+ if (node.type === "alert") {
93
+ // The marker line the html plugin consumed is regenerated here. It is
94
+ // emitted raw rather than as text: escaping would make it "\[!NOTE]",
95
+ // which survives vowel's own parser but is not the literal GFM syntax.
96
+ const marker = {
97
+ type: "html",
98
+ value: `[!${(node.variant || "note").toUpperCase()}]`
99
+ }
100
+ const body = node.body.nodes.map(child => blockFrom(child, nodes))
101
+ return { type: "blockquote", children: [marker, ...body] }
102
+ }
103
+
104
+ if (node.type === "code_block") {
105
+ return { type: "code", lang: node.language || null, value: node.code || "" }
106
+ }
107
+
108
+ if (node.type === "thematic_break") {
109
+ return { type: "thematicBreak" }
110
+ }
111
+
112
+ if (node.type === "image") {
113
+ const image = { type: "image", url: node.source, alt: node.alt || "" }
114
+ const caption = node.caption ? [{ type: "text", value: ` ${node.caption}` }] : []
115
+ return { type: "paragraph", children: [image, ...caption] }
116
+ }
117
+
118
+ if (node.type === "embed") {
119
+ // The directive was reconstructed from the expansion's own class
120
+ // attributes at ingest; emitted verbatim so it never expands twice.
121
+ return { type: "html", value: node.directive }
122
+ }
123
+
124
+ return null
125
+ }
126
+
127
+ /**
128
+ * @param {{document_id: string, nodes: Record<string, object>}} doc
129
+ * @param {{title: string|null, properties: object}} [frontmatter]
130
+ * @returns {string}
131
+ */
132
+ export default function serialize(doc, frontmatter = { title: null, properties: {} }) {
133
+ const page = doc.nodes[doc.document_id]
134
+ const children = page.body.nodes
135
+ .map(id => blockFrom(id, doc.nodes))
136
+ .filter(Boolean)
137
+
138
+ const body = toMarkdown({ type: "root", children }, {
139
+ extensions: [gfmStrikethroughToMarkdown(), highlightMarkToMarkdown],
140
+ bullet: "-",
141
+ emphasis: "_",
142
+ strong: "*",
143
+ fences: true,
144
+ rule: "-"
145
+ })
146
+
147
+ // The title is written as a heading rather than a frontmatter key, so a
148
+ // page whose title only ever lived in frontmatter gains one here.
149
+ const heading = frontmatter.title ? `# ${frontmatter.title}\n\n` : ""
150
+
151
+ const properties = frontmatter.properties || {}
152
+ const yaml = Object.keys(properties).length
153
+ ? `---\n${stringifyYAML(properties)}---\n\n`
154
+ : ""
155
+
156
+ return yaml + heading + body
157
+ }
@@ -0,0 +1,83 @@
1
+ // Reading and writing the project's settings.md from the browser.
2
+ //
3
+ // The preview hands the root settings.md over with the page (source.js),
4
+ // and votive's POST endpoint writes it back. No endpoint to read it, and
5
+ // no settings.md target on the site - the panel edits the same file a
6
+ // person would open in an editor.
7
+
8
+ import { parse, stringify } from "yaml"
9
+ import { getSource, updateSettings } from "./source.js"
10
+
11
+ const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n)?/
12
+
13
+ /**
14
+ * Splits a markdown file into its frontmatter mapping and everything
15
+ * after it. The body is kept as raw text and never parsed: only the
16
+ * frontmatter is rewritten on save, so prose survives a round trip
17
+ * byte for byte.
18
+ * @param {string} text
19
+ */
20
+ export function splitFrontmatter(text) {
21
+ const match = text.match(FRONTMATTER)
22
+ if (!match) return { data: {}, body: text, had: false }
23
+
24
+ const data = parse(match[1]) || {}
25
+ return { data, body: text.slice(match[0].length), had: true }
26
+ }
27
+
28
+ /**
29
+ * The inverse. YAML comments and key order inside the frontmatter are
30
+ * lost here - the same trade the markdown round trip already makes, and
31
+ * the reason the body is spliced back rather than re-emitted.
32
+ * @param {object} data
33
+ * @param {string} body
34
+ */
35
+ export function joinFrontmatter(data, body) {
36
+ return `---\n${stringify(data)}---\n${body.replace(/^\r?\n/, "")}`
37
+ }
38
+
39
+ /**
40
+ * The theme mapping, always as an object. `theme: default` is the short
41
+ * form of `{name: default}`, and a panel that is about to write a font
42
+ * into it needs the long one.
43
+ * @param {unknown} theme
44
+ */
45
+ export function themeObject(theme) {
46
+ if (theme && typeof theme === "object" && !Array.isArray(theme)) return { ...theme }
47
+ if (typeof theme === "string") return { name: theme }
48
+ return {}
49
+ }
50
+
51
+ /**
52
+ * The root settings.md, as the preview handed it over (see source.js).
53
+ * A project with none yet reads as an empty file, so the panel can
54
+ * create it on first save.
55
+ */
56
+ export async function readSettings() {
57
+ const { settings } = getSource()
58
+ return splitFrontmatter(settings.markdown ?? "")
59
+ }
60
+
61
+ /**
62
+ * Writes the file back through votive's write endpoint, which resolves the
63
+ * path under sourceFolder and refuses anything outside it. The rebuild
64
+ * needs no trigger: the same watcher that sees a hand edit sees this one.
65
+ * @param {string} filePath
66
+ * @param {string} data
67
+ */
68
+ export async function writeSettings(filePath, data) {
69
+ const response = await fetch("/", {
70
+ method: "POST",
71
+ headers: { "Content-Type": "application/json" },
72
+ body: JSON.stringify({ type: "file", filePath, data })
73
+ })
74
+
75
+ if (!response.ok) {
76
+ const detail = await response.json().catch(() => ({}))
77
+ throw new Error(detail.error || `write failed (${response.status})`)
78
+ }
79
+
80
+ // What was written is the newest copy until the rebuild pushes one.
81
+ updateSettings(data)
82
+ return response.json()
83
+ }
@@ -0,0 +1,202 @@
1
+ import { updateFromTarget } from "./source.js"
2
+ /**
3
+ * Live reload without the reload. Every "a target changed" message is
4
+ * the target itself (see TargetOutput in votive/lib/createDatabase.js),
5
+ * with `data` holding the file as written. For the page being viewed
6
+ * that is a whole HTML document, and this patches the live DOM to match
7
+ * it rather than calling location.reload().
8
+ *
9
+ * It used to compare the pushed <head> with the live one and reload on
10
+ * any difference. Three things made that a reload on nearly every edit:
11
+ * <title>, og:title and og:description are derived from the content, so
12
+ * editing the content changed the head; a settings row that flipped
13
+ * every build changed it too (fixed at the source); and a stylesheet
14
+ * cache-buster that hashed mutable data flipped every pass (also fixed).
15
+ * None of those needed a reload - they needed the head patched, which
16
+ * this does.
17
+ *
18
+ * The body is morphed, not replaced. `body.replaceWith(newBody)` also
19
+ * threw away everything the client had put there: the injected editor
20
+ * script, the Edit button, the settings panel, and the editor itself if
21
+ * it was mounted - so a save from the settings panel destroyed the
22
+ * panel, and one patch removed the Edit button. Only the elements the
23
+ * build produced are touched; anything else in the body is left alone.
24
+ * While the editor is mounted (`data-vowel-editing` on <html>), <main>
25
+ * is left alone too: the editor's own content is the newer of the two.
26
+ */
27
+ export default function openSocket() {
28
+ console.info("Socket opened")
29
+ const socket = new WebSocket(`ws://${window.location.host}`);
30
+ socket.addEventListener('open', () => {
31
+ socket.send('opened')
32
+ });
33
+
34
+
35
+ socket.addEventListener("close", () => {
36
+ console.info("Socket closed")
37
+ socket.close()
38
+ setTimeout(() => {
39
+ console.info("socket closed refresh")
40
+ location.reload()
41
+
42
+ }, 1000)
43
+ })
44
+
45
+ function isOpen() {
46
+ return socket.readyState === 1
47
+ }
48
+
49
+ socket.addEventListener('message', e => {
50
+ if (!isOpen()) return
51
+
52
+ const target = JSON.parse(e.data)
53
+
54
+ // Nothing here knows what a non-html target should do on change yet -
55
+ // that's each content type's own concern to add, same as html's own
56
+ // patch logic below isn't generic.
57
+ if (target.extension !== ".html") return
58
+
59
+ const regex = new RegExp(window.location.pathname + "(index)?(\\.html)")
60
+ if (!("/" + target.path).match(regex)) return
61
+
62
+ // The page's source travels with the target; the editor saves from it.
63
+ updateFromTarget(target)
64
+
65
+ // No data at all (too large for the socket, or already gone) is the
66
+ // one case a reload is still the honest answer.
67
+ if (!target.data) {
68
+ location.reload()
69
+ return
70
+ }
71
+
72
+ const next = new DOMParser().parseFromString(target.data, "text/html")
73
+ patchHead(document.head, next.head)
74
+ patchBody(document.body, next.body)
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Elements are matched by their serialized form: one that exists in
80
+ * both heads is kept where it is (a <link> that is neither moved nor
81
+ * re-created is never refetched, so the page doesn't flash), one only
82
+ * in the new head is inserted before the next element that survives,
83
+ * one only in the old head is removed. <title> is special-cased because
84
+ * its text is what changes most.
85
+ * @param {HTMLHeadElement} head
86
+ * @param {HTMLHeadElement} nextHead
87
+ */
88
+ function patchHead(head, nextHead) {
89
+ const nextTitle = nextHead.querySelector("title")
90
+ if (nextTitle && document.title !== nextTitle.textContent) document.title = nextTitle.textContent
91
+
92
+ const isTitle = element => element.tagName === "TITLE"
93
+ const remaining = new Map()
94
+ for (const element of head.children) {
95
+ if (isTitle(element)) continue
96
+ const key = element.outerHTML
97
+ if (!remaining.has(key)) remaining.set(key, [])
98
+ remaining.get(key).push(element)
99
+ }
100
+
101
+ const keep = new Set()
102
+ const pending = []
103
+ const place = (clone, before) => {
104
+ before ? head.insertBefore(clone, before) : head.appendChild(clone)
105
+ keep.add(clone)
106
+ }
107
+ for (const element of nextHead.children) {
108
+ if (isTitle(element)) continue
109
+ const matches = remaining.get(element.outerHTML)
110
+ const existing = matches && matches.shift()
111
+ if (existing) {
112
+ // Everything queued since the last survivor goes in front of it,
113
+ // in order.
114
+ pending.forEach(clone => place(clone, existing))
115
+ pending.length = 0
116
+ keep.add(existing)
117
+ continue
118
+ }
119
+ pending.push(document.importNode(element, true))
120
+ }
121
+ pending.forEach(clone => place(clone, null))
122
+
123
+ for (const element of [...head.children]) {
124
+ if (isTitle(element) || keep.has(element)) continue
125
+ element.remove()
126
+ }
127
+ }
128
+
129
+ /**
130
+ * The build's top-level body elements are matched by tag and id (a
131
+ * page has one <header>, one <main>, one <aside>, one <footer>, and
132
+ * the html plugin gives anything else an id). Each is replaced with its
133
+ * new version, a new one is inserted where the build put it, an old one
134
+ * the build no longer produces is removed. Nothing the client added is
135
+ * touched.
136
+ * @param {HTMLElement} body
137
+ * @param {HTMLElement} nextBody
138
+ */
139
+ function patchBody(body, nextBody) {
140
+ const editing = document.documentElement.dataset.vowelEditing === "true"
141
+ const keyOf = element => element.id ? `${element.tagName}#${element.id}` : element.tagName
142
+ // Only the build's own elements take part, on both sides: a <script>
143
+ // in the pushed file would not run if inserted, and never matches
144
+ // the client's.
145
+ const incoming = [...nextBody.children].filter(wasProduced)
146
+ const produced = new Set(incoming.map(keyOf))
147
+
148
+ const current = new Map()
149
+ for (const element of body.children) {
150
+ if (!wasProduced(element)) continue
151
+ const key = keyOf(element)
152
+ if (produced.has(key) && !current.has(key)) current.set(key, element)
153
+ }
154
+
155
+ // Walk the new body in order, keeping a cursor so an element the
156
+ // build added lands in the right place relative to its siblings.
157
+ const placed = new Set()
158
+ let cursor = null
159
+ for (const element of incoming) {
160
+ const key = keyOf(element)
161
+ const existing = current.get(key)
162
+ if (existing && editing && existing.tagName === "MAIN") {
163
+ placed.add(existing)
164
+ cursor = existing
165
+ continue
166
+ }
167
+ const fresh = document.importNode(element, true)
168
+ if (existing) {
169
+ existing.replaceWith(fresh)
170
+ } else if (cursor) {
171
+ cursor.after(fresh)
172
+ } else {
173
+ body.prepend(fresh)
174
+ }
175
+ placed.add(fresh)
176
+ cursor = fresh
177
+ }
178
+
179
+ for (const [, element] of current) {
180
+ if (!placed.has(element) && element.isConnected) element.remove()
181
+ }
182
+
183
+ for (const element of [...body.children]) {
184
+ if (!produced.has(keyOf(element)) && wasProduced(element)) element.remove()
185
+ }
186
+ }
187
+
188
+ /**
189
+ * The client adds elements that aren't the build's: the editor's own
190
+ * <script type="module">, and whatever Svelte mounts into the body (the
191
+ * Edit button, the settings panel, overlays). A <script> or a Svelte
192
+ * host is never the build's, so the morph never touches them - the
193
+ * Svelte components that mount into the body mark their root with
194
+ * data-vowel-client (the settings panel is an <aside>, like the page's
195
+ * nav); the page-structure elements and anything with an id are.
196
+ * @param {Element} element
197
+ */
198
+ function wasProduced(element) {
199
+ if (element.tagName === "SCRIPT") return false
200
+ if (element.hasAttribute("data-vowel-client")) return false
201
+ return ["HEADER", "MAIN", "ASIDE", "FOOTER", "NAV"].includes(element.tagName) || Boolean(element.id)
202
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The page's source, as the preview handed it over: the source path and
3
+ * markdown of this page, and of the root settings.md. Seeded from the
4
+ * JSON the html plugin injects (#vowel-source) and kept current from the
5
+ * live-reload socket, which pushes the whole target - metadata included
6
+ * - on every rebuild. The editor and the settings panel read it at save
7
+ * time, so the frontmatter they keep is the newest the build has seen.
8
+ *
9
+ * It used to be fetched: GET <page>?source for the page, and settings.md
10
+ * was emitted as a target so the panel could GET it. Neither exists now.
11
+ */
12
+
13
+ const empty = { path: null, markdown: null, settings: { path: "settings.md", markdown: null }, editor: false }
14
+
15
+ let current = null
16
+
17
+ function seed() {
18
+ if (current) return current
19
+ const element = document.getElementById("vowel-source")
20
+ try {
21
+ current = element ? { ...empty, ...JSON.parse(element.textContent) } : { ...empty }
22
+ } catch {
23
+ current = { ...empty }
24
+ }
25
+ return current
26
+ }
27
+
28
+ /** The page's source path and markdown, and the root settings.md's. */
29
+ export function getSource() {
30
+ return seed()
31
+ }
32
+
33
+ /** A rebuilt target arrived over the socket: take its source and markdown. */
34
+ export function updateFromTarget(target) {
35
+ const source = seed()
36
+ if (typeof target.source === "string") source.path = target.source
37
+ if (typeof target.metadata?.markdown === "string") source.markdown = target.metadata.markdown
38
+ }
39
+
40
+ /** The settings panel wrote settings.md: remember what it wrote. */
41
+ export function updateSettings(markdown) {
42
+ seed().settings.markdown = markdown
43
+ }
@@ -12,7 +12,6 @@ import * as unpic from "unpic"
12
12
  * @param {boolean} [itemprop]
13
13
  */
14
14
  function createDynamicImage(imagePath, api, alt, itemprop) {
15
- // TODO: Hardcode image height and width
16
15
  const parsed = path.parse(imagePath);
17
16
  const isURL = testURL(imagePath)
18
17
  const isImg = !parsed.ext.search(/^.(png|jpeg|jpg)$/);
@@ -46,16 +45,31 @@ function createDynamicImage(imagePath, api, alt, itemprop) {
46
45
  })
47
46
  })
48
47
 
49
- return h("picture", { itemprop: itemprop && "image" }, sources)
48
+ // The derivative paths in srcset are hash-based, so the original asset
49
+ // path cannot be recovered from them. Carried here rather than as a
50
+ // <source>, which is a loading candidate a browser could select.
51
+ return h("picture", { itemprop: itemprop && "image", "data-original": imagePath }, sources)
50
52
  }
51
53
  }
52
54
 
53
55
  if (!isImg) return
54
56
 
55
- const relativePath = imagePath.startsWith("/") ? path.relative("/", imagePath) : imagePath
57
+ // Lowercased: every vowel target path is (config.js's router), and
58
+ // the author may well have typed the file's real name.
59
+ const relativePath = (imagePath.startsWith("/") ? path.relative("/", imagePath) : imagePath).toLowerCase()
56
60
  const image = api.target(relativePath)
57
61
  if (!image) return
58
- const formats = createImagePaths(image.abstract.sourcePath, "./", image.abstract.uuid)
62
+ // Until the image has been read (a deferred buffer read) it has no
63
+ // hash and no derivatives; a plain <img> stands in, and the tracked
64
+ // read of `hash` rewrites this page when they arrive. Derivatives are
65
+ // named from the *target* path, as the images processor declares them.
66
+ if (!image.metadata.hash) return h("img", { src: "/" + image.path, alt, loading: "lazy" })
67
+ const formats = createImagePaths(image.path, "./", image.metadata.hash)
68
+
69
+ // Intrinsic size, measured at read (plugins/images): the browser
70
+ // reserves the right box before the bytes arrive, and the reset's
71
+ // `img { height: auto }` keeps the ratio when the width is capped.
72
+ const { width, height, dominant } = image.metadata
59
73
 
60
74
  const sources = formats.map((format, index) => {
61
75
  const isImg = index === formats.length - 1
@@ -68,11 +82,23 @@ function createDynamicImage(imagePath, api, alt, itemprop) {
68
82
  loading: isImg && "lazy",
69
83
  src: isImg && "/" + format.at(-1),
70
84
  sizes: "100vw",
85
+ width: isImg && width,
86
+ height: isImg && height,
71
87
  alt: isImg && alt
72
88
  })
73
89
  })
74
90
 
75
- return h("picture", { itemprop: itemprop && "image" }, sources)
91
+ // The derivative paths in srcset are hash-based, so the original asset
92
+ // path cannot be recovered from them. Carried here rather than as a
93
+ // <source>, which is a loading candidate a browser could select.
94
+ // --dominant is the image's dominant colour, for a stylesheet to use
95
+ // as the placeholder behind a lazy image (`picture { background:
96
+ // var(--dominant) }`); nothing here decides that it should.
97
+ return h("picture", {
98
+ itemprop: itemprop && "image",
99
+ "data-original": imagePath,
100
+ style: dominant && `--dominant: ${dominant}`
101
+ }, sources)
76
102
  }
77
103
 
78
104
  export default createDynamicImage