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.
- package/README.md +1 -1
- package/bin.js +47 -27
- package/cli.js +15 -0
- package/config.js +93 -26
- package/extractDate.js +70 -6
- package/index.js +14 -5
- package/package.json +39 -8
- package/plugins/assets/index.js +65 -0
- package/plugins/atproto/index.js +132 -0
- package/plugins/atproto/password.js +71 -0
- package/plugins/atproto/publish.js +406 -0
- package/plugins/atproto/record.js +144 -0
- package/plugins/atproto/rkey.js +54 -0
- package/plugins/deploy-cloudflare/index.js +30 -16
- package/plugins/fonts/index.js +18 -21
- package/plugins/html/assets/bundle-BeK1Hptv.css +21 -0
- package/plugins/html/bundle/assets/index-BeK1Hptv.css +21 -0
- package/plugins/html/bundle/index.js +21145 -0
- package/plugins/html/editor/EditButton.svelte +152 -0
- package/plugins/html/editor/Editor.svelte +51 -0
- package/plugins/html/editor/Overlays.svelte +6 -0
- package/plugins/html/editor/SettingsPanel.svelte +397 -0
- package/plugins/html/editor/Toolbar.svelte +644 -0
- package/plugins/html/editor/create-session.js +250 -0
- package/plugins/html/editor/directives.js +128 -0
- package/plugins/html/editor/frontmatter.js +97 -0
- package/plugins/html/editor/ingest.js +339 -0
- package/plugins/html/editor/main.js +94 -0
- package/plugins/html/editor/nodes/Alert.svelte +21 -0
- package/plugins/html/editor/nodes/Blockquote.svelte +9 -0
- package/plugins/html/editor/nodes/CodeBlock.svelte +19 -0
- package/plugins/html/editor/nodes/Embed.svelte +19 -0
- package/plugins/html/editor/nodes/Emphasis.svelte +11 -0
- package/plugins/html/editor/nodes/Heading.svelte +15 -0
- package/plugins/html/editor/nodes/Highlight.svelte +11 -0
- package/plugins/html/editor/nodes/Image.svelte +18 -0
- package/plugins/html/editor/nodes/InlineCode.svelte +11 -0
- package/plugins/html/editor/nodes/Link.svelte +17 -0
- package/plugins/html/editor/nodes/List.svelte +15 -0
- package/plugins/html/editor/nodes/ListItem.svelte +9 -0
- package/plugins/html/editor/nodes/Page.svelte +21 -0
- package/plugins/html/editor/nodes/Paragraph.svelte +9 -0
- package/plugins/html/editor/nodes/Strikethrough.svelte +11 -0
- package/plugins/html/editor/nodes/Strong.svelte +11 -0
- package/plugins/html/editor/nodes/ThematicBreak.svelte +9 -0
- package/plugins/html/editor/nodes/index.js +18 -0
- package/plugins/html/editor/rollup.config.js +35 -0
- package/plugins/html/editor/serialize.js +157 -0
- package/plugins/html/editor/settings-file.js +83 -0
- package/plugins/html/editor/socket.js +202 -0
- package/plugins/html/editor/source.js +43 -0
- package/plugins/html/image.js +31 -5
- package/plugins/html/index.js +430 -458
- package/plugins/html/partials.js +299 -0
- package/plugins/html/socialLinks.js +67 -0
- package/plugins/html/writeRules.js +377 -0
- package/plugins/icons/index.js +2 -6
- package/plugins/images/index.js +78 -55
- package/plugins/markdown/brandColors.js +10 -0
- package/plugins/markdown/colorScheme.js +64 -0
- package/plugins/markdown/emoji.js +30 -0
- package/plugins/markdown/frontmatter.js +253 -0
- package/plugins/markdown/index.js +276 -334
- package/plugins/markdown/links.js +97 -0
- package/plugins/markdown/metadata.js +229 -68
- package/plugins/markdown/readRules.js +143 -0
- package/plugins/markdown/typography.js +109 -0
- package/plugins/markdown/visitor.js +143 -0
- package/plugins/publish/index.js +34 -0
- package/plugins/robots/agents.js +79 -0
- package/plugins/robots/index.js +30 -10
- package/plugins/robots/robots.js +48 -0
- package/plugins/styles/index.js +111 -18
- package/plugins/styles/theme.js +116 -0
- package/plugins/urls/index.js +49 -62
- package/plugins/urls/urlMetadata.js +246 -0
- package/plugins/vectors/index.js +5 -9
- package/plugins/xml/entryContent.js +103 -0
- package/plugins/xml/index.js +105 -43
- package/secretPaths.js +170 -0
- package/stylesheets/DefaultStyles.css +367 -437
- package/stylesheets/ResetStyles.css +2 -38
- package/stylesheets/SyntaxHighlightingStyles.css +36 -4
- package/stylesheets/TypographyStyles.css +386 -128
- package/systemPaths.js +48 -0
- package/utils.js +56 -5
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -73
- package/.github/ISSUE_TEMPLATE/config.yml +0 -7
- package/docs-source/.cache.json +0 -9
- package/docs-source/.obsidian/app.json +0 -3
- package/docs-source/.obsidian/appearance.json +0 -3
- package/docs-source/.obsidian/core-plugins-migration.json +0 -30
- package/docs-source/.obsidian/core-plugins.json +0 -20
- package/docs-source/.obsidian/workspace.json +0 -168
- package/docs-source/.stackblitzrc +0 -3
- package/docs-source/.votive.db +0 -0
- package/docs-source/about.md +0 -3
- package/docs-source/blog/home.md +0 -5
- package/docs-source/blog/url-ui.md +0 -21
- package/docs-source/blog.md +0 -5
- package/docs-source/docs/.votive.db +0 -0
- package/docs-source/docs/deploy.md +0 -67
- package/docs-source/docs/file-structure.md +0 -31
- package/docs-source/docs/folder-settings.md +0 -23
- package/docs-source/docs/home.md +0 -55
- package/docs-source/docs/images.md +0 -10
- package/docs-source/docs/items.md +0 -13
- package/docs-source/docs/pages.md +0 -141
- package/docs-source/docs/settings.md +0 -4
- package/docs-source/docs/styling.md +0 -34
- package/docs-source/docs/taxonomies.md +0 -37
- package/docs-source/features/cards.md +0 -7
- package/docs-source/features/editing.md +0 -7
- package/docs-source/features/emoji.md +0 -7
- package/docs-source/features/frontmatter.md +0 -7
- package/docs-source/features/lists.md +0 -7
- package/docs-source/features/navigation.md +0 -7
- package/docs-source/features/rich-previews.md +0 -7
- package/docs-source/features/robots.md +0 -7
- package/docs-source/features/rss.md +0 -7
- package/docs-source/features/sitemap.md +0 -7
- package/docs-source/features/speed.md +0 -7
- package/docs-source/features/static.md +0 -7
- package/docs-source/features/taxonomies.md +0 -7
- package/docs-source/home.md +0 -42
- package/docs-source/output/about.html +0 -1
- package/docs-source/output/blog/url-ui.html +0 -8
- package/docs-source/output/blog.html +0 -1
- package/docs-source/output/default.css +0 -1
- package/docs-source/output/docs/deploy.html +0 -34
- package/docs-source/output/docs/file-structure.html +0 -24
- package/docs-source/output/docs/folder-settings.html +0 -20
- package/docs-source/output/docs/images.html +0 -2
- package/docs-source/output/docs/items.html +0 -6
- package/docs-source/output/docs/pages.html +0 -101
- package/docs-source/output/docs/styling.html +0 -18
- package/docs-source/output/docs/taxonomies.html +0 -20
- package/docs-source/output/docs.html +0 -28
- package/docs-source/output/features/cards.html +0 -1
- package/docs-source/output/features/editing.html +0 -1
- package/docs-source/output/features/emoji.html +0 -1
- package/docs-source/output/features/frontmatter.html +0 -1
- package/docs-source/output/features/lists.html +0 -1
- package/docs-source/output/features/navigation.html +0 -1
- package/docs-source/output/features/rich-previews.html +0 -1
- package/docs-source/output/features/robots.html +0 -1
- package/docs-source/output/features/rss.html +0 -1
- package/docs-source/output/features/sitemap.html +0 -1
- package/docs-source/output/features/speed.html +0 -1
- package/docs-source/output/features/static.html +0 -1
- package/docs-source/output/features/taxonomies.html +0 -1
- package/docs-source/output/features.html +0 -1
- package/docs-source/output/feed.xml +0 -1
- package/docs-source/output/index.html +0 -21
- package/docs-source/output/reset.css +0 -1
- package/docs-source/output/roadmap.html +0 -87
- package/docs-source/output/robots.txt +0 -14
- package/docs-source/output/sitemap.xml +0 -16
- package/docs-source/output/typography.css +0 -1
- package/docs-source/roadmap.md +0 -98
- package/docs-source/settings.md +0 -13
- package/getMetadata.js +0 -41
- package/jsconfig.json +0 -10
- package/plugins/markdown/robots.js +0 -18
- package/regex.js +0 -36
- package/stylesheets/brand-colors.css +0 -119
|
@@ -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
|
+
}
|
package/plugins/html/image.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|