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 +2 -0
- package/extractDate.js +1 -1
- package/index.js +6 -1
- package/package.json +2 -2
- package/plugins/atproto/body.js +413 -0
- package/plugins/atproto/index.js +28 -22
- package/plugins/atproto/lexicons.js +179 -0
- package/plugins/atproto/mapper.js +399 -0
- package/plugins/atproto/publish.js +168 -103
- package/plugins/atproto/record.js +18 -72
- package/plugins/html/index.js +9 -3
- package/plugins/html/writeRules.js +5 -3
- package/plugins/markdown/frontmatter.js +17 -1
- package/plugins/markdown/index.js +20 -2
- package/plugins/secret-pages/index.js +75 -0
- package/plugins/xml/entryContent.js +22 -11
- package/plugins/xml/index.js +2 -1
- package/stylesheets/DefaultStyles.css +50 -16
- package/stylesheets/ResetStyles.css +2 -1
- package/stylesheets/TypographyStyles.css +12 -0
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|(
|
|
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
|
@@ -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 }
|
package/plugins/atproto/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
|
-
import {
|
|
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
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
-
|
|
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
|
|
67
|
-
|
|
68
|
-
if (
|
|
69
|
-
|
|
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:
|
|
83
|
+
return { metadata: { atUri: recordURI(did, lexicon, origin, target.metadata.prettyURL) } }
|
|
78
84
|
}
|
|
79
85
|
|
|
80
86
|
/** @type {Votive.ProcessorStubs} */
|