vowel 0.6.4 → 0.7.1-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 (170) hide show
  1. package/README.md +1 -1
  2. package/bin.js +47 -27
  3. package/cli.js +15 -0
  4. package/config.js +95 -26
  5. package/extractDate.js +70 -6
  6. package/index.js +19 -5
  7. package/package.json +38 -7
  8. package/plugins/assets/index.js +65 -0
  9. package/plugins/atproto/body.js +413 -0
  10. package/plugins/atproto/index.js +138 -0
  11. package/plugins/atproto/lexicons.js +179 -0
  12. package/plugins/atproto/mapper.js +399 -0
  13. package/plugins/atproto/password.js +71 -0
  14. package/plugins/atproto/publish.js +471 -0
  15. package/plugins/atproto/record.js +90 -0
  16. package/plugins/atproto/rkey.js +54 -0
  17. package/plugins/deploy-cloudflare/index.js +30 -16
  18. package/plugins/fonts/index.js +18 -21
  19. package/plugins/html/assets/bundle-BeK1Hptv.css +21 -0
  20. package/plugins/html/bundle/assets/index-BeK1Hptv.css +21 -0
  21. package/plugins/html/bundle/index.js +21145 -0
  22. package/plugins/html/editor/EditButton.svelte +152 -0
  23. package/plugins/html/editor/Editor.svelte +51 -0
  24. package/plugins/html/editor/Overlays.svelte +6 -0
  25. package/plugins/html/editor/SettingsPanel.svelte +397 -0
  26. package/plugins/html/editor/Toolbar.svelte +644 -0
  27. package/plugins/html/editor/create-session.js +250 -0
  28. package/plugins/html/editor/directives.js +128 -0
  29. package/plugins/html/editor/frontmatter.js +97 -0
  30. package/plugins/html/editor/ingest.js +339 -0
  31. package/plugins/html/editor/main.js +94 -0
  32. package/plugins/html/editor/nodes/Alert.svelte +21 -0
  33. package/plugins/html/editor/nodes/Blockquote.svelte +9 -0
  34. package/plugins/html/editor/nodes/CodeBlock.svelte +19 -0
  35. package/plugins/html/editor/nodes/Embed.svelte +19 -0
  36. package/plugins/html/editor/nodes/Emphasis.svelte +11 -0
  37. package/plugins/html/editor/nodes/Heading.svelte +15 -0
  38. package/plugins/html/editor/nodes/Highlight.svelte +11 -0
  39. package/plugins/html/editor/nodes/Image.svelte +18 -0
  40. package/plugins/html/editor/nodes/InlineCode.svelte +11 -0
  41. package/plugins/html/editor/nodes/Link.svelte +17 -0
  42. package/plugins/html/editor/nodes/List.svelte +15 -0
  43. package/plugins/html/editor/nodes/ListItem.svelte +9 -0
  44. package/plugins/html/editor/nodes/Page.svelte +21 -0
  45. package/plugins/html/editor/nodes/Paragraph.svelte +9 -0
  46. package/plugins/html/editor/nodes/Strikethrough.svelte +11 -0
  47. package/plugins/html/editor/nodes/Strong.svelte +11 -0
  48. package/plugins/html/editor/nodes/ThematicBreak.svelte +9 -0
  49. package/plugins/html/editor/nodes/index.js +18 -0
  50. package/plugins/html/editor/rollup.config.js +35 -0
  51. package/plugins/html/editor/serialize.js +157 -0
  52. package/plugins/html/editor/settings-file.js +83 -0
  53. package/plugins/html/editor/socket.js +202 -0
  54. package/plugins/html/editor/source.js +43 -0
  55. package/plugins/html/image.js +31 -5
  56. package/plugins/html/index.js +436 -458
  57. package/plugins/html/partials.js +299 -0
  58. package/plugins/html/socialLinks.js +67 -0
  59. package/plugins/html/writeRules.js +379 -0
  60. package/plugins/icons/index.js +2 -6
  61. package/plugins/images/index.js +78 -55
  62. package/plugins/markdown/brandColors.js +10 -0
  63. package/plugins/markdown/colorScheme.js +64 -0
  64. package/plugins/markdown/emoji.js +30 -0
  65. package/plugins/markdown/frontmatter.js +269 -0
  66. package/plugins/markdown/index.js +295 -335
  67. package/plugins/markdown/links.js +97 -0
  68. package/plugins/markdown/metadata.js +229 -68
  69. package/plugins/markdown/readRules.js +143 -0
  70. package/plugins/markdown/typography.js +109 -0
  71. package/plugins/markdown/visitor.js +143 -0
  72. package/plugins/publish/index.js +34 -0
  73. package/plugins/robots/agents.js +79 -0
  74. package/plugins/robots/index.js +30 -10
  75. package/plugins/robots/robots.js +48 -0
  76. package/plugins/secret-pages/index.js +75 -0
  77. package/plugins/styles/index.js +111 -18
  78. package/plugins/styles/theme.js +116 -0
  79. package/plugins/urls/index.js +49 -62
  80. package/plugins/urls/urlMetadata.js +246 -0
  81. package/plugins/vectors/index.js +5 -9
  82. package/plugins/xml/entryContent.js +114 -0
  83. package/plugins/xml/index.js +107 -44
  84. package/secretPaths.js +170 -0
  85. package/stylesheets/DefaultStyles.css +399 -435
  86. package/stylesheets/ResetStyles.css +4 -39
  87. package/stylesheets/SyntaxHighlightingStyles.css +36 -4
  88. package/stylesheets/TypographyStyles.css +398 -128
  89. package/systemPaths.js +48 -0
  90. package/utils.js +56 -5
  91. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -73
  92. package/.github/ISSUE_TEMPLATE/config.yml +0 -7
  93. package/docs-source/.cache.json +0 -9
  94. package/docs-source/.obsidian/app.json +0 -3
  95. package/docs-source/.obsidian/appearance.json +0 -3
  96. package/docs-source/.obsidian/core-plugins-migration.json +0 -30
  97. package/docs-source/.obsidian/core-plugins.json +0 -20
  98. package/docs-source/.obsidian/workspace.json +0 -168
  99. package/docs-source/.stackblitzrc +0 -3
  100. package/docs-source/.votive.db +0 -0
  101. package/docs-source/about.md +0 -3
  102. package/docs-source/blog/home.md +0 -5
  103. package/docs-source/blog/url-ui.md +0 -21
  104. package/docs-source/blog.md +0 -5
  105. package/docs-source/docs/.votive.db +0 -0
  106. package/docs-source/docs/deploy.md +0 -67
  107. package/docs-source/docs/file-structure.md +0 -31
  108. package/docs-source/docs/folder-settings.md +0 -23
  109. package/docs-source/docs/home.md +0 -55
  110. package/docs-source/docs/images.md +0 -10
  111. package/docs-source/docs/items.md +0 -13
  112. package/docs-source/docs/pages.md +0 -141
  113. package/docs-source/docs/settings.md +0 -4
  114. package/docs-source/docs/styling.md +0 -34
  115. package/docs-source/docs/taxonomies.md +0 -37
  116. package/docs-source/features/cards.md +0 -7
  117. package/docs-source/features/editing.md +0 -7
  118. package/docs-source/features/emoji.md +0 -7
  119. package/docs-source/features/frontmatter.md +0 -7
  120. package/docs-source/features/lists.md +0 -7
  121. package/docs-source/features/navigation.md +0 -7
  122. package/docs-source/features/rich-previews.md +0 -7
  123. package/docs-source/features/robots.md +0 -7
  124. package/docs-source/features/rss.md +0 -7
  125. package/docs-source/features/sitemap.md +0 -7
  126. package/docs-source/features/speed.md +0 -7
  127. package/docs-source/features/static.md +0 -7
  128. package/docs-source/features/taxonomies.md +0 -7
  129. package/docs-source/home.md +0 -42
  130. package/docs-source/output/about.html +0 -1
  131. package/docs-source/output/blog/url-ui.html +0 -8
  132. package/docs-source/output/blog.html +0 -1
  133. package/docs-source/output/default.css +0 -1
  134. package/docs-source/output/docs/deploy.html +0 -34
  135. package/docs-source/output/docs/file-structure.html +0 -24
  136. package/docs-source/output/docs/folder-settings.html +0 -20
  137. package/docs-source/output/docs/images.html +0 -2
  138. package/docs-source/output/docs/items.html +0 -6
  139. package/docs-source/output/docs/pages.html +0 -101
  140. package/docs-source/output/docs/styling.html +0 -18
  141. package/docs-source/output/docs/taxonomies.html +0 -20
  142. package/docs-source/output/docs.html +0 -28
  143. package/docs-source/output/features/cards.html +0 -1
  144. package/docs-source/output/features/editing.html +0 -1
  145. package/docs-source/output/features/emoji.html +0 -1
  146. package/docs-source/output/features/frontmatter.html +0 -1
  147. package/docs-source/output/features/lists.html +0 -1
  148. package/docs-source/output/features/navigation.html +0 -1
  149. package/docs-source/output/features/rich-previews.html +0 -1
  150. package/docs-source/output/features/robots.html +0 -1
  151. package/docs-source/output/features/rss.html +0 -1
  152. package/docs-source/output/features/sitemap.html +0 -1
  153. package/docs-source/output/features/speed.html +0 -1
  154. package/docs-source/output/features/static.html +0 -1
  155. package/docs-source/output/features/taxonomies.html +0 -1
  156. package/docs-source/output/features.html +0 -1
  157. package/docs-source/output/feed.xml +0 -1
  158. package/docs-source/output/index.html +0 -21
  159. package/docs-source/output/reset.css +0 -1
  160. package/docs-source/output/roadmap.html +0 -87
  161. package/docs-source/output/robots.txt +0 -14
  162. package/docs-source/output/sitemap.xml +0 -16
  163. package/docs-source/output/typography.css +0 -1
  164. package/docs-source/roadmap.md +0 -98
  165. package/docs-source/settings.md +0 -13
  166. package/getMetadata.js +0 -41
  167. package/jsconfig.json +0 -10
  168. package/plugins/markdown/robots.js +0 -18
  169. package/regex.js +0 -36
  170. package/stylesheets/brand-colors.css +0 -119
