vowel 0.7.0-beta.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -239
  3. package/bin.js +73 -31
  4. package/cli.js +32 -0
  5. package/config.js +14 -0
  6. package/extractDate.js +2 -2
  7. package/index.js +6 -1
  8. package/package.json +9 -34
  9. package/pathDate.js +69 -0
  10. package/plugins/assets/index.js +1 -1
  11. package/plugins/atproto/body.js +413 -0
  12. package/plugins/atproto/index.js +10 -44
  13. package/plugins/atproto/lexicons.js +179 -0
  14. package/plugins/atproto/mapper.js +399 -0
  15. package/plugins/atproto/publish.js +195 -105
  16. package/plugins/atproto/record.js +57 -68
  17. package/plugins/deploy-cloudflare/index.js +4 -1
  18. package/plugins/export/index.js +131 -0
  19. package/plugins/html/bundle/index.js +57 -23
  20. package/plugins/html/editor/Editor.svelte +9 -5
  21. package/plugins/html/editor/create-session.js +3 -1
  22. package/plugins/html/editor/directives.js +12 -0
  23. package/plugins/html/editor/frontmatter.js +42 -11
  24. package/plugins/html/editor/ingest.js +19 -8
  25. package/plugins/html/editor/serialize.js +8 -4
  26. package/plugins/html/image.js +11 -5
  27. package/plugins/html/index.js +217 -100
  28. package/plugins/html/partials.js +14 -8
  29. package/plugins/html/socialLinks.js +5 -8
  30. package/plugins/html/writeRules.js +197 -37
  31. package/plugins/images/favicon.png +0 -0
  32. package/plugins/images/index.js +41 -3
  33. package/plugins/markdown/colorScheme.js +70 -14
  34. package/plugins/markdown/frontmatter.js +32 -6
  35. package/plugins/markdown/index.js +105 -20
  36. package/plugins/markdown/metadata.js +74 -22
  37. package/plugins/markdown/typography.js +11 -11
  38. package/plugins/secret-pages/index.js +75 -0
  39. package/plugins/styles/index.js +4 -4
  40. package/plugins/xml/entryContent.js +22 -11
  41. package/plugins/xml/index.js +19 -11
  42. package/stylesheets/DefaultStyles.css +198 -111
  43. package/stylesheets/ResetStyles.css +2 -1
  44. package/stylesheets/TypographyStyles.css +153 -74
  45. package/utils.js +74 -1
