vowel 0.7.0-beta.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -239
  3. package/bin.js +73 -31
  4. package/cli.js +32 -0
  5. package/config.js +14 -0
  6. package/extractDate.js +2 -2
  7. package/index.js +6 -1
  8. package/package.json +9 -34
  9. package/pathDate.js +69 -0
  10. package/plugins/assets/index.js +1 -1
  11. package/plugins/atproto/body.js +413 -0
  12. package/plugins/atproto/index.js +10 -44
  13. package/plugins/atproto/lexicons.js +179 -0
  14. package/plugins/atproto/mapper.js +399 -0
  15. package/plugins/atproto/publish.js +195 -105
  16. package/plugins/atproto/record.js +57 -68
  17. package/plugins/deploy-cloudflare/index.js +4 -1
  18. package/plugins/export/index.js +131 -0
  19. package/plugins/html/bundle/index.js +57 -23
  20. package/plugins/html/editor/Editor.svelte +9 -5
  21. package/plugins/html/editor/create-session.js +3 -1
  22. package/plugins/html/editor/directives.js +12 -0
  23. package/plugins/html/editor/frontmatter.js +42 -11
  24. package/plugins/html/editor/ingest.js +19 -8
  25. package/plugins/html/editor/serialize.js +8 -4
  26. package/plugins/html/image.js +11 -5
  27. package/plugins/html/index.js +217 -100
  28. package/plugins/html/partials.js +14 -8
  29. package/plugins/html/socialLinks.js +5 -8
  30. package/plugins/html/writeRules.js +197 -37
  31. package/plugins/images/favicon.png +0 -0
  32. package/plugins/images/index.js +41 -3
  33. package/plugins/markdown/colorScheme.js +70 -14
  34. package/plugins/markdown/frontmatter.js +32 -6
  35. package/plugins/markdown/index.js +105 -20
  36. package/plugins/markdown/metadata.js +74 -22
  37. package/plugins/markdown/typography.js +11 -11
  38. package/plugins/secret-pages/index.js +75 -0
  39. package/plugins/styles/index.js +4 -4
  40. package/plugins/xml/entryContent.js +22 -11
  41. package/plugins/xml/index.js +19 -11
  42. package/stylesheets/DefaultStyles.css +198 -111
  43. package/stylesheets/ResetStyles.css +2 -1
  44. package/stylesheets/TypographyStyles.css +153 -74
  45. package/utils.js +74 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vowel",
3
3
  "type": "module",
4
- "version": "0.7.0-beta.0",
4
+ "version": "0.7.1",
5
5
  "engines": {
6
6
  "node": ">=26"
7
7
  },
@@ -32,26 +32,20 @@
32
32
  "@bomb.sh/tab": "^0.0.15",
33
33
  "@clack/prompts": "^1.4.0",
34
34
  "@jsdevtools/rehype-toc": "^3.0.2",
35
- "autoprefixer": "^10.4.27",
36
- "colorhorse": "^0.3.1",
37
- "cssnano": "^7.1.4",
35
+ "colorhorse": "^0.5.0",
38
36
  "emojilib": "^4.0.3",
39
- "encoding-sniffer": "^1.0.2",
40
37
  "fast-xml-parser": "^5.3.6",
41
- "faye-websocket": "^0.11.4",
42
38
  "github-slugger": "^2.0.0",
43
39
  "hast-util-from-html": "^2.0.3",
40
+ "hast-util-to-html": "^9.0.5",
44
41
  "hast-util-to-string": "^3.0.1",
45
42
  "hast-util-to-text": "^4.0.2",
46
43
  "hastscript": "^9.0.1",
47
- "highlight.js": "^11.12.0",
48
- "js-yaml": "^4.1.0",
49
44
  "lightningcss": "^1.33.0",
50
45
  "lowlight": "^3.3.0",
51
- "mdast": "^2.3.2",
52
- "mdast-normalize-headings": "^4.0.0",
53
46
  "mdast-util-from-markdown": "^2.0.2",
54
47
  "mdast-util-frontmatter": "^2.0.1",
48
+ "mdast-util-gfm-footnote": "^2.1.0",
55
49
  "mdast-util-gfm-strikethrough": "^2.0.0",
56
50
  "mdast-util-gfm-table": "^2.0.0",
57
51
  "mdast-util-gfm-task-list-item": "^2.0.0",
@@ -61,42 +55,22 @@
61
55
  "mdast-util-to-string": "^4.0.0",
62
56
  "micromark-extension-frontmatter": "^2.0.0",
63
57
  "micromark-extension-gfm-footnote": "^2.1.0",
58
+ "micromark-extension-gfm-strikethrough": "^2.1.0",
64
59
  "micromark-extension-gfm-table": "^2.1.1",
65
- "micromark-extension-gfm-task-list-item": "^2.1.0",
66
60
  "micromark-extension-highlight-mark": "^1.2.0",
67
- "postcss": "^8.5.9",
68
- "rehype": "^13.0.2",
69
- "rehype-document": "^7.0.3",
70
- "rehype-github-alert": "^1.0.0",
71
- "rehype-parse": "^9.0.1",
72
61
  "rehype-preset-minify": "^7.0.1",
