vowel 0.7.1-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 +12 -0
- package/extractDate.js +1 -1
- package/package.json +8 -33
- package/pathDate.js +69 -0
- package/plugins/assets/index.js +1 -1
- package/plugins/atproto/index.js +5 -45
- package/plugins/atproto/publish.js +34 -9
- package/plugins/atproto/record.js +47 -4
- 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 +211 -100
- package/plugins/html/partials.js +14 -8
- package/plugins/html/socialLinks.js +5 -8
- package/plugins/html/writeRules.js +193 -35
- 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 +18 -8
- package/plugins/markdown/index.js +90 -23
- package/plugins/markdown/metadata.js +74 -22
- package/plugins/markdown/typography.js +11 -11
- package/plugins/styles/index.js +4 -4
- package/plugins/xml/index.js +17 -10
- package/stylesheets/DefaultStyles.css +158 -105
- package/stylesheets/TypographyStyles.css +143 -76
- package/utils.js +74 -1
package/plugins/html/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import { gfmTable } from 'micromark-extension-gfm-table'
|
|
|
13
13
|
import { gfmTableFromMarkdown } from 'mdast-util-gfm-table'
|
|
14
14
|
import { h } from 'hastscript'
|
|
15
15
|
import { readFileSync } from "fs"
|
|
16
|
-
import { testURL, testHashtags, createHashtagPage,
|
|
16
|
+
import { testURL, testHashtags, createHashtagPage, labelFromName, isPageWithoutFile, pageClasses, hashtagRegexSingle, createImagePaths, imageSizes, imageExts } from "./../../utils.js"
|
|
17
17
|
import extractDate from "./../../extractDate.js"
|
|
18
18
|
import { reservedProperties, hiddenProperties } from "./../markdown/metadata.js"
|
|
19
19
|
import { toString as hastToString } from 'hast-util-to-string'
|
|
@@ -22,6 +22,8 @@ import toc from "@jsdevtools/rehype-toc"
|
|
|
22
22
|
import slug from "rehype-slug"
|
|
23
23
|
import createDynamicImage from "./image.js"
|
|
24
24
|
import { listPages } from "./../../utils.js"
|
|
25
|
+
import { pageRecordURI } from "../atproto/record.js"
|
|
26
|
+
import { STATUSES } from "../markdown/frontmatter.js"
|
|
25
27
|
import { themeStylesheets, isVowelStylesheet } from "../styles/theme.js"
|
|
26
28
|
import { displayPath } from "../../secretPaths.js"
|
|
27
29
|
import { socialLinksNav } from "./socialLinks.js"
|
|
@@ -75,8 +77,9 @@ function siteName(settings, api) {
|
|
|
75
77
|
* @param {string} url
|
|
76
78
|
* @param {import("votive").PluginAPI} api
|
|
77
79
|
* @param {Votive.VotiveConfig} config
|
|
80
|
+
* @param {string} [source] - the page's source path, for its classes
|
|
78
81
|
*/
|
|
79
|
-
function makeHeader(metadata, url, api, config) {
|
|
82
|
+
function makeHeader(metadata, url, api, config, source) {
|
|
80
83
|
|
|
81
84
|
const treeMainHead = []
|
|
82
85
|
|
|
@@ -89,7 +92,7 @@ function makeHeader(metadata, url, api, config) {
|
|
|
89
92
|
treeMainHead.push(makeTime(date))
|
|
90
93
|
}
|
|
91
94
|
|
|
92
|
-
if (metadata.image ||
|
|
95
|
+
if (metadata.image || metadata.first_image) {
|
|
93
96
|
const metaImage = metadata.image || metadata.first_image
|
|
94
97
|
|
|
95
98
|
treeMainHead.push(
|
|
@@ -106,9 +109,36 @@ function makeHeader(metadata, url, api, config) {
|
|
|
106
109
|
)
|
|
107
110
|
)
|
|
108
111
|
|
|
109
|
-
|
|
112
|
+
// A card carries its page's classes, as the page's <body> does, and is
|
|
113
|
+
// an item of its own: the itemprops inside it (date, description,
|
|
114
|
+
// image) describe that page, not the page it's listed on. A page with
|
|
115
|
+
// no file (`html_file: false`) has no url, so its card links nowhere.
|
|
116
|
+
const article = h('article', { className: pageClasses(metadata.prettyURL, source), itemscope: true }, treeMainHead)
|
|
117
|
+
return url ? h('a', { href: url }, article) : article
|
|
118
|
+
}
|
|
110
119
|
|
|
111
|
-
|
|
120
|
+
/**
|
|
121
|
+
* How many links a hast tree holds.
|
|
122
|
+
* @param {any} node
|
|
123
|
+
* @returns {number}
|
|
124
|
+
*/
|
|
125
|
+
function linkCount(node) {
|
|
126
|
+
const own = node.tagName === "a" ? 1 : 0
|
|
127
|
+
const children = node.children ?? []
|
|
128
|
+
return children.reduce((sum, child) => sum + linkCount(child), own)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The nav, or null when it has fewer than two links: a nav is only
|
|
133
|
+
* rendered when there is somewhere else to go (per Sam). A one-entry
|
|
134
|
+
* menu, a trail that is only the home crumb, a table of contents with
|
|
135
|
+
* one heading are left out of the page, rather than hidden with CSS.
|
|
136
|
+
* Social links are exempt: one link is still a `rel="me"` verification.
|
|
137
|
+
* @param {any} nav - a hast element, or null
|
|
138
|
+
*/
|
|
139
|
+
function navWithChoices(nav) {
|
|
140
|
+
if (!nav) return null
|
|
141
|
+
return linkCount(nav) > 1 ? nav : null
|
|
112
142
|
}
|
|
113
143
|
|
|
114
144
|
/**
|
|
@@ -187,35 +217,81 @@ function makeValue(value) {
|
|
|
187
217
|
* @param {import("votive").PluginAPI} api
|
|
188
218
|
* @param {Votive.VotiveConfig} config
|
|
189
219
|
*/
|
|
190
|
-
|
|
191
|
-
|
|
220
|
+
/**
|
|
221
|
+
* An element with one more class. Frontmatter properties carry their key
|
|
222
|
+
* this way (`fm_date`, `fm_description`), which is how the editor tells
|
|
223
|
+
* them from the same properties written in the text: those have the
|
|
224
|
+
* itemprop and no class.
|
|
225
|
+
* @param {any} node - a hast element, or nothing
|
|
226
|
+
* @param {string} name
|
|
227
|
+
*/
|
|
228
|
+
function withClass(node, name) {
|
|
229
|
+
if (!node) return node
|
|
230
|
+
const className = [...(node.properties?.className ?? []), name]
|
|
231
|
+
return { ...node, properties: { ...node.properties, className } }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Natural metadata, lifted out of the content: the date line, image and
|
|
236
|
+
* description the author wrote in the text, which the read marked
|
|
237
|
+
* (`data-metadata`, metadata.js) and the write walk has already turned
|
|
238
|
+
* into a <time>, a <picture> and a paragraph. Each becomes a direct
|
|
239
|
+
* child of <main>, like its frontmatter equivalent but without the
|
|
240
|
+
* `fm_` class; the rule that closed a description renders as nothing.
|
|
241
|
+
* The source is untouched - only where the output puts them.
|
|
242
|
+
* @param {any} tree - the walked content; the marked blocks are removed from it
|
|
243
|
+
* @returns {any[]}
|
|
244
|
+
*/
|
|
245
|
+
function naturalMetadata(tree) {
|
|
246
|
+
const isMarked = (node) => Boolean(node.properties?.dataMetadata)
|
|
247
|
+
const marked = tree.children.filter(isMarked)
|
|
248
|
+
tree.children = tree.children.filter(node => !isMarked(node))
|
|
249
|
+
|
|
250
|
+
return marked.flatMap(node => {
|
|
251
|
+
const { dataMetadata: kind, ...properties } = node.properties
|
|
252
|
+
if (kind === "end") return []
|
|
253
|
+
if (kind === "description") return [{ ...node, properties }]
|
|
254
|
+
// A date or an image paragraph only wrapped the one element that is
|
|
255
|
+
// the metadata: the <time>, or the <picture> (an <img> until the
|
|
256
|
+
// image has been read).
|
|
257
|
+
const element = node.children.find(child => child.type === "element")
|
|
258
|
+
if (!element) return []
|
|
259
|
+
return [{ ...element, properties: { ...element.properties, itemprop: kind } }]
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function makeFrontmatter(metadata, api, config, natural = []) {
|
|
264
|
+
const handled = ["title", "slogan", "date", "image", "description"]
|
|
192
265
|
|
|
193
266
|
// hiddenProperties never render: the editor takes frontmatter from
|
|
194
267
|
// the source file, so nothing is lost by leaving a key off the page.
|
|
268
|
+
// `status` is shown only when it's the author's own word ("evergreen");
|
|
269
|
+
// vowel's four (published, hidden, …) are instructions, not content.
|
|
270
|
+
const ownStatus = !STATUSES.includes(metadata.fm_status)
|
|
195
271
|
const properties = (metadata.frontmatter_keys || []).filter(key => {
|
|
196
|
-
return !handled.includes(key) && !hiddenProperties.includes(key)
|
|
272
|
+
return !handled.includes(key) && !hiddenProperties.includes(key) && (key !== "status" || ownStatus)
|
|
197
273
|
})
|
|
198
274
|
|
|
199
275
|
const known = []
|
|
200
276
|
|
|
201
277
|
if (metadata.fm_date) {
|
|
202
|
-
known.push(makeTime(new Date(metadata.fm_date)))
|
|
278
|
+
known.push(withClass(makeTime(new Date(metadata.fm_date)), "fm_date"))
|
|
203
279
|
}
|
|
204
280
|
|
|
205
281
|
if (metadata.fm_image) {
|
|
206
|
-
known.push(createDynamicImage(metadata.fm_image, api, null, true))
|
|
282
|
+
known.push(withClass(createDynamicImage(metadata.fm_image, api, null, true), "fm_image"))
|
|
207
283
|
}
|
|
208
284
|
|
|
209
285
|
if (metadata.fm_description) {
|
|
210
|
-
known.push(h("p", { itemprop: "description" }, metadata.fm_description))
|
|
286
|
+
known.push(h("p.fm_description", { itemprop: "description" }, metadata.fm_description))
|
|
211
287
|
}
|
|
212
288
|
|
|
213
289
|
const generic = properties.map(key => {
|
|
214
290
|
const name = reservedProperties.includes(key) ? key : "fm_" + key
|
|
215
|
-
return h(
|
|
291
|
+
return h(`dl.fm_${key}`, [h("dt", key), h("dd", makeValue(metadata[name]))])
|
|
216
292
|
})
|
|
217
293
|
|
|
218
|
-
return [...known, ...generic]
|
|
294
|
+
return [...known, ...natural, ...generic]
|
|
219
295
|
}
|
|
220
296
|
|
|
221
297
|
/** @type {Votive.ProcessorWrite} */
|
|
@@ -289,11 +365,26 @@ function writeFile(target, { settings, api, config }) {
|
|
|
289
365
|
// setting); the root's decides which theme gets the plain file names.
|
|
290
366
|
const themeSheets = themeStylesheets(settings.lastNonNull("theme"), settings.raw("theme")?.[0]?.at(-1))
|
|
291
367
|
|
|
368
|
+
// The sheets a `styles:` names: each settings.md's along the chain,
|
|
369
|
+
// root first, then the page's own frontmatter. A path is a URL path
|
|
370
|
+
// from the root (frontmatter.js resolves it); a URL - a web font's
|
|
371
|
+
// stylesheet - is linked as written. A path to no file is skipped,
|
|
372
|
+
// with a warning: the <link> would 404. api.target() tracks it, so
|
|
373
|
+
// the file appearing later relinks the page.
|
|
374
|
+
const listed = [...new Set([...(settings.flat("fm_styles") ?? []), ...(metadata.fm_styles ?? [])])]
|
|
375
|
+
const external = listed.filter(entry => /^[a-z][a-z0-9+.-]*:|^\/\//i.test(entry))
|
|
376
|
+
const named = listed
|
|
377
|
+
.filter(entry => !external.includes(entry))
|
|
378
|
+
.map(entry => ({ entry, sheet: entry.replace(/^\/+/, "").split("/").join(path.sep) }))
|
|
379
|
+
const missing = named.filter(({ sheet }) => !api.target(sheet))
|
|
380
|
+
missing.forEach(({ entry }) => config.log?.("warn", `${target.path}: styles names ${entry}, which isn't in the project`))
|
|
381
|
+
const namedSheets = named.filter(({ sheet }) => !missing.some(gone => gone.sheet === sheet)).map(({ sheet }) => sheet)
|
|
382
|
+
|
|
292
383
|
// A stub stylesheet has a source path like any other source, so the
|
|
293
384
|
// `sheet.source` test alone no longer separates vowel's sheets from the
|
|
294
385
|
// project's - exclude them by name instead, or every theme sheet would
|
|
295
386
|
// be linked twice.
|
|
296
|
-
const sheets = [...themeSheets, ...projectSheets.map(sheet => sheet.path)
|
|
387
|
+
const sheets = [...new Set([...themeSheets, ...projectSheets.map(sheet => sheet.path), ...namedSheets])]
|
|
297
388
|
const projectHash = new Map(projectSheets.map(sheet => [sheet.path, sheet.hash]))
|
|
298
389
|
|
|
299
390
|
sheets.forEach(sheet => {
|
|
@@ -321,25 +412,70 @@ function writeFile(target, { settings, api, config }) {
|
|
|
321
412
|
})
|
|
322
413
|
)
|
|
323
414
|
})
|
|
415
|
+
external.forEach(href => treeStyleSheets.push(h('link', { rel: "stylesheet", href })))
|
|
324
416
|
|
|
325
|
-
// The document title: the page's own title, then every
|
|
326
|
-
// the page's
|
|
327
|
-
// hyphen-delimited - "Hats - Shop - Site - Warm heads".
|
|
328
|
-
// the site's front door and shows the site's name and
|
|
417
|
+
// The document title: the page's own title, then every folder from
|
|
418
|
+
// the page's up to the root, then the site's name, then the page's
|
|
419
|
+
// own slogan, hyphen-delimited - "Hats - Shop - Site - Warm heads".
|
|
420
|
+
// The homepage is the site's front door and shows the site's name and
|
|
421
|
+
// its slogan.
|
|
329
422
|
function createTitle() {
|
|
330
423
|
if (isRoot) {
|
|
331
|
-
return [site || metadata?.title, metadata?.
|
|
424
|
+
return [site || metadata?.title, metadata?.fm_slogan]
|
|
332
425
|
.filter(a => a)
|
|
333
426
|
.join(" - ")
|
|
334
427
|
}
|
|
335
428
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
429
|
+
// Every folder between the page and the root, leaf first, by the
|
|
430
|
+
// label its breadcrumb shows: its index page's. `name:` is the
|
|
431
|
+
// site's, so the root (index 0) is left to `site`.
|
|
432
|
+
const folders = ancestorFolders
|
|
433
|
+
.map((folder, index) => index === 0 ? null : folderCrumb(folder, index).label)
|
|
434
|
+
.slice(1)
|
|
435
|
+
.reverse()
|
|
436
|
+
const parts = [metadata.title, ...folders, site, metadata.fm_slogan].filter(a => a)
|
|
339
437
|
return parts.length ? parts.join(" - ") : "Website"
|
|
340
438
|
}
|
|
341
439
|
|
|
342
440
|
|
|
441
|
+
/**
|
|
442
|
+
* A folder's crumb, deduced from the path at render time rather than
|
|
443
|
+
* read from a `breadcrumbs` setting the folder pass used to write.
|
|
444
|
+
*
|
|
445
|
+
* The folder pass is gone, and this is a better place for it anyway:
|
|
446
|
+
* the lookup is tracked, so renaming a section's index page restales
|
|
447
|
+
* every page whose breadcrumb names it - which the setting never did.
|
|
448
|
+
*
|
|
449
|
+
* A folder with an index page is a link and takes that page's own
|
|
450
|
+
* breadcrumb. A folder without one is just a label: vowel no longer
|
|
451
|
+
* generates a page per folder, so linking there would 404.
|
|
452
|
+
*/
|
|
453
|
+
// A secret folder's target segment is a hash, so its readable name has
|
|
454
|
+
// to come from the page's own *source* path - de-salted, because the
|
|
455
|
+
// source keeps the salt and the salt must never reach the page. The
|
|
456
|
+
// page has one ancestor per target segment and one per source segment,
|
|
457
|
+
// aligned by depth, which is what lets the two be zipped.
|
|
458
|
+
const sourceFolderNames = target.source
|
|
459
|
+
? displayPath(target.source).split(path.sep).slice(0, -1)
|
|
460
|
+
: []
|
|
461
|
+
|
|
462
|
+
function folderCrumb(folderPath, index) {
|
|
463
|
+
if (!folderPath) {
|
|
464
|
+
const home = api.target("index.html")
|
|
465
|
+
return { label: home?.metadata?.breadcrumb || "Home", href: "/" }
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// A folder's index is `<folder>.html`, from an authored `<folder>.md`
|
|
469
|
+
// beside it or the generated stub.
|
|
470
|
+
const indexTarget = api.target(`${folderPath}.html`)
|
|
471
|
+
// index 0 is the root, so ancestor i is source segment i - 1.
|
|
472
|
+
const sourceName = sourceFolderNames[index - 1]
|
|
473
|
+
const label = indexTarget?.metadata?.breadcrumb
|
|
474
|
+
|| labelFromName(sourceName ?? folderPath.split(path.sep).at(-1))
|
|
475
|
+
// folderPath is a stored folder (path.sep); an href is a url.
|
|
476
|
+
return { label, href: indexTarget ? "/" + folderPath.split(path.sep).join("/") : null }
|
|
477
|
+
}
|
|
478
|
+
|
|
343
479
|
const site = siteName(settings, api)
|
|
344
480
|
const title = createTitle()
|
|
345
481
|
|
|
@@ -365,6 +501,9 @@ function writeFile(target, { settings, api, config }) {
|
|
|
365
501
|
property: "og:description",
|
|
366
502
|
content: metadata.description,
|
|
367
503
|
}),
|
|
504
|
+
// The plain description most search engines read; og: is for
|
|
505
|
+
// link previews.
|
|
506
|
+
...(metadata.description ? [h('meta', { name: "description", content: metadata.description })] : []),
|
|
368
507
|
...treeStyleSheets,
|
|
369
508
|
])
|
|
370
509
|
|
|
@@ -391,20 +530,26 @@ function writeFile(target, { settings, api, config }) {
|
|
|
391
530
|
// The page's AT Protocol record, for readers that verify a document
|
|
392
531
|
// against its site: standard.site's convention, so only for a
|
|
393
532
|
// site.standard.document, and only for a page that can be one - it
|
|
394
|
-
// needs a title and a date (its `publishedAt`).
|
|
395
|
-
//
|
|
396
|
-
|
|
533
|
+
// needs a title and a date (its `publishedAt`). Worked out here, from
|
|
534
|
+
// the settings this write reads and so is rebuilt by, rather than
|
|
535
|
+
// stored by a transform that only reruns when the page is re-read.
|
|
536
|
+
const record = pageRecordURI(target, settings)
|
|
537
|
+
if (record.warning) config.log?.("warn", `${target.path}: ${record.warning}`)
|
|
538
|
+
const standardDocument = typeof record.uri === "string" && record.uri.split("/")[3] === "site.standard.document"
|
|
397
539
|
if (standardDocument && metadata.title && metadata.date) {
|
|
398
540
|
treeHead.children.push(h("link", {
|
|
399
541
|
rel: "site.standard.document",
|
|
400
|
-
href:
|
|
542
|
+
href: record.uri
|
|
401
543
|
}))
|
|
402
544
|
}
|
|
403
545
|
|
|
404
|
-
|
|
546
|
+
// The page's own image, else the site's `fallback_image` (a setting,
|
|
547
|
+
// so a folder can have its own), so a link preview always has one.
|
|
548
|
+
const ogImage = metadata.image || settings.lastNonNull("fm_fallback_image")
|
|
549
|
+
if (ogImage) {
|
|
405
550
|
treeHead.children.push(h("meta", {
|
|
406
551
|
property: "og:image",
|
|
407
|
-
content:
|
|
552
|
+
content: ogImage
|
|
408
553
|
}))
|
|
409
554
|
}
|
|
410
555
|
|
|
@@ -420,15 +565,16 @@ function writeFile(target, { settings, api, config }) {
|
|
|
420
565
|
// `icon` is a path, everywhere (Sam, Sept 19) - a URL path from the
|
|
421
566
|
// root, resolved at read like `logo` (frontmatter.js). The type is
|
|
422
567
|
// taken from its extension; a browser ignores a wrong one, but a
|
|
423
|
-
// right one lets it pick between several.
|
|
568
|
+
// right one lets it pick between several. Without one, the project's
|
|
569
|
+
// own favicon.png, else Vowel's placeholder (plugins/images). A
|
|
570
|
+
// tracked read, so a favicon.png that appears later is linked.
|
|
424
571
|
const icon = settings.lastNonNull("fm_icon")
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
}
|
|
572
|
+
?? (api.target("favicon.png") ? "/favicon.png" : "/favicon-placeholder.png")
|
|
573
|
+
treeHead.children.push(h("link", {
|
|
574
|
+
href: icon,
|
|
575
|
+
rel: "icon",
|
|
576
|
+
type: iconType(icon)
|
|
577
|
+
}))
|
|
432
578
|
|
|
433
579
|
// The header nav is a partial: the lists for this page's folder chain,
|
|
434
580
|
// computed once per pass by createPartialStubs and rendered once by
|
|
@@ -436,55 +582,18 @@ function writeFile(target, { settings, api, config }) {
|
|
|
436
582
|
// beside shop/) takes that folder's chain, which ends in its own
|
|
437
583
|
// list; otherwise its folder's. The first lookup may miss - votive
|
|
438
584
|
// tracks the miss, so the page is rebuilt if that folder appears.
|
|
439
|
-
const treeNav = navPartial ? withCurrent(navPartial.metadata.hast, metadata.prettyURL) :
|
|
585
|
+
const treeNav = navWithChoices(navPartial ? withCurrent(navPartial.metadata.hast, metadata.prettyURL) : null)
|
|
440
586
|
|
|
441
587
|
let treeBreadcrumbs = []
|
|
442
588
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
*
|
|
447
|
-
* The folder pass is gone, and this is a better place for it anyway:
|
|
448
|
-
* the lookup is tracked, so renaming a section's index page restales
|
|
449
|
-
* every page whose breadcrumb names it - which the setting never did.
|
|
450
|
-
*
|
|
451
|
-
* A folder with an index page is a link and takes that page's own
|
|
452
|
-
* breadcrumb. A folder without one is just a label: vowel no longer
|
|
453
|
-
* generates a page per folder, so linking there would 404.
|
|
454
|
-
*/
|
|
455
|
-
// A secret folder's target segment is a hash, so its readable name has
|
|
456
|
-
// to come from the page's own *source* path - de-salted, because the
|
|
457
|
-
// source keeps the salt and the salt must never reach the page. The
|
|
458
|
-
// page has one ancestor per target segment and one per source segment,
|
|
459
|
-
// aligned by depth, which is what lets the two be zipped.
|
|
460
|
-
const sourceFolderNames = target.source
|
|
461
|
-
? displayPath(target.source).split(path.sep).slice(0, -1)
|
|
462
|
-
: []
|
|
463
|
-
|
|
464
|
-
function folderCrumb(folderPath, index) {
|
|
465
|
-
if (!folderPath) {
|
|
466
|
-
const home = api.target("index.html")
|
|
467
|
-
return { label: home?.metadata?.breadcrumb || "Home", href: "/" }
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// A folder's index is `<folder>.html`, from an authored `<folder>.md`
|
|
471
|
-
// beside it or the generated stub.
|
|
472
|
-
const indexTarget = api.target(`${folderPath}.html`)
|
|
473
|
-
// index 0 is the root, so ancestor i is source segment i - 1.
|
|
474
|
-
const sourceName = sourceFolderNames[index - 1]
|
|
475
|
-
const label = indexTarget?.metadata?.breadcrumb
|
|
476
|
-
|| toTitleCase(sourceName ?? folderPath.split(path.sep).at(-1))
|
|
477
|
-
// folderPath is a stored folder (path.sep); an href is a url.
|
|
478
|
-
return { label, href: indexTarget ? "/" + folderPath.split(path.sep).join("/") : null }
|
|
479
|
-
}
|
|
480
|
-
|
|
589
|
+
// Always an <a>, with or without an href: the breadcrumb's CSS styles
|
|
590
|
+
// its links, and a <span> among them broke it. A crumb has no href
|
|
591
|
+
// only when its folder has no index page to go to.
|
|
481
592
|
treeBreadcrumbs.push(
|
|
482
593
|
...ancestorFolders
|
|
483
594
|
.map(folderCrumb)
|
|
484
595
|
.filter(crumb => crumb.label)
|
|
485
|
-
.map(crumb => crumb.href
|
|
486
|
-
? h('a', { href: crumb.href }, crumb.label)
|
|
487
|
-
: h('span', crumb.label))
|
|
596
|
+
.map(crumb => h('a', crumb.href ? { href: crumb.href } : {}, crumb.label))
|
|
488
597
|
)
|
|
489
598
|
|
|
490
599
|
if (!isRoot) {
|
|
@@ -509,7 +618,7 @@ function writeFile(target, { settings, api, config }) {
|
|
|
509
618
|
identityElements.push(h("img#logo", {
|
|
510
619
|
src: logo,
|
|
511
620
|
"aria-label": "Logo",
|
|
512
|
-
style: `--logo
|
|
621
|
+
style: `--url-logo: url("${logo}")`,
|
|
513
622
|
alt: ""
|
|
514
623
|
}))
|
|
515
624
|
}
|
|
@@ -560,10 +669,6 @@ function writeFile(target, { settings, api, config }) {
|
|
|
560
669
|
|
|
561
670
|
const treeTableOfContents = treeContentSlugged.children.shift()
|
|
562
671
|
|
|
563
|
-
const treeMainHead = makeFrontmatter(metadata, api, config)
|
|
564
|
-
|
|
565
|
-
treeMainHead.push(treeTableOfContents)
|
|
566
|
-
|
|
567
672
|
// One walk for everything that renders against other targets, settings
|
|
568
673
|
// or the url store: relative links, wikilinks, icons, code highlighting,
|
|
569
674
|
// images, directives, link previews and alerts. See writeRules.js.
|
|
@@ -571,6 +676,13 @@ function writeFile(target, { settings, api, config }) {
|
|
|
571
676
|
const walked = { target, api, config, settings, makeHeader, makeTable, highlighted: false }
|
|
572
677
|
writeWalk(abstract, walked)
|
|
573
678
|
|
|
679
|
+
// After the walk, so a natural image is already its <picture>.
|
|
680
|
+
const natural = naturalMetadata(abstract)
|
|
681
|
+
const treeMainHead = makeFrontmatter(metadata, api, config, natural)
|
|
682
|
+
|
|
683
|
+
const tableOfContents = navWithChoices(treeTableOfContents)
|
|
684
|
+
if (tableOfContents) treeMainHead.push(tableOfContents)
|
|
685
|
+
|
|
574
686
|
if (walked.highlighted) {
|
|
575
687
|
// syntax-highlighting.css is always declared by the styles processor
|
|
576
688
|
// (a stub); a page only decides whether to link it.
|
|
@@ -592,20 +704,23 @@ function writeFile(target, { settings, api, config }) {
|
|
|
592
704
|
[
|
|
593
705
|
// The title is main's first child: the one piece of data hoisted out
|
|
594
706
|
// of the content regardless of where the author placed it.
|
|
595
|
-
...(metadata.title ? [h("h1", metadata.title)] : []),
|
|
596
|
-
// A
|
|
597
|
-
|
|
598
|
-
h(
|
|
707
|
+
...(metadata.title ? [h("h1", metadata.fm_title ? { className: ["fm_title"] } : {}, metadata.title)] : []),
|
|
708
|
+
// A slogan is the page's, not the site's: under its title. An
|
|
709
|
+
// itemprop, not an id: a listing can hold several, one per card.
|
|
710
|
+
...(metadata.fm_slogan ? [h("p.fm_slogan", { itemprop: "slogan" }, metadata.fm_slogan)] : []),
|
|
711
|
+
navWithChoices(h('nav', {
|
|
599
712
|
'aria-label': 'Breadcrumbs'
|
|
600
|
-
}, treeBreadcrumbs),
|
|
713
|
+
}, treeBreadcrumbs)),
|
|
601
714
|
treeMainHead,
|
|
602
715
|
h('section#content', abstract),
|
|
603
716
|
treeBacklinks
|
|
604
717
|
].filter(Boolean))
|
|
605
718
|
|
|
606
|
-
// The aside tree is one partial, the same on every page.
|
|
719
|
+
// The aside tree is one partial, the same on every page. Without a
|
|
720
|
+
// nav worth rendering there is no aside at all.
|
|
607
721
|
const asidePartial = api.target(ASIDE_PARTIAL)
|
|
608
|
-
const
|
|
722
|
+
const asideNav = navWithChoices(asidePartial ? asidePartial.metadata.hast : null)
|
|
723
|
+
const treeAside = asideNav ? h("aside", asideNav) : null
|
|
609
724
|
|
|
610
725
|
const treeFooter = h('footer', [
|
|
611
726
|
socialLinksNav(settings),
|
|
@@ -618,14 +733,7 @@ function writeFile(target, { settings, api, config }) {
|
|
|
618
733
|
])
|
|
619
734
|
])
|
|
620
735
|
|
|
621
|
-
const
|
|
622
|
-
.split("/")
|
|
623
|
-
.filter(a => a)
|
|
624
|
-
.map(a => a.replace("_", ""))
|
|
625
|
-
.join("_")
|
|
626
|
-
|| "home"
|
|
627
|
-
|
|
628
|
-
const treeBody = h(`body.${pageClass}`, [
|
|
736
|
+
const treeBody = h("body", { className: pageClasses(target.metadata.prettyURL, target.source) }, [
|
|
629
737
|
treeHeader,
|
|
630
738
|
treeMain,
|
|
631
739
|
treeAside,
|
|
@@ -682,6 +790,7 @@ function makeTable(classes, properties, targets, api) {
|
|
|
682
790
|
|
|
683
791
|
const cell = (target, name) => {
|
|
684
792
|
const value = valueOf(target.metadata, name)
|
|
793
|
+
if (name === "title" && isPageWithoutFile(target)) return h("td", value ?? "")
|
|
685
794
|
if (name === "title") return h("td", h("a", { href: target.metadata.prettyURL }, value ?? target.metadata.prettyURL))
|
|
686
795
|
if (value === undefined || value === null) return h("td")
|
|
687
796
|
if (name === "image") return h("td", createDynamicImage(String(value), api, "") ?? String(value))
|
|
@@ -690,10 +799,12 @@ function makeTable(classes, properties, targets, api) {
|
|
|
690
799
|
return h("td", String(value))
|
|
691
800
|
}
|
|
692
801
|
|
|
693
|
-
|
|
802
|
+
// In a <figure>, like a markdown table (markdown/index.js). The
|
|
803
|
+
// directive's parameters stay on the <table>, where the editor reads them.
|
|
804
|
+
return h("figure", h("table", { class: classes }, [
|
|
694
805
|
h("thead", h("tr", properties.map(name => h("th", name)))),
|
|
695
806
|
h("tbody", targets.map(target => h("tr", properties.map(name => cell(target, name)))))
|
|
696
|
-
])
|
|
807
|
+
]))
|
|
697
808
|
}
|
|
698
809
|
|
|
699
810
|
/**
|
package/plugins/html/partials.js
CHANGED
|
@@ -3,6 +3,9 @@ import { h } from "hastscript"
|
|
|
3
3
|
import { isListedPage } from "../../utils.js"
|
|
4
4
|
import { isVowelStylesheet } from "../styles/theme.js"
|
|
5
5
|
|
|
6
|
+
/** The one stylesheet name linked without being asked for. */
|
|
7
|
+
const PROJECT_SHEET = "styles.css"
|
|
8
|
+
|
|
6
9
|
/**
|
|
7
10
|
* Partials: rendered fragments shared by many pages, built once per
|
|
8
11
|
* pass instead of once per page.
|
|
@@ -20,8 +23,8 @@ import { isVowelStylesheet } from "../styles/theme.js"
|
|
|
20
23
|
* Paths: `partials/nav.partial` is the header nav for pages at the
|
|
21
24
|
* root, `partials/nav/<folder>.partial` for pages in a folder - the
|
|
22
25
|
* lists for every level from the root down to that folder, and the
|
|
23
|
-
* project's own stylesheets along that chain (a
|
|
24
|
-
*
|
|
26
|
+
* project's own stylesheets along that chain (a `styles.css` in any of
|
|
27
|
+
* those folders, with its content hash for the cache-buster).
|
|
25
28
|
* Both are "what this page's folder chain contributes", so they share a
|
|
26
29
|
* stub; the stylesheet half used to be one listing per ancestor per
|
|
27
30
|
* page, whose folder-membership edge restaled every page whenever any
|
|
@@ -60,10 +63,9 @@ function inHeaderNav(page) {
|
|
|
60
63
|
&& page.extension === ".html"
|
|
61
64
|
&& !page.metadata.date
|
|
62
65
|
&& page.path !== "index.html"
|
|
63
|
-
&& page.path !== "404.html"
|
|
64
66
|
&& page.path !== "tags.html"
|
|
65
67
|
&& page.dir !== "tags"
|
|
66
|
-
&& isOn(page.metadata.
|
|
68
|
+
&& isOn(page.metadata.local_nav_item)
|
|
67
69
|
&& isOn(page.metadata.html_file)
|
|
68
70
|
}
|
|
69
71
|
|
|
@@ -118,8 +120,8 @@ function asideTree(pages) {
|
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
const top = undated
|
|
121
|
-
.filter(page => page.dir === "" && page.path !== "index.html" && page.path !== "
|
|
122
|
-
&& isOn(page.metadata.
|
|
123
|
+
.filter(page => page.dir === "" && page.path !== "index.html" && page.path !== "tags.html"
|
|
124
|
+
&& isOn(page.metadata.global_nav_item) && isOn(page.metadata.html_file))
|
|
123
125
|
.map(node)
|
|
124
126
|
|
|
125
127
|
return [...(home ? [{ url: home.path, label: home.metadata.breadcrumb }] : []), ...top]
|
|
@@ -205,11 +207,15 @@ function createPartialStubs(api) {
|
|
|
205
207
|
const folders = new Set([""])
|
|
206
208
|
for (const page of pages) for (const folder of chain(page.dir)) folders.add(folder)
|
|
207
209
|
|
|
208
|
-
// The project's stylesheets by folder: a .css the author wrote
|
|
209
|
-
// source, is not one vowel generates), with the hash its read
|
|
210
|
+
// The project's stylesheets by folder: a `styles.css` the author wrote
|
|
211
|
+
// (has a source, is not one vowel generates), with the hash its read
|
|
212
|
+
// recorded. Only that name: any other .css is published but linked only
|
|
213
|
+
// where a `styles:` names it (the html plugin) - a sheet dropped into a
|
|
214
|
+
// folder used to restyle every page below it.
|
|
210
215
|
const sheets = new Map()
|
|
211
216
|
for (const sheet of all) {
|
|
212
217
|
if (!sheet.source || sheet.extension !== ".css" || isVowelStylesheet(sheet.path)) continue
|
|
218
|
+
if (path.basename(sheet.path) !== PROJECT_SHEET) continue
|
|
213
219
|
if (!sheets.has(sheet.dir)) sheets.set(sheet.dir, [])
|
|
214
220
|
sheets.get(sheet.dir).push({ path: sheet.path, hash: sheet.metadata?.hash ?? "" })
|
|
215
221
|
}
|
|
@@ -22,9 +22,9 @@ import { h } from "hastscript"
|
|
|
22
22
|
*
|
|
23
23
|
* No built-in icons, by decision: an author who wants one drops the SVG
|
|
24
24
|
* in the project and names it. With an icon the label is the image's
|
|
25
|
-
* alt text; without one it is the link text. The
|
|
26
|
-
*
|
|
27
|
-
*
|
|
25
|
+
* alt text; without one it is the link text. The icon's <img> carries
|
|
26
|
+
* its own url as `--url-icon`, the way `img#logo` carries `--url-logo`,
|
|
27
|
+
* so the stylesheet can mask it in `currentColor`.
|
|
28
28
|
* `rel="me"` is the identity-link convention (IndieAuth, Mastodon
|
|
29
29
|
* verification) and costs nothing.
|
|
30
30
|
*/
|
|
@@ -55,11 +55,8 @@ function socialLinksNav(settings) {
|
|
|
55
55
|
const text = label ?? url
|
|
56
56
|
// A URL path from the root, resolved at read (frontmatter.js).
|
|
57
57
|
const iconURL = icon ? String(icon) : null
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
rel: "me",
|
|
61
|
-
style: iconURL && `--icon-url: url("${iconURL}")`
|
|
62
|
-
}, iconURL ? h("img", { src: iconURL, alt: text }) : text))
|
|
58
|
+
const image = iconURL && h("img", { src: iconURL, alt: text, style: `--url-icon: url("${iconURL}")` })
|
|
59
|
+
return h("li", h("a", { href: url, rel: "me" }, image || text))
|
|
63
60
|
}))
|
|
64
61
|
)
|
|
65
62
|
}
|