@@ -0,0 +1,179 @@
1
+ import { promises as dns } from "node:dns"
2
+
3
+ /**
4
+ * Lexicons, fetched from the Atmosphere. Vowel contains none: it knows
5
+ * no schema and has no code for any one lexicon. A lexicon is resolved
6
+ * the way atproto resolves one, and interpreted by mapper.js.
7
+ *
8
+ * 1. The NSID's authority, reversed, names a DNS TXT record:
9
+ * `app.bsky.feed.post` → `_lexicon.feed.bsky.app`, holding `did=…`.
10
+ * 2. The DID document names the account's PDS.
11
+ * 3. The PDS holds the schema as a `com.atproto.lexicon.schema` record
12
+ * whose key is the NSID.
13
+ *
14
+ * A lexicon that doesn't resolve is not supported; there is no bundled
15
+ * fallback. Schemas are cached in memory for the life of the process -
16
+ * they are fetched only by atproto-publish, and a published lexicon may
17
+ * only change in ways that keep old records valid.
18
+ */
19
+
20
+ const TIMEOUT = 30_000
21
+ const SCHEMA_COLLECTION = "com.atproto.lexicon.schema"
22
+
23
+ /**
24
+ * An NSID: a reversed domain authority, then a name. Checked before
25
+ * anything is looked up, so a typo in `atproto_lexicon` is reported as
26
+ * one rather than as a DNS failure.
27
+ */
28
+ const NSID = /^[a-zA-Z](?:[a-zA-Z0-9-]{0,62})?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,62})?)+\.[a-zA-Z][a-zA-Z0-9]{0,62}$/
29
+
30
+ /** @param {unknown} value */
31
+ function validNSID(value) {
32
+ return typeof value === "string" && value.length <= 317 && NSID.test(value)
33
+ }
34
+
35
+ /**
36
+ * The DNS name holding an NSID's authority: every segment but the last,
37
+ * reversed, under `_lexicon`.
38
+ * @param {string} nsid
39
+ */
40
+ function authorityName(nsid) {
41
+ const segments = nsid.split(".")
42
+ return `_lexicon.${segments.slice(0, -1).reverse().join(".")}`
43
+ }
44
+
45
+ /**
46
+ * The PDS that hosts `did`, from its DID document. `did:plc` is resolved
47
+ * through the PLC directory, `did:web` through its own host.
48
+ * @param {string} did
49
+ * @param {{ plcDirectory?: string, fetch?: typeof fetch }} [options]
50
+ */
51
+ async function resolvePDS(did, { plcDirectory = "https://plc.directory", fetch = globalThis.fetch } = {}) {
52
+ const documentURL = did.startsWith("did:plc:")
53
+ // A leading slash: a bare `did:plc:…` would parse as its own scheme.
54
+ ? new URL(`/${did}`, plcDirectory)
55
+ : did.startsWith("did:web:")
56
+ ? `https://${decodeURIComponent(did.slice("did:web:".length))}/.well-known/did.json`
57
+ : undefined
58
+ if (!documentURL) throw new Error(`atproto: can't resolve ${did}; set atproto_pds in settings.md`)
59
+
60
+ const response = await fetch(documentURL, { signal: AbortSignal.timeout(TIMEOUT) })
61
+ if (!response.ok) throw new Error(`atproto: resolving ${did} failed: ${response.status}`)
62
+ const document = await response.json()
63
+ const service = (document.service ?? []).find(entry => entry.id === "#atproto_pds" || entry.id === `${did}#atproto_pds`)
64
+ if (!service) throw new Error(`atproto: ${did} names no PDS`)
65
+ return service.serviceEndpoint
66
+ }
67
+
68
+ /**
69
+ * A ref as `[nsid, def]`: `#x` is local to `from`, `a.b.c#x` names
70
+ * another lexicon's def, and a bare NSID is its `main`.
71
+ * @param {string} ref
72
+ * @param {string} from - the NSID the ref appears in
73
+ * @returns {[string, string]}
74
+ */
75
+ function splitRef(ref, from) {
76
+ if (ref.startsWith("#")) return [from, ref.slice(1)]
77
+ const [nsid, name = "main"] = ref.split("#")
78
+ return [nsid, name]
79
+ }
80
+
81
+ /**
82
+ * The refs a schema node points at, as `[nsid, def]` pairs: a ref, a
83
+ * union's members, and whatever an array's items or an object's
84
+ * properties point at.
85
+ * @param {any} node
86
+ * @param {string} from
87
+ * @returns {[string, string][]}
88
+ */
89
+ function refsOf(node, from) {
90
+ if (!node || typeof node !== "object") return []
91
+ if (node.type === "ref") return [splitRef(node.ref, from)]
92
+ if (node.type === "union") return (node.refs ?? []).map(ref => splitRef(ref, from))
93
+ if (node.type === "array") return refsOf(node.items, from)
94
+ if (node.type === "record") return refsOf(node.record, from)
95
+ if (node.type === "object") return Object.values(node.properties ?? {}).flatMap(property => refsOf(property, from))
96
+ return []
97
+ }
98
+
99
+ /**
100
+ * @typedef {{ lexicon: number, id: string, defs: Record<string, any> }} LexiconDoc
101
+ * @typedef {{ lexicons: Map<string, LexiconDoc | null>, errors: Map<string, string> }} Loaded
102
+ */
103
+
104
+ /**
105
+ * A resolver with its own cache. `resolveTxt`, `plcDirectory` and
106
+ * `fetch` are the three ways out to the network, so a test can stand in
107
+ * for all of them.
108
+ * @param {{ resolveTxt?: (name: string) => Promise<string[][]>, plcDirectory?: string, fetch?: typeof fetch }} [options]
109
+ */
110
+ function createLexiconResolver({ resolveTxt = dns.resolveTxt, plcDirectory, fetch = globalThis.fetch } = {}) {
111
+ /** @type {Map<string, Promise<{ doc: LexiconDoc | null, error?: string }>>} */
112
+ const cache = new Map()
113
+
114
+ /** @param {string} nsid */
115
+ async function fetchLexicon(nsid) {
116
+ if (!validNSID(nsid)) return { doc: null, error: `"${nsid}" is not a lexicon name (an NSID, like app.bsky.feed.post)` }
117
+ const name = authorityName(nsid)
118
+ const records = await resolveTxt(name).catch(() => [])
119
+ const did = records.map(chunks => chunks.join("")).find(text => text.startsWith("did="))?.slice("did=".length)
120
+ if (!did) return { doc: null, error: `${nsid} is not published (no DNS record at ${name})` }
121
+
122
+ const pds = await resolvePDS(did, { plcDirectory, fetch })
123
+ const url = new URL("/xrpc/com.atproto.repo.getRecord", pds)
124
+ url.search = new URLSearchParams({ repo: did, collection: SCHEMA_COLLECTION, rkey: nsid }).toString()
125
+ const response = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT) })
126
+ if (!response.ok) return { doc: null, error: `${nsid} is not published (${did} has no schema for it: ${response.status})` }
127
+ const doc = (await response.json()).value
128
+ if (doc?.id !== nsid || !doc.defs) return { doc: null, error: `${nsid}: the schema ${did} publishes is not a lexicon for it` }
129
+ return { doc }
130
+ }
131
+
132
+ /**
133
+ * One lexicon, fetched once per process. A failure is cached too, as
134
+ * an error string; a thrown network error is caught into one.
135
+ * @param {string} nsid
136
+ */
137
+ function lexicon(nsid) {
138
+ if (!cache.has(nsid)) {
139
+ cache.set(nsid, fetchLexicon(nsid).catch(error => ({ doc: null, error: `${nsid}: ${error.message}` })))
140
+ }
141
+ return cache.get(nsid)
142
+ }
143
+
144
+ /**
145
+ * A lexicon and every lexicon its record can reach through refs and
146
+ * unions, fetched a level at a time. An unreachable one is `null`,
147
+ * with its error; the mapper then can't check what it would have
148
+ * described.
149
+ * @param {string[]} roots - NSIDs whose `main` def to start from
150
+ * @returns {Promise<Loaded>}
151
+ */
152
+ async function load(roots) {
153
+ const lexicons = new Map()
154
+ const errors = new Map()
155
+ const visited = new Set()
156
+
157
+ /** @param {[string, string][]} pending */
158
+ async function crawl(pending) {
159
+ const fresh = pending.filter(([nsid, name]) => !visited.has(`${nsid}#${name}`))
160
+ if (!fresh.length) return
161
+ fresh.forEach(([nsid, name]) => visited.add(`${nsid}#${name}`))
162
+ const nsids = [...new Set(fresh.map(([nsid]) => nsid))].filter(nsid => !lexicons.has(nsid))
163
+ const results = await Promise.all(nsids.map(nsid => lexicon(nsid)))
164
+ nsids.forEach((nsid, index) => {
165
+ lexicons.set(nsid, results[index].doc)
166
+ if (results[index].error) errors.set(nsid, results[index].error)
167
+ })
168
+ const next = fresh.flatMap(([nsid, name]) => refsOf(lexicons.get(nsid)?.defs?.[name], nsid))
169
+ await crawl(next)
170
+ }
171
+
172
+ await crawl(roots.map(nsid => [nsid, "main"]))
173
+ return { lexicons, errors }
174
+ }
175
+
176
+ return { lexicon, load }
177
+ }
178
+
179
+ export { createLexiconResolver, resolvePDS, splitRef, validNSID, authorityName }
@@ -0,0 +1,399 @@
1
+ import { splitRef } from "./lexicons.js"
2
+ import { facetsFor } from "./body.js"
3
+
4
+ /**
5
+ * A page as a record of a lexicon vowel has never seen: the schema is
6
+ * fetched (lexicons.js), and every value is found, converted and checked
7
+ * against it here. Nothing in this file names a lexicon, except the
8
+ * standard.site rule below.
9
+ *
10
+ * **Where a value comes from**, for each property of the record:
11
+ *
12
+ * 1. The page's frontmatter, by name: `startsAt` or `starts_at` - the
13
+ * camelCase key wins when both are written. Nested YAML is a nested
14
+ * object, found the same way. Keys vowel reads for itself (`status`,
15
+ * `tags`, `atproto_lexicon`, … - frontmatter.js's EXPECTED) are never
16
+ * mapped by name.
17
+ * 2. An alias, on the record's own properties only:
18
+ * - `title` and `name` ← the page's title
19
+ * - `description` ← its description, and nothing else ever is
20
+ * - `tags` ← its tags, frontmatter and hashtags
21
+ * - `image` ← its `image`, and only a property named `image`
22
+ * - `publishedAt` ← its date; else `createdAt` ← its date
23
+ * - the body ← the first of `content` and `text` the record has
24
+ * 3. For a `site.standard.*` record, `site` and `path` as standard.site
25
+ * defines them: the publication's AT-URI and the page's path.
26
+ *
27
+ * **What happens to it**: it is converted to the property's type (a
28
+ * date to a datetime, a root-relative path to a URL against `domain`),
29
+ * trimmed at a word boundary when text is too long, cut when a list is,
30
+ * and checked. A required property with no value, or one that fails,
31
+ * means the page is not published, with a warning naming the lexicon
32
+ * and the property. An optional one that fails is left out, with a
33
+ * warning.
34
+ */
35
+
36
+ const ALIAS_KEYS = new Set(["title", "name", "description", "tags", "image", "publishedAt", "createdAt"])
37
+ const BODY_KEYS = ["content", "text"]
38
+ const MARKDOWN_SLOTS = ["markdown", "content", "text"]
39
+
40
+ /**
41
+ * @typedef {Map<string, { defs: Record<string, any> } | null>} Lexicons
42
+ * @typedef {{
43
+ * title?: string, description?: string, date?: string, image?: string, tags?: string[],
44
+ * frontmatter: Record<string, unknown>,
45
+ * markdown: () => string,
46
+ * richText: (features: import("./body.js").Features) => { text: string, spans: any[] }
47
+ * }} Page
48
+ * @typedef {{ origin: string, pageURL: string, path: string, publication?: string, contentLexicon: string }} Context
49
+ */
50
+
51
+ /** `startsAt` → `starts_at`. @param {string} name */
52
+ const snake = (name) => name.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`)
53
+
54
+ /** @param {unknown} value */
55
+ const isPlainObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value)
56
+
57
+ /**
58
+ * A property's value in a frontmatter object: its own name, else its
59
+ * snake_case spelling.
60
+ * @param {Record<string, unknown>} source
61
+ * @param {string} name
62
+ */
63
+ function byName(source, name) {
64
+ if (name in source) return source[name]
65
+ const spelled = snake(name)
66
+ return spelled !== name && spelled in source ? source[spelled] : undefined
67
+ }
68
+
69
+ /**
70
+ * A def by ref, a record def standing for its record object.
71
+ * @param {Lexicons} lexicons
72
+ * @param {string} ref
73
+ * @param {string} from
74
+ */
75
+ function resolveDef(lexicons, ref, from) {
76
+ const [nsid, name] = splitRef(ref, from)
77
+ const def = lexicons.get(nsid)?.defs?.[name]
78
+ if (!def) return undefined
79
+ return { nsid, name, id: name === "main" ? nsid : `${nsid}#${name}`, def: def.type === "record" ? def.record : def }
80
+ }
81
+
82
+ /** @param {string} ref @param {string} from */
83
+ function fullRef(ref, from) {
84
+ const [nsid, name] = splitRef(ref, from)
85
+ return name === "main" ? nsid : `${nsid}#${name}`
86
+ }
87
+
88
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" })
89
+
90
+ /**
91
+ * The longest start of `text` within both limits, cut back to a word
92
+ * boundary; one word longer than the limit is cut at a grapheme.
93
+ * @param {string} text
94
+ * @param {number} [maxGraphemes]
95
+ * @param {number} [maxBytes]
96
+ */
97
+ function trimWords(text, maxGraphemes = Infinity, maxBytes = Infinity) {
98
+ const graphemes = [...segmenter.segment(text)].map(segment => segment.segment)
99
+ if (graphemes.length <= maxGraphemes && Buffer.byteLength(text) <= maxBytes) return text
100
+ const { fits } = graphemes.reduce(({ fits, bytes, done }, grapheme, index) => {
101
+ if (done) return { fits, bytes, done }
102
+ const next = bytes + Buffer.byteLength(grapheme)
103
+ if (index + 1 > maxGraphemes || next > maxBytes) return { fits, bytes, done: true }
104
+ return { fits: fits + grapheme, bytes: next, done: false }
105
+ }, { fits: "", bytes: 0, done: false })
106
+ const boundary = fits.search(/\s+\S*$/)
107
+ const cut = boundary > 0 ? fits.slice(0, boundary) : fits
108
+ return cut.replace(/\s+$/, "")
109
+ }
110
+
111
+ const FORMATS = {
112
+ datetime: (value) => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.test(value),
113
+ uri: (value) => /^[a-z][a-z0-9+.-]*:\S+$/i.test(value),
114
+ "at-uri": (value) => /^at:\/\/\S+$/.test(value),
115
+ did: (value) => /^did:[a-z]+:[A-Za-z0-9._:%-]+$/.test(value),
116
+ handle: (value) => /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]*$/i.test(value),
117
+ "at-identifier": (value) => FORMATS.did(value) || FORMATS.handle(value),
118
+ language: (value) => /^[a-z]{2,3}(-[a-z0-9]{1,8})*$/i.test(value),
119
+ nsid: (value) => /^[a-z][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)+\.[a-z][a-z0-9]*$/i.test(value)
120
+ }
121
+
122
+ /**
123
+ * A value converted to a string property's format: a Date or a date
124
+ * string to a datetime, a root-relative path to a URL.
125
+ * @param {string} value
126
+ * @param {string | undefined} format
127
+ * @param {Context} context
128
+ */
129
+ function toFormat(value, format, context) {
130
+ // A time without seconds is the author's time, offset and all: add the
131
+ // seconds atproto requires rather than moving it to UTC.
132
+ const minutes = format === "datetime" ? value.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})(Z|[+-]\d{2}:\d{2})$/) : null
133
+ if (minutes) return `${minutes[1]}:00${minutes[2]}`
134
+ if (format === "datetime" && !FORMATS.datetime(value)) {
135
+ const date = new Date(value)
136
+ return Number.isNaN(date.getTime()) ? value : date.toISOString()
137
+ }
138
+ if (format === "uri" && value.startsWith("/")) return new URL(value, context.pageURL).href
139
+ return value
140
+ }
141
+
142
+ /**
143
+ * @typedef {{ ok: true, value: unknown, warnings?: string[] } | { ok: false, reason: string }} Fit
144
+ */
145
+
146
+ /**
147
+ * A value fitted to a schema node: converted, trimmed and checked.
148
+ * @param {unknown} value
149
+ * @param {any} schema
150
+ * @param {string} from - the NSID local refs resolve in
151
+ * @param {{ lexicons: Lexicons, context: Context }} env
152
+ * @returns {Fit}
153
+ */
154
+ function fit(value, schema, from, env) {
155
+ const type = schema?.type
156
+ if (value === undefined || value === null) return { ok: false, reason: "has no value" }
157
+
158
+ if (type === "string") {
159
+ const text = value instanceof Date ? value.toISOString() : typeof value === "number" || typeof value === "boolean" ? String(value) : value
160
+ if (typeof text !== "string") return { ok: false, reason: `needs text, got ${Array.isArray(value) ? "a list" : typeof value}` }
161
+ const formatted = toFormat(text, schema.format, env.context)
162
+ if (schema.format && FORMATS[schema.format] && !FORMATS[schema.format](formatted)) return { ok: false, reason: `needs a ${schema.format}, got ${JSON.stringify(text)}` }
163
+ const trimmed = schema.format ? formatted : trimWords(formatted, schema.maxGraphemes, schema.maxLength)
164
+ if (schema.enum && !schema.enum.includes(trimmed)) return { ok: false, reason: `must be one of ${schema.enum.join(", ")}` }
165
+ if (schema.const !== undefined && trimmed !== schema.const) return { ok: false, reason: `must be ${schema.const}` }
166
+ const graphemes = [...segmenter.segment(trimmed)].length
167
+ if ((schema.minGraphemes && graphemes < schema.minGraphemes) || (schema.minLength && Buffer.byteLength(trimmed) < schema.minLength)) return { ok: false, reason: "is too short" }
168
+ return { ok: true, value: trimmed }
169
+ }
170
+
171
+ if (type === "integer") {
172
+ const number = typeof value === "string" && value.trim() ? Number(value) : value
173
+ if (typeof number !== "number" || !Number.isInteger(number)) return { ok: false, reason: `needs a whole number, got ${JSON.stringify(value)}` }
174
+ if ((schema.minimum !== undefined && number < schema.minimum) || (schema.maximum !== undefined && number > schema.maximum)) return { ok: false, reason: `must be between ${schema.minimum ?? "-∞"} and ${schema.maximum ?? "∞"}` }
175
+ if (schema.enum && !schema.enum.includes(number)) return { ok: false, reason: `must be one of ${schema.enum.join(", ")}` }
176
+ return { ok: true, value: number }
177
+ }
178
+
179
+ if (type === "boolean") {
180
+ if (typeof value !== "boolean") return { ok: false, reason: `needs true or false, got ${JSON.stringify(value)}` }
181
+ return { ok: true, value }
182
+ }
183
+
184
+ if (type === "array") {
185
+ const items = Array.isArray(value) ? value : [value]
186
+ const fitted = items.map(item => fit(item, schema.items, from, env))
187
+ const kept = fitted.filter(result => result.ok).map(result => result.value)
188
+ const dropped = fitted.filter(result => !result.ok).map(result => `an item ${result.reason}`)
189
+ const cut = schema.maxLength !== undefined ? kept.slice(0, schema.maxLength) : kept
190
+ if (schema.minLength !== undefined && cut.length < schema.minLength) return { ok: false, reason: `needs at least ${schema.minLength} items` }
191
+ return { ok: true, value: cut, warnings: dropped }
192
+ }
193
+
194
+ if (type === "object") return fitObject(value, schema, from, env)
195
+
196
+ if (type === "ref") {
197
+ const target = resolveDef(env.lexicons, schema.ref, from)
198
+ // A lexicon that didn't resolve can't be checked: the author's value
199
+ // goes as written.
200
+ if (!target) return { ok: true, value }
201
+ if (target.def.type === "token") return value === target.id ? { ok: true, value } : { ok: false, reason: `must be ${target.id}` }
202
+ return fit(value, target.def, target.nsid, env)
203
+ }
204
+
205
+ if (type === "union") {
206
+ if (!isPlainObject(value)) return { ok: false, reason: "needs an object with a $type" }
207
+ const refs = (schema.refs ?? []).map(ref => fullRef(ref, from))
208
+ const declared = typeof value.$type === "string" ? value.$type.replace(/#main$/, "") : refs.length === 1 ? refs[0] : undefined
209
+ if (!declared) return { ok: false, reason: `needs a $type (one of ${refs.join(", ")})` }
210
+ if (!refs.includes(declared)) {
211
+ if (schema.closed) return { ok: false, reason: `$type must be one of ${refs.join(", ")}` }
212
+ return { ok: true, value: { ...value, $type: declared } }
213
+ }
214
+ const target = resolveDef(env.lexicons, declared, from)
215
+ if (!target) return { ok: true, value: { ...value, $type: declared } }
216
+ const inner = fit(value, target.def, target.nsid, env)
217
+ return inner.ok ? { ...inner, value: { $type: declared, ...inner.value } } : inner
218
+ }
219
+
220
+ if (type === "blob") {
221
+ if (isPlainObject(value) && value.$type === "blob") return { ok: true, value }
222
+ return { ok: false, reason: "is a file upload, which vowel doesn't do yet" }
223
+ }
224
+
225
+ if (type === "cid-link") return isPlainObject(value) && "$link" in value ? { ok: true, value } : { ok: false, reason: "needs a CID link" }
226
+ if (type === "bytes") return isPlainObject(value) && "$bytes" in value ? { ok: true, value } : { ok: false, reason: "needs bytes" }
227
+ return { ok: true, value }
228
+ }
229
+
230
+ /**
231
+ * An object: each declared property found by name, fitted, and the
232
+ * required ones insisted on. Undeclared keys are left out.
233
+ * @param {unknown} value
234
+ * @param {any} schema
235
+ * @param {string} from
236
+ * @param {{ lexicons: Lexicons, context: Context }} env
237
+ * @returns {Fit}
238
+ */
239
+ function fitObject(value, schema, from, env) {
240
+ if (!isPlainObject(value)) return { ok: false, reason: `needs an object, got ${JSON.stringify(value)}` }
241
+ const required = new Set(schema.required ?? [])
242
+ const results = Object.entries(schema.properties ?? {}).map(([name, property]) => {
243
+ const found = byName(value, name)
244
+ if (found === undefined || found === null) return { name, missing: true }
245
+ return { name, result: fit(found, property, from, env) }
246
+ })
247
+ const missing = results.find(entry => entry.missing && required.has(entry.name))
248
+ if (missing) return { ok: false, reason: `needs ${missing.name}` }
249
+ const failed = results.find(entry => entry.result && !entry.result.ok && required.has(entry.name))
250
+ if (failed) return { ok: false, reason: `${failed.name} ${failed.result.reason}` }
251
+ const kept = results.filter(entry => entry.result?.ok)
252
+ const warnings = [
253
+ ...results.filter(entry => entry.result && !entry.result.ok).map(entry => `${entry.name} ${entry.result.reason}; left out`),
254
+ ...kept.flatMap(entry => (entry.result.warnings ?? []).map(warning => `${entry.name}: ${warning}`))
255
+ ]
256
+ return { ok: true, value: Object.fromEntries(kept.map(entry => [entry.name, entry.result.value])), warnings }
257
+ }
258
+
259
+ /**
260
+ * The facet features a `facets` property allows, by name. The facet
261
+ * def's `index` names its byte-slice properties.
262
+ * @param {any} facetsSchema - the record's `facets` property
263
+ * @param {string} from
264
+ * @param {Lexicons} lexicons
265
+ */
266
+ function facetShape(facetsSchema, from, lexicons) {
267
+ const facet = facetsSchema?.items?.type === "ref" ? resolveDef(lexicons, facetsSchema.items.ref, from) : undefined
268
+ if (facet?.def?.type !== "object") return undefined
269
+ const index = facet.def.properties?.index?.type === "ref" ? resolveDef(lexicons, facet.def.properties.index.ref, facet.nsid) : undefined
270
+ const indexProperties = Object.keys(index?.def?.properties ?? {})
271
+ const startProperty = indexProperties.find(name => /start/i.test(name))
272
+ const endProperty = indexProperties.find(name => /end/i.test(name))
273
+ const union = facet.def.properties?.features?.items
274
+ if (!startProperty || !endProperty || union?.type !== "union") return undefined
275
+
276
+ const features = Object.fromEntries((union.refs ?? []).map(ref => {
277
+ const target = resolveDef(lexicons, ref, facet.nsid)
278
+ const name = target?.name === "main" ? target.nsid.split(".").at(-1) : target?.name
279
+ const properties = Object.entries(target?.def?.properties ?? {})
280
+ const uriProperty = properties.find(([, property]) => property.format === "uri")?.[0]
281
+ const tagProperty = properties.find(([key, property]) => property.type === "string" && key === "tag")?.[0]
282
+ return [name, { $type: fullRef(ref, facet.nsid), uriProperty, tagProperty }]
283
+ }))
284
+ return { features, index: { startProperty, endProperty } }
285
+ }
286
+
287
+ /**
288
+ * The markdown, shaped as an object a content lexicon describes: its
289
+ * required properties filled, the first string among `markdown`,
290
+ * `content` or `text` holding the markdown, a required ref followed.
291
+ * Undefined when the shape asks for something else.
292
+ * @param {any} def
293
+ * @param {string} from
294
+ * @param {string} markdown
295
+ * @param {Lexicons} lexicons
296
+ * @param {number} [depth]
297
+ * @returns {Record<string, unknown> | undefined}
298
+ */
299
+ function markdownObject(def, from, markdown, lexicons, depth = 0) {
300
+ if (def?.type !== "object" || depth > 4) return undefined
301
+ const properties = def.properties ?? {}
302
+ const slot = MARKDOWN_SLOTS.find(name => properties[name]?.type === "string")
303
+ if (slot) {
304
+ const others = (def.required ?? []).filter(name => name !== slot)
305
+ return others.length ? undefined : { [slot]: markdown }
306
+ }
307
+ const nested = (def.required ?? []).map(name => {
308
+ const property = properties[name]
309
+ const target = property?.type === "ref" ? resolveDef(lexicons, property.ref, from) : undefined
310
+ const inner = target ? markdownObject(target.def, target.nsid, markdown, lexicons, depth + 1) : undefined
311
+ return [name, inner]
312
+ })
313
+ if (!nested.length || nested.some(([, inner]) => !inner)) return undefined
314
+ return Object.fromEntries(nested)
315
+ }
316
+
317
+ /**
318
+ * The body for the record's body property, in the form its schema asks
319
+ * for.
320
+ * @param {string} name - `content` or `text`
321
+ * @param {any} property
322
+ * @param {any} record - the record's object schema
323
+ * @param {string} nsid
324
+ * @param {Page} page
325
+ * @param {{ lexicons: Lexicons, context: Context }} env
326
+ * @returns {{ values: Record<string, unknown> } | { reason: string }}
327
+ */
328
+ function bodyValues(name, property, record, nsid, page, env) {
329
+ const facets = record.properties?.facets
330
+ if (property.type === "string" && facets?.type === "array") {
331
+ const shape = facetShape(facets, nsid, env.lexicons)
332
+ const { text, spans } = page.richText(shape?.features ?? {})
333
+ const trimmed = trimWords(text, property.maxGraphemes, property.maxLength)
334
+ const values = shape ? { [name]: trimmed, facets: facetsFor(trimmed, spans, shape.index) } : { [name]: trimmed }
335
+ return { values }
336
+ }
337
+ if (property.type === "string") return { values: { [name]: page.markdown() } }
338
+
339
+ const markdown = page.markdown()
340
+ const candidates = property.type === "union"
341
+ ? [...(property.refs ?? []).map(ref => fullRef(ref, nsid)), ...(property.closed ? [] : [env.context.contentLexicon])]
342
+ : property.type === "ref" ? [fullRef(property.ref, nsid)] : []
343
+ const built = candidates
344
+ .map(ref => ({ ref, target: resolveDef(env.lexicons, ref, nsid) }))
345
+ .map(({ ref, target }) => ({ ref, object: target ? markdownObject(target.def, target.nsid, markdown, env.lexicons) : undefined }))
346
+ .find(candidate => candidate.object)
347
+ if (!built) return { reason: `has no shape vowel can put markdown in` }
348
+ const value = property.type === "union" ? { $type: built.ref, ...built.object } : built.object
349
+ return { values: { [name]: value } }
350
+ }
351
+
352
+ /**
353
+ * The page as a record of `nsid`.
354
+ * @param {string} nsid
355
+ * @param {Page} page
356
+ * @param {{ lexicons: Lexicons, context: Context }} env
357
+ * @returns {{ record: Record<string, unknown>, warnings: string[] } | { ignored: string, warnings: string[] }}
358
+ */
359
+ function mapRecord(nsid, page, env) {
360
+ const main = env.lexicons.get(nsid)?.defs?.main
361
+ if (!main) return { ignored: `${nsid} is not published`, warnings: [] }
362
+ if (main.type !== "record") return { ignored: `${nsid} is not a record lexicon`, warnings: [] }
363
+ if (!["tid", "any"].includes(main.key)) return { ignored: `${nsid}'s record key is "${main.key}", which vowel doesn't publish to`, warnings: [] }
364
+
365
+ const record = main.record
366
+ const properties = record.properties ?? {}
367
+ const has = (name) => name in properties
368
+ const dateField = has("publishedAt") ? "publishedAt" : has("createdAt") ? "createdAt" : undefined
369
+ const aliases = {
370
+ title: page.title,
371
+ name: page.title,
372
+ description: page.description,
373
+ tags: page.tags?.length ? page.tags : undefined,
374
+ image: page.image,
375
+ ...(dateField ? { [dateField]: page.date } : {})
376
+ }
377
+ const standardSite = nsid.startsWith("site.standard.")
378
+ ? { site: env.context.publication ?? env.context.origin, path: env.context.path }
379
+ : {}
380
+
381
+ // Frontmatter by name first, then an alias, then the standard.site rule.
382
+ const found = Object.fromEntries(Object.keys(properties).map(name => {
383
+ const written = byName(page.frontmatter, name)
384
+ if (written !== undefined) return [name, written]
385
+ if (ALIAS_KEYS.has(name) && aliases[name] !== undefined) return [name, aliases[name]]
386
+ return [name, standardSite[name]]
387
+ }).filter(([, value]) => value !== undefined))
388
+
389
+ const bodyName = BODY_KEYS.find(name => has(name) && !(name in found))
390
+ const body = bodyName ? bodyValues(bodyName, properties[bodyName], record, nsid, page, env) : { values: {} }
391
+ const bodyWarnings = "reason" in body ? [`${bodyName} ${body.reason}; left out`] : []
392
+ const values = { ...("values" in body ? body.values : {}), ...found }
393
+
394
+ const fitted = fitObject(values, record, nsid, env)
395
+ if (!fitted.ok) return { ignored: `a ${nsid} record ${fitted.reason}`, warnings: bodyWarnings }
396
+ return { record: { $type: nsid, ...fitted.value }, warnings: [...bodyWarnings, ...(fitted.warnings ?? [])] }
397
+ }
398
+
399
+ export { mapRecord, fit, trimWords, facetShape, markdownObject }