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
|
@@ -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 }
|