73
62
  "rehype-slug": "^6.0.0",
74
63
  "rehype-stringify": "^10.0.1",
75
- "remark": "^15.0.1",
76
- "remark-frontmatter": "^5.0.0",
77
- "remark-gfm": "^4.0.1",
78
- "remark-html": "^16.0.1",
79
- "remark-parse": "^11.0.0",
80
- "remark-rehype": "^11.1.2",
81
64
  "rollup-plugin-import-css": "^4.2.1",
82
65
  "sharp": "^0.35.3",
83
66
  "svedit": "^0.14.0",
84
67
  "unified": "^11.0.5",
85
- "unist-util-find": "^3.0.0",
86
- "unist-util-is": "^6.0.0",
87
- "unist-util-position": "5.0.0",
88
- "unist-util-remove": "^4.0.0",
89
- "unist-util-stringify-position": "^4.0.0",
90
68
  "unist-util-visit": "^5.0.0",
91
- "unist-util-visit-parents": "^6.0.0",
92
69
  "unpic": "^4.2.2",
93
70
  "url-metadata": "^5.4.1",
94
- "votive": "0.5.0-beta.0",
95
- "xast-util-sitemap": "^2.0.0",
96
- "xast-util-to-xml": "^4.0.0",
71
+ "votive": "0.5.2",
97
72
  "xml": "^1.0.1",
98
- "yaml": "^2.8.2",
99
- "yamljs": "^0.3.0"
73
+ "yaml": "^2.8.2"
100
74
  },
101
75
  "files": [
102
76
  "bin.js",
@@ -104,6 +78,7 @@
104
78
  "config.js",
105
79
  "extractDate.js",
106
80
  "index.js",
81
+ "pathDate.js",
107
82
  "secretPaths.js",
108
83
  "systemPaths.js",
109
84
  "utils.js",
@@ -112,4 +87,4 @@
112
87
  "!plugins/html/bundle.js",
113
88
  "!plugins/html/editorClient.js"
114
89
  ]
115
- }
90
+ }
package/pathDate.js ADDED
@@ -0,0 +1,69 @@
1
+ import path from "node:path"
2
+ import { monthIndex } from "./extractDate.js"
3
+
4
+ // Strict on purpose, and separate from extractDate's pattern: that one is
5
+ // unanchored and reads day-first orders, so on "2023/03/2023-03-24" it
6
+ // matches "2023/03/2023". A path is a guess the author never wrote as a
7
+ // date, so only the year-month-day order counts, each part a whole
8
+ // segment.
9
+ const YEAR = /^\d{4}$/
10
+ const MONTH = /^(?:\d{1,2}|jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t|tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)$/i
11
+ const DAY = /^\d{1,2}$/
12
+ const NAME_PREFIX = /^(\d{4})-(\d{1,2})-(\d{1,2})(?!\d)/
13
+
14
+ /**
15
+ * @param {string} year
16
+ * @param {string} month - a number or a month name
17
+ * @param {string} day
18
+ */
19
+ function calendarDate(year, month, day) {
20
+ const yearNumber = Number(year)
21
+ const monthNumber = monthIndex(month)
22
+ const dayNumber = Number(day)
23
+ const date = new Date(Date.UTC(yearNumber, monthNumber, dayNumber))
24
+
25
+ // Date.UTC rolls 30 February into March and month 13 into next year,
26
+ // and maps years 0-99 to 1900-1999; any of those is not the date the
27
+ // path spelled.
28
+ const rolled =
29
+ date.getUTCFullYear() !== yearNumber ||
30
+ date.getUTCMonth() !== monthNumber ||
31
+ date.getUTCDate() !== dayNumber
32
+
33
+ if (rolled) return
34
+ return date
35
+ }
36
+
37
+ /**
38
+ * @param {string} year
39
+ * @param {string} month
40
+ * @param {string} day
41
+ */
42
+ function segmentsDate(year, month, day) {
43
+ const isDate = YEAR.test(year) && MONTH.test(month) && DAY.test(day)
44
+ if (!isDate) return
45
+ return calendarDate(year, month, day)
46
+ }
47
+
48
+ /**
49
+ * The date a source path spells, or undefined: a `YYYY-MM-DD` filename
50
+ * prefix (`blog/2024/2024-11-03-sixty.md`), else a year, month and day in
51
+ * neighbouring segments (`2023/04/07/hello-world.md`,
52
+ * `journal/2024/april/1.md`), the deepest run winning. A secret segment's
53
+ * salt should already be stripped (displayPath).
54
+ * @param {string} sourcePath - project-relative
55
+ */
56
+ function pathDate(sourcePath) {
57
+ const { dir, name } = path.parse(sourcePath)
58
+
59
+ const prefix = NAME_PREFIX.exec(name)
60
+ const nameDate = prefix ? calendarDate(prefix[1], prefix[2], prefix[3]) : undefined
61
+ if (nameDate) return nameDate
62
+
63
+ const segments = [...dir.split(path.sep), name]
64
+ const dates = segments.slice(0, -2).map((year, index) => segmentsDate(year, segments[index + 1], segments[index + 2]))
65
+ return dates.findLast(Boolean)
66
+ }
67
+
68
+ export default pathDate
69
+ export { pathDate }
@@ -20,7 +20,7 @@ import path from "node:path"
20
20
  * to be there; adding an extension is adding it here.
