vowel 0.7.0-beta.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +41 -239
- package/bin.js +73 -31
- package/cli.js +32 -0
- package/config.js +14 -0
- package/extractDate.js +2 -2
- package/index.js +6 -1
- package/package.json +9 -34
- package/pathDate.js +69 -0
- package/plugins/assets/index.js +1 -1
- package/plugins/atproto/body.js +413 -0
- package/plugins/atproto/index.js +10 -44
- package/plugins/atproto/lexicons.js +179 -0
- package/plugins/atproto/mapper.js +399 -0
- package/plugins/atproto/publish.js +195 -105
- package/plugins/atproto/record.js +57 -68
- package/plugins/deploy-cloudflare/index.js +4 -1
- package/plugins/export/index.js +131 -0
- package/plugins/html/bundle/index.js +57 -23
- package/plugins/html/editor/Editor.svelte +9 -5
- package/plugins/html/editor/create-session.js +3 -1
- package/plugins/html/editor/directives.js +12 -0
- package/plugins/html/editor/frontmatter.js +42 -11
- package/plugins/html/editor/ingest.js +19 -8
- package/plugins/html/editor/serialize.js +8 -4
- package/plugins/html/image.js +11 -5
- package/plugins/html/index.js +217 -100
- package/plugins/html/partials.js +14 -8
- package/plugins/html/socialLinks.js +5 -8
- package/plugins/html/writeRules.js +197 -37
- package/plugins/images/favicon.png +0 -0
- package/plugins/images/index.js +41 -3
- package/plugins/markdown/colorScheme.js +70 -14
- package/plugins/markdown/frontmatter.js +32 -6
- package/plugins/markdown/index.js +105 -20
- package/plugins/markdown/metadata.js +74 -22
- package/plugins/markdown/typography.js +11 -11
- package/plugins/secret-pages/index.js +75 -0
- package/plugins/styles/index.js +4 -4
- package/plugins/xml/entryContent.js +22 -11
- package/plugins/xml/index.js +19 -11
- package/stylesheets/DefaultStyles.css +198 -111
- package/stylesheets/ResetStyles.css +2 -1
- package/stylesheets/TypographyStyles.css +153 -74
- package/utils.js +74 -1
package/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} */
|
|
@@ -231,6 +307,9 @@ function writeFile(target, { settings, api, config }) {
|
|
|
231
307
|
// the moment it does not. No self-deleting targets.
|
|
232
308
|
|
|
233
309
|
const { metadata, ...rest } = target
|
|
310
|
+
// A deleted page (`status: deleted`) is kept only for atproto-publish;
|
|
311
|
+
// there is nothing to render.
|
|
312
|
+
if (metadata.fm_status === "deleted") return undefined
|
|
234
313
|
const abstract = metadata.hastAbstract
|
|
235
314
|
|
|
236
315
|
/** @param {string} filePath */
|
|
@@ -286,11 +365,26 @@ function writeFile(target, { settings, api, config }) {
|
|
|
286
365
|
// setting); the root's decides which theme gets the plain file names.
|
|
287
366
|
const themeSheets = themeStylesheets(settings.lastNonNull("theme"), settings.raw("theme")?.[0]?.at(-1))
|
|
288
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
|
+
|
|
289
383
|
// A stub stylesheet has a source path like any other source, so the
|
|
290
384
|
// `sheet.source` test alone no longer separates vowel's sheets from the
|
|
291
385
|
// project's - exclude them by name instead, or every theme sheet would
|
|
292
386
|
// be linked twice.
|
|
293
|
-
const sheets = [...themeSheets, ...projectSheets.map(sheet => sheet.path)
|
|
387
|
+
const sheets = [...new Set([...themeSheets, ...projectSheets.map(sheet => sheet.path), ...namedSheets])]
|
|
294
388
|
const projectHash = new Map(projectSheets.map(sheet => [sheet.path, sheet.hash]))
|
|
295
389
|
|
|
296
390
|
sheets.forEach(sheet => {
|
|
@@ -318,25 +412,70 @@ function writeFile(target, { settings, api, config }) {
|
|
|
318
412
|
})
|
|
319
413
|
)
|
|
320
414
|
})
|
|
415
|
+
external.forEach(href => treeStyleSheets.push(h('link', { rel: "stylesheet", href })))
|
|
321
416
|
|
|
322
|
-
// The document title: the page's own title, then every
|
|
323
|
-
// the page's
|
|
324
|
-
// hyphen-delimited - "Hats - Shop - Site - Warm heads".
|
|
325
|
-
// 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.
|
|
326
422
|
function createTitle() {
|
|
327
423
|
if (isRoot) {
|
|
328
|
-
return [site || metadata?.title, metadata?.
|
|
424
|
+
return [site || metadata?.title, metadata?.fm_slogan]
|
|
329
425
|
.filter(a => a)
|
|
330
426
|
.join(" - ")
|
|
331
427
|
}
|
|
332
428
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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)
|
|
336
437
|
return parts.length ? parts.join(" - ") : "Website"
|
|
337
438
|
}
|
|
338
439
|
|
|
339
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
|
+
|
|
340
479
|
const site = siteName(settings, api)
|
|
341
480
|
const title = createTitle()
|
|
342
481
|
|
|
@@ -362,6 +501,9 @@ function writeFile(target, { settings, api, config }) {
|
|
|
362
501
|
property: "og:description",
|
|
363
502
|
content: metadata.description,
|
|
364
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 })] : []),
|
|
365
507
|
...treeStyleSheets,
|
|
366
508
|
])
|
|
367
509
|
|
|
@@ -386,19 +528,28 @@ function writeFile(target, { settings, api, config }) {
|
|
|
386
528
|
}
|
|
387
529
|
|
|
388
530
|
// The page's AT Protocol record, for readers that verify a document
|
|
389
|
-
// against its site
|
|
390
|
-
//
|
|
391
|
-
|
|
531
|
+
// against its site: standard.site's convention, so only for a
|
|
532
|
+
// site.standard.document, and only for a page that can be one - it
|
|
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"
|
|
539
|
+
if (standardDocument && metadata.title && metadata.date) {
|
|
392
540
|
treeHead.children.push(h("link", {
|
|
393
541
|
rel: "site.standard.document",
|
|
394
|
-
href:
|
|
542
|
+
href: record.uri
|
|
395
543
|
}))
|
|
396
544
|
}
|
|
397
545
|
|
|
398
|
-
|
|
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) {
|
|
399
550
|
treeHead.children.push(h("meta", {
|
|
400
551
|
property: "og:image",
|
|
401
|
-
content:
|
|
552
|
+
content: ogImage
|
|
402
553
|
}))
|
|
403
554
|
}
|
|
404
555
|
|
|
@@ -414,15 +565,16 @@ function writeFile(target, { settings, api, config }) {
|
|
|
414
565
|
// `icon` is a path, everywhere (Sam, Sept 19) - a URL path from the
|
|
415
566
|
// root, resolved at read like `logo` (frontmatter.js). The type is
|
|
416
567
|
// taken from its extension; a browser ignores a wrong one, but a
|
|
417
|
-
// 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.
|
|
418
571
|
const icon = settings.lastNonNull("fm_icon")
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
}
|
|
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
|
+
}))
|
|
426
578
|
|
|
427
579
|
// The header nav is a partial: the lists for this page's folder chain,
|
|
428
580
|
// computed once per pass by createPartialStubs and rendered once by
|
|
@@ -430,55 +582,18 @@ function writeFile(target, { settings, api, config }) {
|
|
|
430
582
|
// beside shop/) takes that folder's chain, which ends in its own
|
|
431
583
|
// list; otherwise its folder's. The first lookup may miss - votive
|
|
432
584
|
// tracks the miss, so the page is rebuilt if that folder appears.
|
|
433
|
-
const treeNav = navPartial ? withCurrent(navPartial.metadata.hast, metadata.prettyURL) :
|
|
585
|
+
const treeNav = navWithChoices(navPartial ? withCurrent(navPartial.metadata.hast, metadata.prettyURL) : null)
|
|
434
586
|
|
|
435
587
|
let treeBreadcrumbs = []
|
|
436
588
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
*
|
|
441
|
-
* The folder pass is gone, and this is a better place for it anyway:
|
|
442
|
-
* the lookup is tracked, so renaming a section's index page restales
|
|
443
|
-
* every page whose breadcrumb names it - which the setting never did.
|
|
444
|
-
*
|
|
445
|
-
* A folder with an index page is a link and takes that page's own
|
|
446
|
-
* breadcrumb. A folder without one is just a label: vowel no longer
|
|
447
|
-
* generates a page per folder, so linking there would 404.
|
|
448
|
-
*/
|
|
449
|
-
// A secret folder's target segment is a hash, so its readable name has
|
|
450
|
-
// to come from the page's own *source* path - de-salted, because the
|
|
451
|
-
// source keeps the salt and the salt must never reach the page. The
|
|
452
|
-
// page has one ancestor per target segment and one per source segment,
|
|
453
|
-
// aligned by depth, which is what lets the two be zipped.
|
|
454
|
-
const sourceFolderNames = target.source
|
|
455
|
-
? displayPath(target.source).split(path.sep).slice(0, -1)
|
|
456
|
-
: []
|
|
457
|
-
|
|
458
|
-
function folderCrumb(folderPath, index) {
|
|
459
|
-
if (!folderPath) {
|
|
460
|
-
const home = api.target("index.html")
|
|
461
|
-
return { label: home?.metadata?.breadcrumb || "Home", href: "/" }
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// A folder's index is `<folder>.html`, from an authored `<folder>.md`
|
|
465
|
-
// beside it or the generated stub.
|
|
466
|
-
const indexTarget = api.target(`${folderPath}.html`)
|
|
467
|
-
// index 0 is the root, so ancestor i is source segment i - 1.
|
|
468
|
-
const sourceName = sourceFolderNames[index - 1]
|
|
469
|
-
const label = indexTarget?.metadata?.breadcrumb
|
|
470
|
-
|| toTitleCase(sourceName ?? folderPath.split(path.sep).at(-1))
|
|
471
|
-
// folderPath is a stored folder (path.sep); an href is a url.
|
|
472
|
-
return { label, href: indexTarget ? "/" + folderPath.split(path.sep).join("/") : null }
|
|
473
|
-
}
|
|
474
|
-
|
|
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.
|
|
475
592
|
treeBreadcrumbs.push(
|
|
476
593
|
...ancestorFolders
|
|
477
594
|
.map(folderCrumb)
|
|
478
595
|
.filter(crumb => crumb.label)
|
|
479
|
-
.map(crumb => crumb.href
|
|
480
|
-
? h('a', { href: crumb.href }, crumb.label)
|
|
481
|
-
: h('span', crumb.label))
|
|
596
|
+
.map(crumb => h('a', crumb.href ? { href: crumb.href } : {}, crumb.label))
|
|
482
597
|
)
|
|
483
598
|
|
|
484
599
|
if (!isRoot) {
|
|
@@ -503,7 +618,7 @@ function writeFile(target, { settings, api, config }) {
|
|
|
503
618
|
identityElements.push(h("img#logo", {
|
|
504
619
|
src: logo,
|
|
505
620
|
"aria-label": "Logo",
|
|
506
|
-
style: `--logo
|
|
621
|
+
style: `--url-logo: url("${logo}")`,
|
|
507
622
|
alt: ""
|
|
508
623
|
}))
|
|
509
624
|
}
|
|
@@ -554,10 +669,6 @@ function writeFile(target, { settings, api, config }) {
|
|
|
554
669
|
|
|
555
670
|
const treeTableOfContents = treeContentSlugged.children.shift()
|
|
556
671
|
|
|
557
|
-
const treeMainHead = makeFrontmatter(metadata, api, config)
|
|
558
|
-
|
|
559
|
-
treeMainHead.push(treeTableOfContents)
|
|
560
|
-
|
|
561
672
|
// One walk for everything that renders against other targets, settings
|
|
562
673
|
// or the url store: relative links, wikilinks, icons, code highlighting,
|
|
563
674
|
// images, directives, link previews and alerts. See writeRules.js.
|
|
@@ -565,6 +676,13 @@ function writeFile(target, { settings, api, config }) {
|
|
|
565
676
|
const walked = { target, api, config, settings, makeHeader, makeTable, highlighted: false }
|
|
566
677
|
writeWalk(abstract, walked)
|
|
567
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
|
+
|
|
568
686
|
if (walked.highlighted) {
|
|
569
687
|
// syntax-highlighting.css is always declared by the styles processor
|
|
570
688
|
// (a stub); a page only decides whether to link it.
|
|
@@ -586,20 +704,23 @@ function writeFile(target, { settings, api, config }) {
|
|
|
586
704
|
[
|
|
587
705
|
// The title is main's first child: the one piece of data hoisted out
|
|
588
706
|
// of the content regardless of where the author placed it.
|
|
589
|
-
...(metadata.title ? [h("h1", metadata.title)] : []),
|
|
590
|
-
// A
|
|
591
|
-
|
|
592
|
-
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', {
|
|
593
712
|
'aria-label': 'Breadcrumbs'
|
|
594
|
-
}, treeBreadcrumbs),
|
|
713
|
+
}, treeBreadcrumbs)),
|
|
595
714
|
treeMainHead,
|
|
596
715
|
h('section#content', abstract),
|
|
597
716
|
treeBacklinks
|
|
598
717
|
].filter(Boolean))
|
|
599
718
|
|
|
600
|
-
// 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.
|
|
601
721
|
const asidePartial = api.target(ASIDE_PARTIAL)
|
|
602
|
-
const
|
|
722
|
+
const asideNav = navWithChoices(asidePartial ? asidePartial.metadata.hast : null)
|
|
723
|
+
const treeAside = asideNav ? h("aside", asideNav) : null
|
|
603
724
|
|
|
604
725
|
const treeFooter = h('footer', [
|
|
605
726
|
socialLinksNav(settings),
|
|
@@ -612,14 +733,7 @@ function writeFile(target, { settings, api, config }) {
|
|
|
612
733
|
])
|
|
613
734
|
])
|
|
614
735
|
|
|
615
|
-
const
|
|
616
|
-
.split("/")
|
|
617
|
-
.filter(a => a)
|
|
618
|
-
.map(a => a.replace("_", ""))
|
|
619
|
-
.join("_")
|
|
620
|
-
|| "home"
|
|
621
|
-
|
|
622
|
-
const treeBody = h(`body.${pageClass}`, [
|
|
736
|
+
const treeBody = h("body", { className: pageClasses(target.metadata.prettyURL, target.source) }, [
|
|
623
737
|
treeHeader,
|
|
624
738
|
treeMain,
|
|
625
739
|
treeAside,
|
|
@@ -676,6 +790,7 @@ function makeTable(classes, properties, targets, api) {
|
|
|
676
790
|
|
|
677
791
|
const cell = (target, name) => {
|
|
678
792
|
const value = valueOf(target.metadata, name)
|
|
793
|
+
if (name === "title" && isPageWithoutFile(target)) return h("td", value ?? "")
|
|
679
794
|
if (name === "title") return h("td", h("a", { href: target.metadata.prettyURL }, value ?? target.metadata.prettyURL))
|
|
680
795
|
if (value === undefined || value === null) return h("td")
|
|
681
796
|
if (name === "image") return h("td", createDynamicImage(String(value), api, "") ?? String(value))
|
|
@@ -684,10 +799,12 @@ function makeTable(classes, properties, targets, api) {
|
|
|
684
799
|
return h("td", String(value))
|
|
685
800
|
}
|
|
686
801
|
|
|
687
|
-
|
|
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 }, [
|
|
688
805
|
h("thead", h("tr", properties.map(name => h("th", name)))),
|
|
689
806
|
h("tbody", targets.map(target => h("tr", properties.map(name => cell(target, name)))))
|
|
690
|
-
])
|
|
807
|
+
]))
|
|
691
808
|
}
|
|
692
809
|
|
|
693
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
|
}
|