@@ -0,0 +1,413 @@
1
+ import { toMarkdown } from "mdast-util-to-markdown"
2
+ import { gfmTableToMarkdown } from "mdast-util-gfm-table"
3
+ import { gfmStrikethroughToMarkdown } from "mdast-util-gfm-strikethrough"
4
+ import { gfmFootnoteToMarkdown } from "mdast-util-gfm-footnote"
5
+ import { gfmTaskListItemToMarkdown } from "mdast-util-gfm-task-list-item"
6
+ import { toText } from "hast-util-to-text"
7
+ import { emojiFor } from "../markdown/emoji.js"
8
+
9
+ /**
10
+ * A page's body as a record carries it, from the rendered
11
+ * `<section id=content>`: after the html plugin has resolved images,
12
+ * wikilinks and icons and expanded listings, which the source markdown
13
+ * has not. Two forms:
14
+ *
15
+ * - **Simplified markdown** (`markdownBody`), for a body field with no
16
+ * facets: CommonMark, plus the GFM tables, strikethrough, task items
17
+ * and footnotes vowel itself reads. Links and images are absolute; an
18
+ * image links to its copy on the site. An icon is its emoji, or its
19
+ * shortcode as text. A listing is a list of links. A hashtag is a link
20
+ * whose text keeps its `#`. No title: it is outside the section.
21
+ * - **Rich text** (`richTextBody`), for a `text` field with `facets`
22
+ * beside it: plain text, and a facet for each link, hashtag and mark
23
+ * the lexicon has a feature for. The rest is dropped, images included.
24
+ */
25
+
26
+ /** @param {any} node @param {string} tag */
27
+ const is = (node, tag) => node?.type === "element" && node.tagName === tag
28
+
29
+ /** @param {any} node */
30
+ const classes = (node) => [node?.properties?.className ?? []].flat().map(String)
31
+
32
+ /** @param {any} node @param {string} name */
33
+ const hasClass = (node, name) => classes(node).includes(name)
34
+
35
+ /** @param {string} value @param {string} base */
36
+ function absolute(value, base) {
37
+ try {
38
+ return new URL(value, base).href
39
+ } catch {
40
+ return value
41
+ }
42
+ }
43
+
44
+ /**
45
+ * An icon as text: `img.icon[data-icon=fa/rocket]` → 🚀, found by the
46
+ * name's last segment, else the shortcode the author wrote.
47
+ * @param {any} node
48
+ */
49
+ function iconText(node) {
50
+ const name = String(node.properties?.dataIcon ?? node.properties?.alt ?? "")
51
+ return emojiFor(name.split("/").at(-1)) ?? `:${name}:`
52
+ }
53
+
54
+ /**
55
+ * A listing item is `li > a > article`, the article holding the
56
+ * post's heading. Its title and url, or undefined.
57
+ * @param {any} node - an `a`
58
+ */
59
+ function listingLink(node) {
60
+ const article = (node.children ?? []).find(child => is(child, "article"))
61
+ if (!article) return undefined
62
+ const heading = (article.children ?? []).find(child => ["h1", "h2", "h3", "h4", "h5", "h6"].includes(child?.tagName))
63
+ const title = toText(heading ?? article).trim()
64
+ return { title, href: node.properties?.href }
65
+ }
66
+
67
+ /** A footnote reference's label, from `#user-content-fn-<label>`. @param {any} node */
68
+ function footnoteLabel(node) {
69
+ const href = String(node.properties?.href ?? "")
70
+ return href.replace(/^#user-content-fn-/, "")
71
+ }
72
+
73
+ /** @param {any} node */
74
+ const isFootnoteReference = (node) => is(node, "a") && ("dataFootnoteRef" in (node.properties ?? {}))
75
+
76
+ /** @param {any} node */
77
+ const isFootnoteSection = (node) => is(node, "section") && ("dataFootnotes" in (node.properties ?? {}))
78
+
79
+ const BLOCKS = new Set(["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "blockquote", "pre", "hr", "table", "div", "section", "figure", "figcaption", "details", "summary", "aside", "article", "header", "footer", "main", "picture", "dl", "dd", "dt"])
80
+ const DROPPED = new Set(["script", "style", "template", "svg", "iframe", "video", "audio", "nav", "form", "button", "noscript", "source"])
81
+
82
+ /** @param {any} node */
83
+ const isBlock = (node) => node?.type === "element" && BLOCKS.has(node.tagName)
84
+
85
+ // ---------------------------------------------------------------------
86
+ // Simplified markdown: hast → mdast, then mdast-util-to-markdown, which
87
+ // does the escaping.
88
+
89
+ /**
90
+ * @param {any[]} nodes
91
+ * @param {string} base - the page's URL
92
+ * @returns {any[]} mdast phrasing content
93
+ */
94
+ function phrasing(nodes, base) {
95
+ return nodes.flatMap(node => phrase(node, base))
96
+ }
97
+
98
+ /** @param {any} node @param {string} base @returns {any[]} */
99
+ function phrase(node, base) {
100
+ if (node.type === "text") return [{ type: "text", value: node.value.replace(/\s+/g, " ") }]
101
+ if (node.type !== "element" || DROPPED.has(node.tagName)) return []
102
+ const tag = node.tagName
103
+ const children = () => phrasing(node.children ?? [], base)
104
+ if (tag === "strong" || tag === "b") return [{ type: "strong", children: children() }]
105
+ if (tag === "em" || tag === "i") return [{ type: "emphasis", children: children() }]
106
+ if (tag === "del" || tag === "s") return [{ type: "delete", children: children() }]
107
+ if (tag === "code") return [{ type: "inlineCode", value: toText(node) }]
108
+ if (tag === "br") return [{ type: "break" }]
109
+ if (tag === "img") {
110
+ if (hasClass(node, "icon")) return [{ type: "text", value: iconText(node) }]
111
+ const src = node.properties?.src
112
+ return src ? [{ type: "image", url: absolute(String(src), base), alt: String(node.properties?.alt ?? "") }] : []
113
+ }
114
+ if (tag === "picture") {
115
+ const img = (node.children ?? []).find(child => is(child, "img"))
116
+ const src = node.properties?.dataOriginal ?? img?.properties?.src
117
+ return src ? [{ type: "image", url: absolute(String(src), base), alt: String(img?.properties?.alt ?? "") }] : []
118
+ }
119
+ if (tag === "a") {
120
+ if (isFootnoteReference(node)) return [{ type: "footnoteReference", identifier: footnoteLabel(node), label: footnoteLabel(node) }]
121
+ const listing = listingLink(node)
122
+ if (listing) return [{ type: "link", url: absolute(String(listing.href), base), children: [{ type: "text", value: listing.title }] }]
123
+ const href = node.properties?.href
124
+ // An unresolved wikilink has no href: its text is all there is.
125
+ if (href === undefined || href === null) return children()
126
+ return [{ type: "link", url: absolute(String(href), base), children: children() }]
127
+ }
128
+ if (tag === "input") return []
129
+ return children()
130
+ }
131
+
132
+ /**
133
+ * @param {any[]} nodes
134
+ * @param {string} base
135
+ * @returns {any[]} mdast block content
136
+ */
137
+ function blocks(nodes, base) {
138
+ const { out, run } = nodes.reduce(({ out, run }, node) => {
139
+ if (isBlock(node) || isFootnoteSection(node)) {
140
+ const flushed = run.length ? [...out, paragraph(run, base)] : out
141
+ return { out: [...flushed, ...block(node, base)], run: [] }
142
+ }
143
+ if (node.type === "text" && !node.value.trim() && !run.length) return { out, run }
144
+ return { out, run: [...run, node] }
145
+ }, { out: [], run: [] })
146
+ const all = run.length ? [...out, paragraph(run, base)] : out
147
+ return all.filter(Boolean)
148
+ }
149
+
150
+ /** @param {any[]} nodes @param {string} base */
151
+ function paragraph(nodes, base) {
152
+ const children = trimPhrasing(phrasing(nodes, base))
153
+ return children.length ? { type: "paragraph", children } : undefined
154
+ }
155
+
156
+ /**
157
+ * Leading and trailing whitespace off a run of phrasing, adjacent text
158
+ * merged first so a space before a dropped element is trimmed too.
159
+ * @param {any[]} input
160
+ */
161
+ function trimPhrasing(input) {
162
+ const children = input.reduce((merged, child) => {
163
+ const previous = merged.at(-1)
164
+ if (child.type === "text" && previous?.type === "text") return [...merged.slice(0, -1), { ...previous, value: previous.value + child.value }]
165
+ return [...merged, child]
166
+ }, [])
167
+ const first = children[0]
168
+ const last = children.at(-1)
169
+ const start = first?.type === "text" ? [{ ...first, value: first.value.replace(/^\s+/, "") }, ...children.slice(1)] : children
170
+ const trimmed = last?.type === "text" && start.length ? [...start.slice(0, -1), { ...start.at(-1), value: start.at(-1).value.replace(/\s+$/, "") }] : start
171
+ return trimmed.filter(child => child.type !== "text" || child.value)
172
+ }
173
+
174
+ /** @param {any} node @param {string} base @returns {any[]} */
175
+ function block(node, base) {
176
+ const tag = node.tagName
177
+ if (DROPPED.has(tag)) return []
178
+ if (tag === "p" || tag === "figcaption" || tag === "summary" || tag === "dt") return [paragraph(node.children ?? [], base)]
179
+ if (/^h[1-6]$/.test(tag)) return [{ type: "heading", depth: Number(tag[1]), children: trimPhrasing(phrasing(node.children ?? [], base)) }]
180
+ if (tag === "hr") return [{ type: "thematicBreak" }]
181
+ if (tag === "picture") return [paragraph([node], base)]
182
+ if (tag === "blockquote" || hasClass(node, "markdown-alert")) return [{ type: "blockquote", children: blocks(node.children ?? [], base) }]
183
+ if (tag === "pre") {
184
+ const code = (node.children ?? []).find(child => is(child, "code")) ?? node
185
+ const language = classes(code).find(name => name.startsWith("language-"))?.slice("language-".length)
186
+ return [{ type: "code", lang: language ?? null, value: toText(code).replace(/\n$/, "") }]
187
+ }
188
+ if (tag === "ul" || tag === "ol") return [list(node, base)]
189
+ if (tag === "table") return [table(node, base)]
190
+ if (isFootnoteSection(node)) return footnotes(node, base)
191
+ return blocks(node.children ?? [], base)
192
+ }
193
+
194
+ /** @param {any} node @param {string} base */
195
+ function list(node, base) {
196
+ const items = (node.children ?? []).filter(child => is(child, "li"))
197
+ const start = Number(node.properties?.start ?? 1)
198
+ return {
199
+ type: "list",
200
+ ordered: node.tagName === "ol",
201
+ start,
202
+ spread: false,
203
+ children: items.map(item => {
204
+ const checkbox = (item.children ?? []).find(child => is(child, "input") && child.properties?.type === "checkbox")
205
+ return {
206
+ type: "listItem",
207
+ spread: false,
208
+ checked: checkbox ? Boolean(checkbox.properties?.checked) : null,
209
+ children: blocks(item.children ?? [], base)
210
+ }
211
+ })
212
+ }
213
+ }
214
+
215
+ /** @param {any} node @param {string} base */
216
+ function table(node, base) {
217
+ const rows = []
218
+ const collect = (parent) => (parent.children ?? []).forEach(child => {
219
+ if (is(child, "tr")) rows.push(child)
220
+ else if (child.type === "element") collect(child)
221
+ })
222
+ collect(node)
223
+ return {
224
+ type: "table",
225
+ align: [],
226
+ children: rows.map(row => ({
227
+ type: "tableRow",
228
+ children: (row.children ?? []).filter(cell => is(cell, "td") || is(cell, "th")).map(cell => ({
229
+ type: "tableCell",
230
+ children: trimPhrasing(phrasing(cell.children ?? [], base))
231
+ }))
232
+ }))
233
+ }
234
+ }
235
+
236
+ /** The footnotes section, as definitions; the back-links dropped. @param {any} node @param {string} base */
237
+ function footnotes(node, base) {
238
+ const items = []
239
+ const collect = (parent) => (parent.children ?? []).forEach(child => {
240
+ if (is(child, "li")) items.push(child)
241
+ else if (child.type === "element") collect(child)
242
+ })
243
+ collect(node)
244
+ /** @param {any} parent @returns {any} */
245
+ const withoutBacklinks = (parent) => ({
246
+ ...parent,
247
+ children: (parent.children ?? [])
248
+ .filter(child => !(is(child, "a") && ("dataFootnoteBackref" in (child.properties ?? {}))))
249
+ .map(child => child.type === "element" ? withoutBacklinks(child) : child)
250
+ })
251
+ return items.map(item => {
252
+ const label = String(item.properties?.id ?? "").replace(/^user-content-fn-/, "")
253
+ return { type: "footnoteDefinition", identifier: label, label, children: blocks(withoutBacklinks(item).children ?? [], base) }
254
+ })
255
+ }
256
+
257
+ /**
258
+ * The body as simplified markdown.
259
+ * @param {any} section - the page's `<section id=content>` (entryContent.js)
260
+ * @param {string} pageURL - absolute; relative links resolve against it
261
+ * @returns {string}
262
+ */
263
+ function markdownBody(section, pageURL) {
264
+ if (!section) return ""
265
+ const tree = { type: "root", children: blocks(section.children ?? [], pageURL) }
266
+ return toMarkdown(tree, {
267
+ extensions: [gfmTableToMarkdown(), gfmStrikethroughToMarkdown(), gfmFootnoteToMarkdown(), gfmTaskListItemToMarkdown()],
268
+ bullet: "-",
269
+ emphasis: "*",
270
+ rule: "-",
271
+ fences: true
272
+ }).trim()
273
+ }
274
+
275
+ // ---------------------------------------------------------------------
276
+ // Rich text: plain text and facets.
277
+
278
+ /**
279
+ * The marks rich text can carry, by feature name. A lexicon's facet
280
+ * features are matched by the name after `#` (`app.bsky.richtext.facet#link`),
281
+ * so any facet lexicon shaped like Bluesky's works, and a feature the
282
+ * lexicon doesn't define is simply not emitted.
283
+ * @typedef {{ $type: string, uriProperty?: string, tagProperty?: string }} Feature
284
+ * @typedef {Partial<Record<"link" | "tag" | "bold" | "italic" | "code" | "strikethrough", Feature>>} Features
285
+ */
286
+
287
+ const MARKS = { strong: "bold", b: "bold", em: "italic", i: "italic", code: "code", del: "strikethrough", s: "strikethrough" }
288
+
289
+ /**
290
+ * @param {any} section
291
+ * @param {string} pageURL
292
+ * @param {Features} features
293
+ * @returns {{ text: string, spans: { start: number, end: number, feature: object }[] }}
294
+ * `start`/`end` are string indices; `facetsFor` turns them into bytes
295
+ * once the text is final.
296
+ */
297
+ function richTextBody(section, pageURL, features) {
298
+ const state = { text: "", spans: [] }
299
+
300
+ /** Separate what comes next from what came before. @param {string} separator */
301
+ const separate = (separator) => {
302
+ if (!state.text) return
303
+ const trimmed = state.text.replace(/[ \t]+$/, "")
304
+ state.text = trimmed.endsWith(separator) || (separator === "\n" && trimmed.endsWith("\n")) ? trimmed : trimmed.replace(/\n*$/, "") + separator
305
+ }
306
+
307
+ /** @param {string} value */
308
+ const append = (value) => {
309
+ const atLineStart = !state.text || state.text.endsWith("\n")
310
+ state.text += atLineStart ? value.replace(/^\s+/, "") : value
311
+ }
312
+
313
+ /** @param {number} start @param {object} feature */
314
+ const span = (start, feature) => {
315
+ if (state.text.length > start && feature) state.spans.push({ start, end: state.text.length, feature })
316
+ }
317
+
318
+ /** @param {any} node @param {boolean} [pre] */
319
+ const inline = (node, pre = false) => {
320
+ if (node.type === "text") return append(pre ? node.value : node.value.replace(/\s+/g, " "))
321
+ if (node.type !== "element" || DROPPED.has(node.tagName)) return
322
+ const tag = node.tagName
323
+ if (tag === "br") return append("\n")
324
+ if (tag === "img") return hasClass(node, "icon") ? append(iconText(node)) : undefined
325
+ if (tag === "picture" || tag === "input" || isFootnoteReference(node)) return
326
+ if (tag === "a") {
327
+ const listing = listingLink(node)
328
+ const start = state.text.length
329
+ if (listing) append(listing.title)
330
+ else (node.children ?? []).forEach(child => inline(child))
331
+ const href = listing?.href ?? node.properties?.href
332
+ if (href === undefined || href === null) return
333
+ if (hasClass(node, "hashtag") && features.tag) {
334
+ const tag = state.text.slice(start).replace(/^#/, "")
335
+ return span(start, { $type: features.tag.$type, [features.tag.tagProperty ?? "tag"]: tag })
336
+ }
337
+ if (features.link) span(start, { $type: features.link.$type, [features.link.uriProperty ?? "uri"]: absolute(String(href), pageURL) })
338
+ return
339
+ }
340
+ const mark = MARKS[tag]
341
+ const start = state.text.length
342
+ ;(node.children ?? []).forEach(child => inline(child, pre))
343
+ if (mark && features[mark]) span(start, { $type: features[mark].$type })
344
+ }
345
+
346
+ /** @param {any} node @param {number} depth */
347
+ const blockNode = (node, depth) => {
348
+ if (node.type === "text") return node.value.trim() ? inline(node) : undefined
349
+ if (node.type !== "element" || DROPPED.has(node.tagName) || isFootnoteSection(node)) return
350
+ const tag = node.tagName
351
+ if (!isBlock(node)) return inline(node)
352
+ if (tag === "hr" || tag === "picture") return separate("\n\n")
353
+ if (tag === "ul" || tag === "ol") {
354
+ const items = (node.children ?? []).filter(child => is(child, "li"))
355
+ if (depth === 0) separate("\n\n")
356
+ items.forEach((item, index) => {
357
+ separate("\n")
358
+ // Not append(): that strips the indentation at a line's start.
359
+ state.text += `${" ".repeat(depth)}${tag === "ol" ? `${Number(node.properties?.start ?? 1) + index}.` : "-"} `
360
+ ;(item.children ?? []).forEach(child => is(child, "ul") || is(child, "ol") ? blockNode(child, depth + 1) : isBlock(child) ? (child.children ?? []).forEach(inner => inline(inner)) : inline(child))
361
+ })
362
+ return separate("\n\n")
363
+ }
364
+ if (tag === "pre") {
365
+ separate("\n\n")
366
+ ;(node.children ?? []).forEach(child => inline(child, true))
367
+ state.text = state.text.replace(/\n+$/, "")
368
+ return separate("\n\n")
369
+ }
370
+ if (tag === "table") {
371
+ const rows = []
372
+ const collect = (parent) => (parent.children ?? []).forEach(child => is(child, "tr") ? rows.push(child) : child.type === "element" && collect(child))
373
+ collect(node)
374
+ separate("\n\n")
375
+ rows.forEach(row => {
376
+ separate("\n")
377
+ const cells = (row.children ?? []).filter(cell => is(cell, "td") || is(cell, "th"))
378
+ cells.forEach((cell, index) => {
379
+ if (index) state.text = state.text.replace(/\s+$/, "") + " | "
380
+ ;(cell.children ?? []).forEach(child => inline(child))
381
+ })
382
+ })
383
+ return separate("\n\n")
384
+ }
385
+ if (["p", "h1", "h2", "h3", "h4", "h5", "h6", "figcaption", "summary", "dt"].includes(tag)) {
386
+ separate("\n\n")
387
+ ;(node.children ?? []).forEach(child => inline(child))
388
+ return separate("\n\n")
389
+ }
390
+ ;(node.children ?? []).forEach(child => blockNode(child, depth))
391
+ }
392
+
393
+ ;(section?.children ?? []).forEach(child => blockNode(child, 0))
394
+ const text = state.text.replace(/\s+$/, "")
395
+ return { text, spans: state.spans.filter(span => span.start < text.length) }
396
+ }
397
+
398
+ /**
399
+ * Spans as facets over the final text, in UTF-8 bytes. A span the text
400
+ * was trimmed through is dropped rather than cut, so no facet points
401
+ * past the end.
402
+ * @param {string} text
403
+ * @param {{ start: number, end: number, feature: object }[]} spans
404
+ * @param {{ startProperty: string, endProperty: string }} index - the facet's byte-slice property names
405
+ */
406
+ function facetsFor(text, spans, index) {
407
+ const bytes = (end) => Buffer.byteLength(text.slice(0, end))
408
+ return spans
409
+ .filter(span => span.end <= text.length && span.end > span.start)
410
+ .map(span => ({ index: { [index.startProperty]: bytes(span.start), [index.endProperty]: bytes(span.end) }, features: [span.feature] }))
411
+ }
412
+
413
+ export { markdownBody, richTextBody, facetsFor }
@@ -0,0 +1,138 @@
1
+ import path from "node:path"
2
+ import { lexiconFor, publicationURI, recordURI, siteOrigin } from "./record.js"
3
+ import { validNSID } from "./lexicons.js"
4
+ import { publishToAtproto } from "./publish.js"
5
+ import { saveAppPassword } from "./password.js"
6
+
7
+ /** @import * as Votive from "votive" */
8
+
9
+ /**
10
+ * AT Protocol, phase one: the site's domain becomes the account's handle.
11
+ *
12
+ * An AT Protocol account is a DID (`did:plc:…`), and a domain becomes its
13
+ * handle when the domain proves it points at that DID - one of the two
14
+ * proofs is serving the DID as plain text at `/.well-known/atproto-did`.
15
+ * That is a file, so it is a stub: declared when settings.md says
16
+ *
17
+ * atproto_did: did:plc:abc123
18
+ *
19
+ * and gone, file and all, when it stops saying so. Deploying the site is
20
+ * then the verification. Nothing here talks to the network; the author
21
+ * pastes the DID from their account settings. Resolving it from a handle
22
+ * is a later phase, through readURL like any other fetch.
23
+ *
24
+ * Phase two: a page under an `atproto_lexicon:` gets an AT-URI, computed
25
+ * by `transformFile` with no network (record.js). Any published lexicon
26
+ * will do: vowel bundles none. Publishing is a command,
27
+ * `atproto-publish` (publish.js), which fetches each lexicon, fits the
28
+ * page to it (mapper.js) and sends what changed. A
29
+ * `site.standard.document` page also gets a
30
+ * `<link rel="site.standard.document">` from the html plugin.
31
+ *
32
+ * The publication: with a `domain` too, the site is a
33
+ * `site.standard.publication`, every document's `site` names it, and
34
+ * `/.well-known/site.standard.publication` serves its AT-URI - the
35
+ * domain's claim to it. The record is the publish command's.
36
+ */
37
+
38
+ const WELL_KNOWN = path.join(".well-known", "atproto-did")
39
+ const WELL_KNOWN_PUBLICATION = path.join(".well-known", "site.standard.publication")
40
+
41
+ /** A DID is `did:<method>:<id>`; that is all this checks. */
42
+ function validDID(value) {
43
+ return typeof value === "string" && /^did:[a-z]+:[A-Za-z0-9._:%-]+$/.test(value)
44
+ }
45
+
46
+ /**
47
+ * The account, from the nearest settings.md that names one. The atproto
48
+ * settings are flat, `atproto_did` / `atproto_lexicon` / `atproto_pds`,
49
+ * so each cascades on its own: a folder that sets only a lexicon keeps
50
+ * the root's account. Not reserved, so each arrives as `fm_atproto_*`.
51
+ * @param {{ lastNonNull: (label: string) => unknown }} settings
52
+ */
53
+ function atprotoDID(settings) {
54
+ return settings.lastNonNull("fm_atproto_did")
55
+ }
56
+
57
+ /**
58
+ * The page's AT-URI, stored as metadata for the html plugin's link and
59
+ * the publish command. Runs once per read of the page and reaches no
60
+ * network, so it can't know whether the page fits its lexicon - the
61
+ * publish command finds that out. A lexicon name that isn't an NSID is
62
+ * warned about here.
63
+ *
64
+ * Not for a page with `status: hidden`, which is listed nowhere, nor for
65
+ * one that isn't written (`html_file: false`) - except `status: deleted`,
66
+ * which is kept unwritten precisely so publish can delete its record.
67
+ */
68
+ function transformFile(target, { settings, config }) {
69
+ const status = target.metadata.fm_status
70
+ if (status === "hidden") return undefined
71
+ if (target.write === false && status !== "deleted") return undefined
72
+ const did = atprotoDID(settings)
73
+ if (!validDID(did)) return undefined
74
+ const origin = siteOrigin(settings.lastNonNull("fm_domain"))
75
+ if (!origin) return undefined
76
+
77
+ const lexicon = lexiconFor(target, settings)
78
+ if (!lexicon) return undefined
79
+ if (!validNSID(lexicon)) {
80
+ config.log("warn", `${target.path}: atproto_lexicon "${lexicon}" is not a lexicon name (an NSID, like app.bsky.feed.post)`)
81
+ return undefined
82
+ }
83
+ return { metadata: { atUri: recordURI(did, lexicon, origin, target.metadata.prettyURL) } }
84
+ }
85
+
86
+ /** @type {Votive.ProcessorStubs} */
87
+ function createStubs({ settings }) {
88
+ const did = atprotoDID(settings)
89
+ if (!did) return []
90
+ if (!validDID(did)) {
91
+ throw new Error(`settings.md: atproto_did must look like did:plc:… or did:web:…, not ${JSON.stringify(did)}`)
92
+ }
93
+ const origin = siteOrigin(settings.lastNonNull("fm_domain"))
94
+ const publication = origin ? [{ path: WELL_KNOWN_PUBLICATION, params: { text: publicationURI(did, origin) } }] : []
95
+ return [{ path: WELL_KNOWN, params: { text: did } }, ...publication]
96
+ }
97
+
98
+ /** @type {Votive.ProcessorExpand} */
99
+ function expandStubs({ params }) {
100
+ return { text: params.text }
101
+ }
102
+
103
+ /**
104
+ * Claims files with **no extension**, which is what a well-known file
105
+ * is, and `.publication`, which `site.standard.publication` looks like
106
+ * one to. The scan never hands this processor a real file - dot-folders
107
+ * are skipped - so only the stubs arrive; a file elsewhere in the project
108
+ * with either (a LICENSE) is read here and routed nowhere.
109
+ * @type {Votive.VotiveProcessor}
110
+ */
111
+ const wellKnownProcessor = {
112
+ extensions: ["", ".publication"],
113
+ format: "text",
114
+ router: ({ dir, name, ext }) => dir.includes(".well-known") ? { dir, name, ext } : false,
115
+ createStubs,
116
+ expandStubs,
117
+ readFile: (source) => ({ data: source.text, metadata: {} }),
118
+ writeFile: (target) => ({ data: target.data })
119
+ }
120
+
121
+ const recordProcessor = {
122
+ extensions: [".html"],
123
+ format: "text",
124
+ transformFile
125
+ }
126
+
127
+ /** @type {Votive.VotivePlugin} */
128
+ const vowelAtprotoPlugin = {
129
+ name: "vowel-atproto",
130
+ processors: [wellKnownProcessor, recordProcessor],
131
+ commands: {
132
+ "atproto-publish": publishToAtproto,
133
+ "atproto-password": saveAppPassword
134
+ }
135
+ }
136
+
137
+ export default vowelAtprotoPlugin
138
+ export { validDID, atprotoDID }