21
21
  */
22
22
 
23
- const EXTENSIONS = [".mp4", ".webm", ".csv", ".json", ".pdf"]
23
+ const EXTENSIONS = [".mp4", ".webm", ".csv", ".json", ".pdf", ".ico"]
24
24
 
25
25
  /** 25 MiB: Cloudflare Pages' per-file limit. */
26
26
  const DEFAULT_SIZE_LIMIT = 25 * 1024 * 1024
@@ -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 }
@@ -1,5 +1,5 @@
1
1
  import path from "node:path"
2
- import { pageRecord, publicationURI, siteOrigin } from "./record.js"
2
+ import { publicationURI, siteOrigin, validDID } from "./record.js"
3
3
  import { publishToAtproto } from "./publish.js"
4
4
  import { saveAppPassword } from "./password.js"
5
5
 
@@ -20,11 +20,14 @@ import { saveAppPassword } from "./password.js"
20
20
  * pastes the DID from their account settings. Resolving it from a handle
21
21
  * is a later phase, through readURL like any other fetch.
22
22
  *
23
- * Phase two: a page under an `atproto_lexicon:` gets an AT-URI and a record,
24
- * computed by `transformFile` with no network (record.js), and a
25
- * `<link rel="site.standard.document">` from the html plugin. Publishing
26
- * is a command, `atproto-publish` (publish.js), which makes the account's
27
- * records match the site. See tasks/2-in-progress/atproto-integration.md.
23
+ * Phase two: a page under an `atproto_lexicon:` has an AT-URI, a function
24
+ * of the page and its settings with no network (record.js's
25
+ * `pageRecordURI`, worked out at write and at publish). 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.
28
31
  *
29
32
  * The publication: with a `domain` too, the site is a
30
33
  * `site.standard.publication`, every document's `site` names it, and
@@ -35,11 +38,6 @@ import { saveAppPassword } from "./password.js"
35
38
  const WELL_KNOWN = path.join(".well-known", "atproto-did")
36
39
  const WELL_KNOWN_PUBLICATION = path.join(".well-known", "site.standard.publication")
37
40
 
38
- /** A DID is `did:<method>:<id>`; that is all this checks. */
39
- function validDID(value) {
40
- return typeof value === "string" && /^did:[a-z]+:[A-Za-z0-9._:%-]+$/.test(value)
41
- }
42
-
43
41
  /**
44
42
  * The account, from the nearest settings.md that names one. The atproto
45
43
  * settings are flat, `atproto_did` / `atproto_lexicon` / `atproto_pds`,
@@ -51,32 +49,6 @@ function atprotoDID(settings) {
51
49
  return settings.lastNonNull("fm_atproto_did")
52
50
  }
53
51
 
54
- /**
55
- * The page's AT-URI and record, stored as metadata for the html plugin's
56
- * link and the publish command. Runs once per read of the page; nothing
57
- * here reaches the network. A page missing a property its lexicon
58
- * requires is not a record (mentioned under `verbose`); an unsupported
59
- * lexicon is warned about.
60
- */
61
- function transformFile(target, { settings, config }) {
62
- if (target.write === false) return undefined
63
- const did = atprotoDID(settings)
64
- if (!validDID(did)) return undefined
65
-
66
- const domain = settings.lastNonNull("fm_domain")
67
- const result = pageRecord(target, { did, domain, settings })
68
- if ("skip" in result) return undefined
69
- if ("incomplete" in result) {
70
- config.log("info", `${target.path}: ${result.incomplete}`)
71
- return undefined
72
- }
73
- if ("warning" in result) {
74
- config.log("warn", `${target.path}: ${result.warning}`)
75
- return undefined
76
- }
77
- return { metadata: { atUri: result.atUri, atprotoRecord: result.record } }
78
- }
79
-
80
52
  /** @type {Votive.ProcessorStubs} */
81
53
  function createStubs({ settings }) {
82
54
  const did = atprotoDID(settings)
@@ -112,16 +84,10 @@ const wellKnownProcessor = {
112
84
  writeFile: (target) => ({ data: target.data })
113
85
  }
114
86
 
115
- const recordProcessor = {
116
- extensions: [".html"],
117
- format: "text",
118
- transformFile
119
- }
120
-
121
87
  /** @type {Votive.VotivePlugin} */
122
88
  const vowelAtprotoPlugin = {
123
89
  name: "vowel-atproto",
124
- processors: [wellKnownProcessor, recordProcessor],
90
+ processors: [wellKnownProcessor],
125
91
  commands: {
126
92
  "atproto-publish": publishToAtproto,
127
93
  "atproto-password": saveAppPassword