vowel 0.7.0-beta.0 → 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.
package/config.js CHANGED
@@ -15,6 +15,7 @@ import vowelWriteHTMLPlugin from "./plugins/html/index.js"
15
15
  import vowelAtprotoPlugin from "./plugins/atproto/index.js"
16
16
  import vowelPublishPlugin from "./plugins/publish/index.js"
17
17
  import vowelURLsPlugin from "./plugins/urls/index.js"
18
+ import vowelSecretPagesPlugin from "./plugins/secret-pages/index.js"
18
19
 
19
20
  /** @import {VotiveConfig} from "votive" */
20
21
 
@@ -45,6 +46,7 @@ const defaultPlugins = [
45
46
  vowelURLsPlugin,
46
47
  vowelAtprotoPlugin,
47
48
  vowelPublishPlugin,
49
+ vowelSecretPagesPlugin,
48
50
  vowelWriteHTMLPlugin
49
51
  ]
50
52
 
package/extractDate.js CHANGED
@@ -16,7 +16,7 @@ function monthIndex(month) {
16
16
  // Deliberately unanchored: a caller may want the date out of a longer
17
17
  // string. Use dateSpan() to tell "this text *is* a date" from "this text
18
18
  // mentions one".
19
- const DATE_PATTERN = /(?:\b(?<first_segment>(?<first_MMM_or_MMMM>January|February|March|April|May|June|July|August|September|October|November|December|Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sept|Sep|Oct|Nov|Dec)|(?<first_YYYY>\d\d\d\d)|(?<first_do>(?:\d|[0-3]\d)(?:st|nd|rd|th))|(?<first_dd_or_MM_or_yy>[0-3]\d)|(?<first_d_or_M>\d))(?<first_delimiter>\.| |, |\/|-)?)(?:\b(?<second_segment>(?<second_MMM_or_MMMM>January|February|March|April|May|June|July|August|September|October|November|December|Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sept|Sep|Oct|Nov|Dec)|(?<second_YYYY>\d\d\d\d)|(?<second_do>(\d\d)(:?st|nd|rd|th))|(?<second_dd_or_MM_or_yy>[0-3]\d)|(?<second_d_or_M>\d))(?<second_delimiter>\.| |, |\/|-)?)(?:\b(?<third_segment>(?<third_MMM_or_MMMM>January|February|March|April|May|June|July|August|September|October|November|December|Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sept|Sep|Oct|Nov|Dec)|(?<third_YYYY>\d\d\d\d)|(?<third_do>(\d\d)(?:st|nd|rd|th))|(?<third_dd_or_MM_or_yy>[0-3]\d)|(?<third_d_or_M>\d)))(?<time>(?<time_delimiter>T|\ | at )(?<H_or_HH>[0-2]?\d):(?<MM>[0-6]\d)(?::(?<SS>00))?(?:\.(?<SSS>\d\d\d))?(?<Z>Z|(?:\+(\d\d):(\d\d)))?(?<meridiam_indicator>am|pm)?)?/i
19
+ const DATE_PATTERN = /(?:\b(?<first_segment>(?<first_MMM_or_MMMM>January|February|March|April|May|June|July|August|September|October|November|December|Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sept|Sep|Oct|Nov|Dec)|(?<first_YYYY>\d\d\d\d)|(?<first_do>(?:\d|[0-3]\d)(?:st|nd|rd|th))|(?<first_dd_or_MM_or_yy>[0-3]\d)|(?<first_d_or_M>\d))(?<first_delimiter>\.| |, |\/|-)?)(?:\b(?<second_segment>(?<second_MMM_or_MMMM>January|February|March|April|May|June|July|August|September|October|November|December|Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sept|Sep|Oct|Nov|Dec)|(?<second_YYYY>\d\d\d\d)|(?<second_do>(\d\d)(:?st|nd|rd|th))|(?<second_dd_or_MM_or_yy>[0-3]\d)|(?<second_d_or_M>\d))(?<second_delimiter>\.| |, |\/|-)?)(?:\b(?<third_segment>(?<third_MMM_or_MMMM>January|February|March|April|May|June|July|August|September|October|November|December|Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sept|Sep|Oct|Nov|Dec)|(?<third_YYYY>\d\d\d\d)|(?<third_do>(\d\d)(?:st|nd|rd|th))|(?<third_dd_or_MM_or_yy>[0-3]\d)|(?<third_d_or_M>\d)))(?<time>(?<time_delimiter>T|\ | at )(?<H_or_HH>[0-2]?\d):(?<MM>[0-6]\d)(?::(?<SS>00))?(?:\.(?<SSS>\d\d\d))?(?<Z>Z|(?:[\+\-](\d\d):(\d\d)))?(?<meridiam_indicator>am|pm)?)?/i
20
20
 
21
21
  /**
22
22
  * The part of `text` that looks like a date, or undefined.
package/index.js CHANGED
@@ -12,10 +12,15 @@ import { createConfig } from "./config.js"
12
12
  * differ from the one requested; `close()` releases the watchers and the
13
13
  * listener, which a host serving more than one project over a session
14
14
  * needs before opening the next one.
15
+ *
16
+ * The dev server prints each secret page's url as it first sees it
17
+ * (plugins/secret-pages), which is how an author finds them. Only here:
18
+ * a build that deploys or publishes may be logged somewhere public.
19
+ * Pass `announceSecretPages: false` to turn it off.
15
20
  */
16
21
  async function init(options = {}) {
17
22
  const { sourceFolder, ...overrides } = options
18
- const config = createConfig(sourceFolder, overrides)
23
+ const config = createConfig(sourceFolder, { announceSecretPages: true, ...overrides })
19
24
  return startServer(config)
20
25
  }
21
26
 
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-beta.0",
5
5
  "engines": {
6
6
  "node": ">=26"
7
7
  },
@@ -112,4 +112,4 @@
112
112
  "!plugins/html/bundle.js",
113
113
  "!plugins/html/editorClient.js"
114
114
  ]
115
- }
115
+ }
@@ -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,6 @@
1
1
  import path from "node:path"
2
- import { pageRecord, publicationURI, siteOrigin } from "./record.js"
2
+ import { lexiconFor, publicationURI, recordURI, siteOrigin } from "./record.js"
3
+ import { validNSID } from "./lexicons.js"
3
4
  import { publishToAtproto } from "./publish.js"
4
5
  import { saveAppPassword } from "./password.js"
5
6
 
@@ -20,11 +21,13 @@ import { saveAppPassword } from "./password.js"
20
21
  * pastes the DID from their account settings. Resolving it from a handle
21
22
  * is a later phase, through readURL like any other fetch.
22
23
  *
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.
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.
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
@@ -52,29 +55,32 @@ function atprotoDID(settings) {
52
55
  }
53
56
 
54
57
  /**
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.
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.
60
67
  */
61
68
  function transformFile(target, { settings, config }) {
62
- if (target.write === false) return undefined
69
+ const status = target.metadata.fm_status
70
+ if (status === "hidden") return undefined
71
+ if (target.write === false && status !== "deleted") return undefined
63
72
  const did = atprotoDID(settings)
64
73
  if (!validDID(did)) return undefined
74
+ const origin = siteOrigin(settings.lastNonNull("fm_domain"))
75
+ if (!origin) return undefined
65
76
 
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}`)
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)`)
75
81
  return undefined
76
82
  }
77
- return { metadata: { atUri: result.atUri, atprotoRecord: result.record } }
83
+ return { metadata: { atUri: recordURI(did, lexicon, origin, target.metadata.prettyURL) } }
78
84
  }
79
85
 
80
86
  /** @type {Votive.ProcessorStubs} */