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.
- package/LICENSE +21 -0
- package/README.md +41 -239
- package/bin.js +73 -31
- package/cli.js +32 -0
- package/config.js +14 -0
- package/extractDate.js +2 -2
- package/index.js +6 -1
- package/package.json +9 -34
- package/pathDate.js +69 -0
- package/plugins/assets/index.js +1 -1
- package/plugins/atproto/body.js +413 -0
- package/plugins/atproto/index.js +10 -44
- package/plugins/atproto/lexicons.js +179 -0
- package/plugins/atproto/mapper.js +399 -0
- package/plugins/atproto/publish.js +195 -105
- package/plugins/atproto/record.js +57 -68
- package/plugins/deploy-cloudflare/index.js +4 -1
- package/plugins/export/index.js +131 -0
- package/plugins/html/bundle/index.js +57 -23
- package/plugins/html/editor/Editor.svelte +9 -5
- package/plugins/html/editor/create-session.js +3 -1
- package/plugins/html/editor/directives.js +12 -0
- package/plugins/html/editor/frontmatter.js +42 -11
- package/plugins/html/editor/ingest.js +19 -8
- package/plugins/html/editor/serialize.js +8 -4
- package/plugins/html/image.js +11 -5
- package/plugins/html/index.js +217 -100
- package/plugins/html/partials.js +14 -8
- package/plugins/html/socialLinks.js +5 -8
- package/plugins/html/writeRules.js +197 -37
- package/plugins/images/favicon.png +0 -0
- package/plugins/images/index.js +41 -3
- package/plugins/markdown/colorScheme.js +70 -14
- package/plugins/markdown/frontmatter.js +32 -6
- package/plugins/markdown/index.js +105 -20
- package/plugins/markdown/metadata.js +74 -22
- package/plugins/markdown/typography.js +11 -11
- package/plugins/secret-pages/index.js +75 -0
- package/plugins/styles/index.js +4 -4
- package/plugins/xml/entryContent.js +22 -11
- package/plugins/xml/index.js +19 -11
- package/stylesheets/DefaultStyles.css +198 -111
- package/stylesheets/ResetStyles.css +2 -1
- package/stylesheets/TypographyStyles.css +153 -74
- 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.
|
|
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
|
-
"
|
|
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.
|
|
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 }
|
package/plugins/assets/index.js
CHANGED
|
@@ -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 }
|
package/plugins/atproto/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from "node:path"
|
|
2
|
-
import {
|
|
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:`
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
|
90
|
+
processors: [wellKnownProcessor],
|
|
125
91
|
commands: {
|
|
126
92
|
"atproto-publish": publishToAtproto,
|
|
127
93
|
"atproto-password": saveAppPassword
|