@rathnasgala2/template 2.0.0
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 +201 -0
- package/README.md +515 -0
- package/contracts/render-policy.jcs +1 -0
- package/contracts/theme-styling-contract.jcs +1 -0
- package/package.json +96 -0
- package/src/core/errors.js +136 -0
- package/src/core/index.js +758 -0
- package/src/core/internal/appearance/bootstrap-script.js +159 -0
- package/src/core/internal/appearance/contract.js +193 -0
- package/src/core/internal/appearance/controller-markup.js +80 -0
- package/src/core/internal/appearance/styling-contract.js +509 -0
- package/src/core/internal/canonical-jcs.js +0 -0
- package/src/core/internal/content-security.js +0 -0
- package/src/core/internal/eleventy-render.js +220 -0
- package/src/core/internal/feeds.js +161 -0
- package/src/core/internal/fs-walk.js +38 -0
- package/src/core/internal/media/avif-probe.js +169 -0
- package/src/core/internal/media/font-policy.js +56 -0
- package/src/core/internal/media/jpeg-codec.js +0 -0
- package/src/core/internal/media/limits.js +129 -0
- package/src/core/internal/media/pipeline.js +353 -0
- package/src/core/internal/media/png-codec.js +406 -0
- package/src/core/internal/media/resize.js +147 -0
- package/src/core/internal/media/sniff.js +109 -0
- package/src/core/internal/media/webp-probe.js +139 -0
- package/src/core/internal/messages.js +78 -0
- package/src/core/internal/page-kinds.js +709 -0
- package/src/core/internal/provenance.js +49 -0
- package/src/core/internal/render-policy-content.js +295 -0
- package/src/core/internal/route-labels.js +68 -0
- package/src/core/internal/route.js +167 -0
- package/src/core/internal/search-index.js +119 -0
- package/src/core/internal/seo.js +114 -0
- package/src/core/internal/sitemap.js +77 -0
- package/src/core/internal/skeleton.js +413 -0
- package/src/core/internal/source-inventory.js +145 -0
- package/src/core/internal/text-direction.js +59 -0
- package/src/core/internal/theme-assets.js +387 -0
- package/src/core/internal/uuid.js +51 -0
- package/src/core/manifest.js +0 -0
- package/types/eleventy-plugin-syntaxhighlight.d.ts +26 -0
- package/types/eleventy.d.ts +38 -0
- package/types/index.d.ts +347 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-kind assembly (task packet S2-T06): builds every generated page's
|
|
3
|
+
* `<main>` content, breadcrumb trail and route from a validated
|
|
4
|
+
* `build-input:2.0.0` instance, and the shared site chrome (header, primary
|
|
5
|
+
* navigation, footer) every page kind is composed with through
|
|
6
|
+
* `internal/skeleton.js`'s {@link import('./skeleton.js').renderPageBody}.
|
|
7
|
+
*
|
|
8
|
+
* Page kinds this module produces, matching brief S2 section 3's "Required
|
|
9
|
+
* generated outputs" and task packet S2-T06's page-kind list:
|
|
10
|
+
*
|
|
11
|
+
* - `profile` — the publication's own optional profile page
|
|
12
|
+
* (`publication.profile`);
|
|
13
|
+
* - `author` — one page per entry in `build-input.authors` (DEC-097 section 5:
|
|
14
|
+
* this array is already exactly the set of authors referenced by the
|
|
15
|
+
* selected content, publication contact or footer card — never an
|
|
16
|
+
* unreferenced author), at `/authors/<id>`;
|
|
17
|
+
* - `article` / `page` — one page per `content[]` record, at its own
|
|
18
|
+
* `contentFrontmatterNormalized` route;
|
|
19
|
+
* - `index` — a paginated reverse-chronological listing of every
|
|
20
|
+
* `status: "published"` `kind: "article"` record (an `unlisted` record is
|
|
21
|
+
* reachable only by its own direct route or an authored navigation entry,
|
|
22
|
+
* never through a generated listing — the ordinary meaning of "unlisted");
|
|
23
|
+
* - `tag` / `series` — one listing page per distinct authored tag/series
|
|
24
|
+
* value, plus one `/tags`/`/series` root page linking to each, present only
|
|
25
|
+
* when at least one record declares that field;
|
|
26
|
+
* - `archive` — one listing page per UTC calendar year (from `publishedAt`),
|
|
27
|
+
* plus one `/archive` root page linking to each year.
|
|
28
|
+
*
|
|
29
|
+
* The `error` page kind ({@link renderErrorPageBody}) is exported as a pure
|
|
30
|
+
* function, not wired into any generated route here: brief S2 section 3
|
|
31
|
+
* assigns the one generated `404.html` artifact to S2-T08, which this task
|
|
32
|
+
* packet's own scope notes forbid touching. S2-T08 composes its `404.html`
|
|
33
|
+
* from this same function.
|
|
34
|
+
*
|
|
35
|
+
* Every generated page's own `route` field (see {@link GeneratedPage}) is an
|
|
36
|
+
* un-joined `canonicalRoute`, in the same convention `internal/index.js`
|
|
37
|
+
* already uses for content pages: the caller joins it with `basePath` via
|
|
38
|
+
* `internal/route.js`'s `joinBasePathAndRoute` before route projection. Every
|
|
39
|
+
* *link href* this module writes into a page's own body (breadcrumbs, tag and
|
|
40
|
+
* series listings, pagination controls) is, by contrast, already joined with
|
|
41
|
+
* `basePath` here, because it is read back only as literal HTML text, never
|
|
42
|
+
* re-joined by a caller. This mirrors DEC-097 section 5's navigation
|
|
43
|
+
* normalization rule: an *authored* navigation item's route is used exactly
|
|
44
|
+
* as authored, never synthesized or rewritten (so `internal/skeleton.js`'s
|
|
45
|
+
* navigation renderer never joins it), while every route this renderer itself
|
|
46
|
+
* derives (every page kind here) is basePath-joined at the point it becomes
|
|
47
|
+
* link text, exactly like S2-T03's own redirect/content route projection.
|
|
48
|
+
*
|
|
49
|
+
* Every listing here is a deliberately scoped decision this module documents
|
|
50
|
+
* rather than assumes: page size, sort tie-break and the tag/series/archive
|
|
51
|
+
* root pages are not specified verbatim by the brief text this task read.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import {
|
|
55
|
+
escapeHtml,
|
|
56
|
+
renderBreadcrumbs,
|
|
57
|
+
renderPagination,
|
|
58
|
+
renderSlot,
|
|
59
|
+
} from './skeleton.js';
|
|
60
|
+
import { getMessages } from './messages.js';
|
|
61
|
+
import { resolveTextDirection } from './text-direction.js';
|
|
62
|
+
import { routeSegmentForLabel } from './route-labels.js';
|
|
63
|
+
import { joinBasePathAndRoute } from './route.js';
|
|
64
|
+
|
|
65
|
+
/** Listing page size (index and per-year archive pages). Scoped decision:
|
|
66
|
+
* the brief does not fix a page size; 10 is a deterministic, documented
|
|
67
|
+
* default with no author-facing configuration surface in S2.
|
|
68
|
+
* @type {number} */
|
|
69
|
+
export const LISTING_PAGE_SIZE = 10;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The DEC-097-style semantic template-owned attribute this renderer sets on
|
|
73
|
+
* every generated page's `<body>` (S2-T12: `contracts/theme-styling-contract.jcs`
|
|
74
|
+
* exposes this as a public theme hook, one per {@link PAGE_KIND_VALUES}
|
|
75
|
+
* entry, so a theme can vary presentation by page kind — e.g. a different
|
|
76
|
+
* article vs. listing background — without the template ever promoting a
|
|
77
|
+
* private/incidental selector). Scoped decision: this attribute did not
|
|
78
|
+
* exist before S2-T12; it is this task packet's own addition, introduced
|
|
79
|
+
* specifically because the published styling contract needs a
|
|
80
|
+
* page-kind-targeting hook the brief's "every page-kind ... class/attribute"
|
|
81
|
+
* language calls for.
|
|
82
|
+
*
|
|
83
|
+
* @type {string}
|
|
84
|
+
*/
|
|
85
|
+
export const PAGE_KIND_ATTRIBUTE = 'data-gala-page-kind';
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Every value {@link PAGE_KIND_ATTRIBUTE} may carry: every {@link GeneratedPage}
|
|
89
|
+
* `kind` plus the synthetic `error` kind (`internal/index.js`'s always-present
|
|
90
|
+
* generated `404.html`), sorted by UTF-8 bytes (DEC-097's own attribute-value
|
|
91
|
+
* sort convention).
|
|
92
|
+
*
|
|
93
|
+
* @type {readonly string[]}
|
|
94
|
+
*/
|
|
95
|
+
export const PAGE_KIND_VALUES = Object.freeze(
|
|
96
|
+
[
|
|
97
|
+
'archive',
|
|
98
|
+
'article',
|
|
99
|
+
'author',
|
|
100
|
+
'error',
|
|
101
|
+
'index',
|
|
102
|
+
'page',
|
|
103
|
+
'profile',
|
|
104
|
+
'series',
|
|
105
|
+
'tag',
|
|
106
|
+
].sort(),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {import('../../../types/index.d.ts').ContentFrontmatterNormalized} frontmatter
|
|
111
|
+
* @returns {string} the record's own canonical route (un-joined)
|
|
112
|
+
*/
|
|
113
|
+
export function contentRoute(frontmatter) {
|
|
114
|
+
return frontmatter.route ?? `/${frontmatter.slug}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Split an array into fixed-size, order-preserving pages.
|
|
119
|
+
*
|
|
120
|
+
* @template T
|
|
121
|
+
* @param {readonly T[]} items items, already in final display order
|
|
122
|
+
* @param {number} pageSize maximum items per page (at least 1)
|
|
123
|
+
* @returns {T[][]} one array per page, always at least one (possibly empty)
|
|
124
|
+
* page
|
|
125
|
+
*/
|
|
126
|
+
function paginate(items, pageSize) {
|
|
127
|
+
if (items.length === 0) return [[]];
|
|
128
|
+
/** @type {T[][]} */
|
|
129
|
+
const pages = [];
|
|
130
|
+
for (let index = 0; index < items.length; index += pageSize) {
|
|
131
|
+
pages.push(items.slice(index, index + pageSize));
|
|
132
|
+
}
|
|
133
|
+
return pages;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Resolve an author's display name by ID, failing closed on a dangling
|
|
138
|
+
* reference (an adapter defect: `resolvedAuthorIds` is schema/DEC-097
|
|
139
|
+
* guaranteed to resolve).
|
|
140
|
+
*
|
|
141
|
+
* @param {ReadonlyMap<string, import('../../../types/index.d.ts').AuthorNormalized>} authorsById
|
|
142
|
+
* @param {string} authorId
|
|
143
|
+
* @returns {import('../../../types/index.d.ts').AuthorNormalized}
|
|
144
|
+
*/
|
|
145
|
+
function requireAuthor(authorsById, authorId) {
|
|
146
|
+
const author = authorsById.get(authorId);
|
|
147
|
+
if (!author) {
|
|
148
|
+
throw new Error(`internal: no author record for id ${authorId}`);
|
|
149
|
+
}
|
|
150
|
+
return author;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @typedef {object} GeneratedPage
|
|
155
|
+
* @property {'profile' | 'author' | 'article' | 'page' | 'index' | 'tag' | 'series' | 'archive'} kind
|
|
156
|
+
* @property {string} route an un-joined `canonicalRoute` (caller joins with
|
|
157
|
+
* `basePath`)
|
|
158
|
+
* @property {string} title the page's `<title>` text
|
|
159
|
+
* @property {string} language the page's BCP-47 language tag
|
|
160
|
+
* @property {string} [direction] the page's resolved base text direction (assigned by buildGeneratedPages's own return mapping)
|
|
161
|
+
* @property {string} bodyHtml the page's complete `<main>` inner HTML,
|
|
162
|
+
* starting with exactly one `<h1>`
|
|
163
|
+
* @property {string} [breadcrumbHtml] an optional already-rendered breadcrumb
|
|
164
|
+
* @property {string} [description] task packet S2-T08: an optional plain-text
|
|
165
|
+
* description this page's SEO/Open-Graph/Twitter `<meta>` tags are built
|
|
166
|
+
* from (never HTML, never the sanitized body — a distinct authored or
|
|
167
|
+
* publication-level plain-text field)
|
|
168
|
+
* @property {'website' | 'article' | 'profile'} [ogType] task packet S2-T08:
|
|
169
|
+
* the Open Graph `og:type` this page kind maps to; defaults to `'website'`
|
|
170
|
+
* when omitted
|
|
171
|
+
* @property {{path: string, sourceDigest: string} | undefined} [socialImageRef]
|
|
172
|
+
* task packet S2-T08: an optional `resolvedFile` reference this page's
|
|
173
|
+
* social image is resolved from (the caller, `src/core/index.js`, is the
|
|
174
|
+
* one place that can turn this into an absolute derivative URL, because
|
|
175
|
+
* only it has run the S2-T05 media pipeline and knows the derivative
|
|
176
|
+
* output path a given `sourceDigest` produced)
|
|
177
|
+
* @property {string} [robotsContent] task packet S2-T08: an optional
|
|
178
|
+
* `<meta name="robots">` content value (e.g. `'noindex, follow'` for
|
|
179
|
+
* `status: "unlisted"` content); omitted entirely for ordinarily indexable
|
|
180
|
+
* pages, matching this renderer's "documented, not silently assumed" style
|
|
181
|
+
* elsewhere — every page kind's robots decision is made once, here, rather
|
|
182
|
+
* than re-derived by every later consumer (sitemap, search index)
|
|
183
|
+
* @property {string} [lastModified] task packet S2-T08: an optional
|
|
184
|
+
* `rfc3339` timestamp the generated sitemap's `<lastmod>` for this page is
|
|
185
|
+
* drawn from — always derived from authored content timestamps
|
|
186
|
+
* (`publishedAt`/`updatedAt`), never build time (brief S2 section 3:
|
|
187
|
+
* "sitemap ... `lastmod` from content timestamps not build time"). Omitted
|
|
188
|
+
* for a page kind with no natural underlying timestamp (`profile`,
|
|
189
|
+
* `author`), which the generated sitemap then emits with no `<lastmod>`
|
|
190
|
+
* element at all (a sitemap's own `<lastmod>` is optional per the Sitemaps
|
|
191
|
+
* 0.9 protocol).
|
|
192
|
+
*/
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The reverse-chronological, deterministically tie-broken set of every
|
|
196
|
+
* `status: "published"`, `kind: "article"` record — the exact selection and
|
|
197
|
+
* ordering the `index` page kind paginates, and (task packet S2-T08) the
|
|
198
|
+
* same selection the generated feeds draw from, so a feed's item order can
|
|
199
|
+
* never drift from the index listing's own order. Extracted to its own
|
|
200
|
+
* export rather than duplicated, since `internal/fs-walk.js`'s duplication
|
|
201
|
+
* gate (`jscpd`, 3%/50 tokens) would otherwise flag a second copy of this
|
|
202
|
+
* sort comparator.
|
|
203
|
+
*
|
|
204
|
+
* @param {readonly import('../../../types/index.d.ts').ContentBuildRecord[]} content
|
|
205
|
+
* @returns {import('../../../types/index.d.ts').ContentBuildRecord[]} the
|
|
206
|
+
* selected records, most recently published first
|
|
207
|
+
*/
|
|
208
|
+
export function selectPublishedArticles(content) {
|
|
209
|
+
return content
|
|
210
|
+
.filter(
|
|
211
|
+
(record) =>
|
|
212
|
+
record.frontmatter.kind === 'article' &&
|
|
213
|
+
record.frontmatter.status === 'published',
|
|
214
|
+
)
|
|
215
|
+
.slice()
|
|
216
|
+
.sort((a, b) => {
|
|
217
|
+
if (a.frontmatter.publishedAt !== b.frontmatter.publishedAt) {
|
|
218
|
+
return a.frontmatter.publishedAt < b.frontmatter.publishedAt ? 1 : -1;
|
|
219
|
+
}
|
|
220
|
+
return a.frontmatter.id < b.frontmatter.id ? -1 : 1;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The most recent of a content record's own `updatedAt` (when authored) or
|
|
226
|
+
* `publishedAt` — this renderer's one definition of "a content record's own
|
|
227
|
+
* last-modified instant" (task packet S2-T08's sitemap `<lastmod>` and the
|
|
228
|
+
* static search index both read this, rather than each re-deriving it).
|
|
229
|
+
*
|
|
230
|
+
* @param {import('../../../types/index.d.ts').ContentFrontmatterNormalized} frontmatter
|
|
231
|
+
* @returns {string} an `rfc3339` timestamp
|
|
232
|
+
*/
|
|
233
|
+
export function contentLastModified(frontmatter) {
|
|
234
|
+
return frontmatter.updatedAt ?? frontmatter.publishedAt;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The latest {@link contentLastModified} across a non-empty set of records —
|
|
239
|
+
* this renderer's definition of "a generated listing page's own
|
|
240
|
+
* `<lastmod>`" (task packet S2-T08): the most recent authored timestamp
|
|
241
|
+
* among the content actually shown on that page, never build time.
|
|
242
|
+
*
|
|
243
|
+
* @param {readonly import('../../../types/index.d.ts').ContentBuildRecord[]} records
|
|
244
|
+
* @returns {string | undefined} the latest timestamp, or `undefined` for an
|
|
245
|
+
* empty set
|
|
246
|
+
*/
|
|
247
|
+
function latestContentTimestamp(records) {
|
|
248
|
+
return records.reduce(
|
|
249
|
+
/** @type {(latest: string | undefined, record: import('../../../types/index.d.ts').ContentBuildRecord) => string} */ (
|
|
250
|
+
latest,
|
|
251
|
+
record,
|
|
252
|
+
) => {
|
|
253
|
+
const candidate = contentLastModified(record.frontmatter);
|
|
254
|
+
return !latest || candidate > latest ? candidate : latest;
|
|
255
|
+
},
|
|
256
|
+
/** @type {string | undefined} */ (undefined),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Build every generated page (every kind except `error`) from a validated
|
|
262
|
+
* `build-input:2.0.0` instance.
|
|
263
|
+
*
|
|
264
|
+
* @param {import('../../../types/index.d.ts').NormalizedBuildInput} validatedInput
|
|
265
|
+
* @returns {GeneratedPage[]} every generated page, in a stable, deterministic
|
|
266
|
+
* order
|
|
267
|
+
*/
|
|
268
|
+
export function buildGeneratedPages(validatedInput) {
|
|
269
|
+
const { publication, authors, content, basePath } = validatedInput;
|
|
270
|
+
const messages = getMessages(publication.defaultLanguage);
|
|
271
|
+
const authorsById = new Map(authors.map((author) => [author.id, author]));
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* @param {string} route an un-joined route
|
|
275
|
+
* @returns {string} the `basePath`-joined absolute route
|
|
276
|
+
*/
|
|
277
|
+
const site = (route) => joinBasePathAndRoute(basePath, route);
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* @param {import('../../../types/index.d.ts').ContentBuildRecord} record
|
|
281
|
+
* @returns {string} one `<li>` summary
|
|
282
|
+
*/
|
|
283
|
+
const renderContentSummary = (record) => {
|
|
284
|
+
const { frontmatter } = record;
|
|
285
|
+
const route = escapeHtml(site(contentRoute(frontmatter)));
|
|
286
|
+
const title = escapeHtml(frontmatter.title);
|
|
287
|
+
const authorNames = frontmatter.authorIds
|
|
288
|
+
.map((id) => requireAuthor(authorsById, id).displayName)
|
|
289
|
+
.join(', ');
|
|
290
|
+
const byLine = escapeHtml(
|
|
291
|
+
/** @type {(displayName: string) => string} */ (messages.byLineLabel)(
|
|
292
|
+
authorNames,
|
|
293
|
+
),
|
|
294
|
+
);
|
|
295
|
+
const time = `<time datetime="${escapeHtml(frontmatter.publishedAt)}">${escapeHtml(frontmatter.publishedAt)}</time>`;
|
|
296
|
+
const tags =
|
|
297
|
+
frontmatter.tags.length > 0
|
|
298
|
+
? `<ul>${frontmatter.tags
|
|
299
|
+
.map(
|
|
300
|
+
(tag) =>
|
|
301
|
+
`<li><a href="${escapeHtml(site(`/tags/${routeSegmentForLabel(tag)}`))}">${escapeHtml(tag)}</a></li>`,
|
|
302
|
+
)
|
|
303
|
+
.join('')}</ul>`
|
|
304
|
+
: '';
|
|
305
|
+
return (
|
|
306
|
+
`<li><h2><a href="${route}">${title}</a></h2>` +
|
|
307
|
+
`<p>${byLine}</p>${time}${tags}</li>`
|
|
308
|
+
);
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
/** @type {GeneratedPage[]} */
|
|
312
|
+
const pages = [];
|
|
313
|
+
|
|
314
|
+
if (publication.profile) {
|
|
315
|
+
pages.push({
|
|
316
|
+
kind: 'profile',
|
|
317
|
+
route: publication.profile.route,
|
|
318
|
+
title: publication.title,
|
|
319
|
+
language: publication.defaultLanguage,
|
|
320
|
+
description: publication.description,
|
|
321
|
+
ogType: 'website',
|
|
322
|
+
socialImageRef: publication.defaultImage,
|
|
323
|
+
bodyHtml: `<h1>${escapeHtml(publication.title)}</h1>${publication.profile.body.body}`,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
for (const author of authors) {
|
|
328
|
+
const pronouns = author.pronouns
|
|
329
|
+
? `<p>${escapeHtml(author.pronouns)}</p>`
|
|
330
|
+
: '';
|
|
331
|
+
const biography = author.biography
|
|
332
|
+
? `<p>${escapeHtml(author.biography)}</p>`
|
|
333
|
+
: '';
|
|
334
|
+
const links =
|
|
335
|
+
author.links.length > 0
|
|
336
|
+
? `<ul>${author.links
|
|
337
|
+
.map((link) => {
|
|
338
|
+
const label = escapeHtml(link.label ?? link.uri);
|
|
339
|
+
return `<li><a href="${escapeHtml(link.uri)}">${label}</a></li>`;
|
|
340
|
+
})
|
|
341
|
+
.join('')}</ul>`
|
|
342
|
+
: '';
|
|
343
|
+
const heading = /** @type {(displayName: string) => string} */ (
|
|
344
|
+
messages.aboutAuthorHeading
|
|
345
|
+
)(author.displayName);
|
|
346
|
+
pages.push({
|
|
347
|
+
kind: 'author',
|
|
348
|
+
route: `/authors/${author.id}`,
|
|
349
|
+
title: heading,
|
|
350
|
+
language: publication.defaultLanguage,
|
|
351
|
+
description: author.biography || undefined,
|
|
352
|
+
ogType: 'profile',
|
|
353
|
+
socialImageRef: author.avatar,
|
|
354
|
+
breadcrumbHtml: renderBreadcrumbs({
|
|
355
|
+
trail: [
|
|
356
|
+
{
|
|
357
|
+
label: /** @type {string} */ (messages.homeLinkLabel),
|
|
358
|
+
route: site('/'),
|
|
359
|
+
},
|
|
360
|
+
{ label: /** @type {string} */ (messages.authorsSectionLabel) },
|
|
361
|
+
{ label: author.displayName },
|
|
362
|
+
],
|
|
363
|
+
messages,
|
|
364
|
+
}),
|
|
365
|
+
bodyHtml:
|
|
366
|
+
`<h1>${escapeHtml(heading)}</h1>` + pronouns + biography + links,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
for (const record of content) {
|
|
371
|
+
const { frontmatter } = record;
|
|
372
|
+
const authorNames = frontmatter.authorIds
|
|
373
|
+
.map((id) => requireAuthor(authorsById, id).displayName)
|
|
374
|
+
.join(', ');
|
|
375
|
+
const byLine = `<p>${escapeHtml(
|
|
376
|
+
/** @type {(displayName: string) => string} */ (messages.byLineLabel)(
|
|
377
|
+
authorNames,
|
|
378
|
+
),
|
|
379
|
+
)}</p>`;
|
|
380
|
+
const time = `<time datetime="${escapeHtml(frontmatter.publishedAt)}">${escapeHtml(frontmatter.publishedAt)}</time>`;
|
|
381
|
+
const tags =
|
|
382
|
+
frontmatter.tags.length > 0
|
|
383
|
+
? `<ul>${frontmatter.tags
|
|
384
|
+
.map(
|
|
385
|
+
(tag) =>
|
|
386
|
+
`<li><a href="${escapeHtml(site(`/tags/${routeSegmentForLabel(tag)}`))}">${escapeHtml(tag)}</a></li>`,
|
|
387
|
+
)
|
|
388
|
+
.join('')}</ul>`
|
|
389
|
+
: '';
|
|
390
|
+
const seriesHtml = frontmatter.series
|
|
391
|
+
? `<p><a href="${escapeHtml(site(`/series/${routeSegmentForLabel(frontmatter.series)}`))}">${escapeHtml(frontmatter.series)}</a></p>`
|
|
392
|
+
: '';
|
|
393
|
+
const breadcrumbTrail =
|
|
394
|
+
frontmatter.kind === 'article'
|
|
395
|
+
? [
|
|
396
|
+
{
|
|
397
|
+
label: /** @type {string} */ (messages.homeLinkLabel),
|
|
398
|
+
route: site('/'),
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
label: /** @type {string} */ (messages.indexHeading),
|
|
402
|
+
route: site('/'),
|
|
403
|
+
},
|
|
404
|
+
{ label: frontmatter.title },
|
|
405
|
+
]
|
|
406
|
+
: [
|
|
407
|
+
{
|
|
408
|
+
label: /** @type {string} */ (messages.homeLinkLabel),
|
|
409
|
+
route: site('/'),
|
|
410
|
+
},
|
|
411
|
+
{ label: frontmatter.title },
|
|
412
|
+
];
|
|
413
|
+
pages.push({
|
|
414
|
+
kind: frontmatter.kind,
|
|
415
|
+
route: contentRoute(frontmatter),
|
|
416
|
+
title: frontmatter.title,
|
|
417
|
+
language: frontmatter.language,
|
|
418
|
+
description: frontmatter.description,
|
|
419
|
+
ogType: frontmatter.kind === 'article' ? 'article' : 'website',
|
|
420
|
+
socialImageRef: frontmatter.socialImage ?? frontmatter.hero?.file,
|
|
421
|
+
robotsContent:
|
|
422
|
+
frontmatter.status === 'unlisted' ? 'noindex, follow' : undefined,
|
|
423
|
+
lastModified: contentLastModified(frontmatter),
|
|
424
|
+
breadcrumbHtml: renderBreadcrumbs({ trail: breadcrumbTrail, messages }),
|
|
425
|
+
bodyHtml:
|
|
426
|
+
`<article>` +
|
|
427
|
+
renderSlot('article-preamble') +
|
|
428
|
+
`<h1>${escapeHtml(frontmatter.title)}</h1>` +
|
|
429
|
+
byLine +
|
|
430
|
+
time +
|
|
431
|
+
seriesHtml +
|
|
432
|
+
tags +
|
|
433
|
+
record.body +
|
|
434
|
+
renderSlot('article-end') +
|
|
435
|
+
renderSlot('article-footer-ad', { collapsed: true }) +
|
|
436
|
+
`</article>`,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const publishedArticles = selectPublishedArticles(content);
|
|
441
|
+
|
|
442
|
+
const indexPages = paginate(publishedArticles, LISTING_PAGE_SIZE);
|
|
443
|
+
indexPages.forEach((items, pageIndex) => {
|
|
444
|
+
const pageNumber = pageIndex + 1;
|
|
445
|
+
const route = pageNumber === 1 ? '/' : `/page/${pageNumber}`;
|
|
446
|
+
pages.push({
|
|
447
|
+
kind: 'index',
|
|
448
|
+
route,
|
|
449
|
+
title: /** @type {string} */ (messages.indexHeading),
|
|
450
|
+
language: publication.defaultLanguage,
|
|
451
|
+
description: publication.description,
|
|
452
|
+
ogType: 'website',
|
|
453
|
+
socialImageRef: publication.defaultImage,
|
|
454
|
+
lastModified: latestContentTimestamp(items),
|
|
455
|
+
bodyHtml:
|
|
456
|
+
`<h1>${escapeHtml(/** @type {string} */ (messages.indexHeading))}</h1>` +
|
|
457
|
+
`<ul>${items.map((record) => renderContentSummary(record)).join('')}</ul>` +
|
|
458
|
+
renderPagination({
|
|
459
|
+
currentPage: pageNumber,
|
|
460
|
+
totalPages: indexPages.length,
|
|
461
|
+
routeForPage: (n) => site(n === 1 ? '/' : `/page/${n}`),
|
|
462
|
+
messages,
|
|
463
|
+
}),
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
/** @type {Map<string, import('../../../types/index.d.ts').ContentBuildRecord[]>} */
|
|
468
|
+
const byTag = new Map();
|
|
469
|
+
for (const record of publishedArticles) {
|
|
470
|
+
for (const tag of record.frontmatter.tags) {
|
|
471
|
+
const bucket = byTag.get(tag) ?? [];
|
|
472
|
+
bucket.push(record);
|
|
473
|
+
byTag.set(tag, bucket);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const sortedTags = [...byTag.keys()].sort();
|
|
477
|
+
if (sortedTags.length > 0) {
|
|
478
|
+
pages.push({
|
|
479
|
+
kind: 'tag',
|
|
480
|
+
route: '/tags',
|
|
481
|
+
title: /** @type {string} */ (messages.tagsSectionLabel),
|
|
482
|
+
language: publication.defaultLanguage,
|
|
483
|
+
description: publication.description,
|
|
484
|
+
ogType: 'website',
|
|
485
|
+
socialImageRef: publication.defaultImage,
|
|
486
|
+
lastModified: latestContentTimestamp(publishedArticles),
|
|
487
|
+
bodyHtml:
|
|
488
|
+
`<h1>${escapeHtml(/** @type {string} */ (messages.tagsSectionLabel))}</h1>` +
|
|
489
|
+
`<ul>${sortedTags
|
|
490
|
+
.map(
|
|
491
|
+
(tag) =>
|
|
492
|
+
`<li><a href="${escapeHtml(site(`/tags/${routeSegmentForLabel(tag)}`))}">${escapeHtml(tag)}</a></li>`,
|
|
493
|
+
)
|
|
494
|
+
.join('')}</ul>`,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
for (const tag of sortedTags) {
|
|
498
|
+
const items =
|
|
499
|
+
/** @type {import('../../../types/index.d.ts').ContentBuildRecord[]} */ (
|
|
500
|
+
byTag.get(tag)
|
|
501
|
+
);
|
|
502
|
+
const heading = `${/** @type {string} */ (messages.tagIndexLabelPrefix)} ${tag}`;
|
|
503
|
+
pages.push({
|
|
504
|
+
kind: 'tag',
|
|
505
|
+
route: `/tags/${routeSegmentForLabel(tag)}`,
|
|
506
|
+
title: heading,
|
|
507
|
+
language: publication.defaultLanguage,
|
|
508
|
+
ogType: 'website',
|
|
509
|
+
socialImageRef: publication.defaultImage,
|
|
510
|
+
lastModified: latestContentTimestamp(items),
|
|
511
|
+
breadcrumbHtml: renderBreadcrumbs({
|
|
512
|
+
trail: [
|
|
513
|
+
{
|
|
514
|
+
label: /** @type {string} */ (messages.homeLinkLabel),
|
|
515
|
+
route: site('/'),
|
|
516
|
+
},
|
|
517
|
+
{
|
|
518
|
+
label: /** @type {string} */ (messages.tagsSectionLabel),
|
|
519
|
+
route: site('/tags'),
|
|
520
|
+
},
|
|
521
|
+
{ label: tag },
|
|
522
|
+
],
|
|
523
|
+
messages,
|
|
524
|
+
}),
|
|
525
|
+
bodyHtml:
|
|
526
|
+
`<h1>${escapeHtml(heading)}</h1>` +
|
|
527
|
+
`<ul>${items.map((record) => renderContentSummary(record)).join('')}</ul>`,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** @type {Map<string, import('../../../types/index.d.ts').ContentBuildRecord[]>} */
|
|
532
|
+
const bySeries = new Map();
|
|
533
|
+
for (const record of content) {
|
|
534
|
+
const series = record.frontmatter.series;
|
|
535
|
+
if (!series || record.frontmatter.status !== 'published') continue;
|
|
536
|
+
const bucket = bySeries.get(series) ?? [];
|
|
537
|
+
bucket.push(record);
|
|
538
|
+
bySeries.set(series, bucket);
|
|
539
|
+
}
|
|
540
|
+
const sortedSeries = [...bySeries.keys()].sort();
|
|
541
|
+
if (sortedSeries.length > 0) {
|
|
542
|
+
pages.push({
|
|
543
|
+
kind: 'series',
|
|
544
|
+
route: '/series',
|
|
545
|
+
title: /** @type {string} */ (messages.seriesSectionLabel),
|
|
546
|
+
language: publication.defaultLanguage,
|
|
547
|
+
description: publication.description,
|
|
548
|
+
ogType: 'website',
|
|
549
|
+
socialImageRef: publication.defaultImage,
|
|
550
|
+
lastModified: latestContentTimestamp([...bySeries.values()].flat()),
|
|
551
|
+
bodyHtml:
|
|
552
|
+
`<h1>${escapeHtml(/** @type {string} */ (messages.seriesSectionLabel))}</h1>` +
|
|
553
|
+
`<ul>${sortedSeries
|
|
554
|
+
.map(
|
|
555
|
+
(series) =>
|
|
556
|
+
`<li><a href="${escapeHtml(site(`/series/${routeSegmentForLabel(series)}`))}">${escapeHtml(series)}</a></li>`,
|
|
557
|
+
)
|
|
558
|
+
.join('')}</ul>`,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
for (const series of sortedSeries) {
|
|
562
|
+
const items =
|
|
563
|
+
/** @type {import('../../../types/index.d.ts').ContentBuildRecord[]} */ (
|
|
564
|
+
bySeries.get(series)
|
|
565
|
+
)
|
|
566
|
+
.slice()
|
|
567
|
+
.sort(
|
|
568
|
+
(a, b) =>
|
|
569
|
+
(a.frontmatter.seriesOrder ?? 0) - (b.frontmatter.seriesOrder ?? 0),
|
|
570
|
+
);
|
|
571
|
+
const heading = `${/** @type {string} */ (messages.seriesIndexLabelPrefix)} ${series}`;
|
|
572
|
+
pages.push({
|
|
573
|
+
kind: 'series',
|
|
574
|
+
route: `/series/${routeSegmentForLabel(series)}`,
|
|
575
|
+
title: heading,
|
|
576
|
+
language: publication.defaultLanguage,
|
|
577
|
+
ogType: 'website',
|
|
578
|
+
socialImageRef: publication.defaultImage,
|
|
579
|
+
lastModified: latestContentTimestamp(items),
|
|
580
|
+
breadcrumbHtml: renderBreadcrumbs({
|
|
581
|
+
trail: [
|
|
582
|
+
{
|
|
583
|
+
label: /** @type {string} */ (messages.homeLinkLabel),
|
|
584
|
+
route: site('/'),
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
label: /** @type {string} */ (messages.seriesSectionLabel),
|
|
588
|
+
route: site('/series'),
|
|
589
|
+
},
|
|
590
|
+
{ label: series },
|
|
591
|
+
],
|
|
592
|
+
messages,
|
|
593
|
+
}),
|
|
594
|
+
bodyHtml:
|
|
595
|
+
`<h1>${escapeHtml(heading)}</h1>` +
|
|
596
|
+
`<ol>${items.map((record) => renderContentSummary(record)).join('')}</ol>`,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** @type {Map<string, import('../../../types/index.d.ts').ContentBuildRecord[]>} */
|
|
601
|
+
const byYear = new Map();
|
|
602
|
+
for (const record of publishedArticles) {
|
|
603
|
+
const year = record.frontmatter.publishedAt.slice(0, 4);
|
|
604
|
+
const bucket = byYear.get(year) ?? [];
|
|
605
|
+
bucket.push(record);
|
|
606
|
+
byYear.set(year, bucket);
|
|
607
|
+
}
|
|
608
|
+
const sortedYears = [...byYear.keys()].sort().reverse();
|
|
609
|
+
if (sortedYears.length > 0) {
|
|
610
|
+
pages.push({
|
|
611
|
+
kind: 'archive',
|
|
612
|
+
route: '/archive',
|
|
613
|
+
title: /** @type {string} */ (messages.archiveSectionLabel),
|
|
614
|
+
language: publication.defaultLanguage,
|
|
615
|
+
description: publication.description,
|
|
616
|
+
ogType: 'website',
|
|
617
|
+
socialImageRef: publication.defaultImage,
|
|
618
|
+
lastModified: latestContentTimestamp(publishedArticles),
|
|
619
|
+
bodyHtml:
|
|
620
|
+
`<h1>${escapeHtml(/** @type {string} */ (messages.archiveSectionLabel))}</h1>` +
|
|
621
|
+
`<ul>${sortedYears
|
|
622
|
+
.map(
|
|
623
|
+
(year) =>
|
|
624
|
+
`<li><a href="${escapeHtml(site(`/archive/${year}`))}">${escapeHtml(year)}</a></li>`,
|
|
625
|
+
)
|
|
626
|
+
.join('')}</ul>`,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
for (const year of sortedYears) {
|
|
630
|
+
const items =
|
|
631
|
+
/** @type {import('../../../types/index.d.ts').ContentBuildRecord[]} */ (
|
|
632
|
+
byYear.get(year)
|
|
633
|
+
);
|
|
634
|
+
const yearPages = paginate(items, LISTING_PAGE_SIZE);
|
|
635
|
+
yearPages.forEach((pageItems, pageIndex) => {
|
|
636
|
+
const pageNumber = pageIndex + 1;
|
|
637
|
+
const route =
|
|
638
|
+
pageNumber === 1
|
|
639
|
+
? `/archive/${year}`
|
|
640
|
+
: `/archive/${year}/page/${pageNumber}`;
|
|
641
|
+
const heading = `${/** @type {string} */ (messages.archiveYearLabelPrefix)} ${year}`;
|
|
642
|
+
pages.push({
|
|
643
|
+
kind: 'archive',
|
|
644
|
+
route,
|
|
645
|
+
title: heading,
|
|
646
|
+
language: publication.defaultLanguage,
|
|
647
|
+
ogType: 'website',
|
|
648
|
+
socialImageRef: publication.defaultImage,
|
|
649
|
+
lastModified: latestContentTimestamp(pageItems),
|
|
650
|
+
breadcrumbHtml: renderBreadcrumbs({
|
|
651
|
+
trail: [
|
|
652
|
+
{
|
|
653
|
+
label: /** @type {string} */ (messages.homeLinkLabel),
|
|
654
|
+
route: site('/'),
|
|
655
|
+
},
|
|
656
|
+
{
|
|
657
|
+
label: /** @type {string} */ (messages.archiveSectionLabel),
|
|
658
|
+
route: site('/archive'),
|
|
659
|
+
},
|
|
660
|
+
{ label: year },
|
|
661
|
+
],
|
|
662
|
+
messages,
|
|
663
|
+
}),
|
|
664
|
+
bodyHtml:
|
|
665
|
+
`<h1>${escapeHtml(heading)}</h1>` +
|
|
666
|
+
`<ul>${pageItems.map((record) => renderContentSummary(record)).join('')}</ul>` +
|
|
667
|
+
renderPagination({
|
|
668
|
+
currentPage: pageNumber,
|
|
669
|
+
totalPages: yearPages.length,
|
|
670
|
+
routeForPage: (n) =>
|
|
671
|
+
site(n === 1 ? `/archive/${year}` : `/archive/${year}/page/${n}`),
|
|
672
|
+
messages,
|
|
673
|
+
}),
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return pages.map((page) => ({
|
|
679
|
+
...page,
|
|
680
|
+
direction: resolveTextDirection(page.language),
|
|
681
|
+
}));
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Render the `error` page kind's `<main>` body (task packet S2-T06). This is
|
|
686
|
+
* a pure function with no route/build-input dependency, exported for a
|
|
687
|
+
* future caller (S2-T08's generated `404.html`, out of this task packet's
|
|
688
|
+
* scope) and for this repository's own structural/golden tests.
|
|
689
|
+
*
|
|
690
|
+
* @param {object} options rendering options
|
|
691
|
+
* @param {Readonly<Record<string, string | ((...args: string[]) => string)>>} options.messages
|
|
692
|
+
* the resolved message catalog
|
|
693
|
+
* @param {string} options.homeRoute the publication's own (already joined)
|
|
694
|
+
* home route
|
|
695
|
+
* @returns {string} the error page's `<main>` inner HTML, starting with
|
|
696
|
+
* exactly one `<h1>`
|
|
697
|
+
*/
|
|
698
|
+
export function renderErrorPageBody({ messages, homeRoute }) {
|
|
699
|
+
const heading = escapeHtml(/** @type {string} */ (messages.errorPageHeading));
|
|
700
|
+
const body = escapeHtml(/** @type {string} */ (messages.errorPageBody));
|
|
701
|
+
const returnLabel = escapeHtml(
|
|
702
|
+
/** @type {string} */ (messages.errorPageReturnHomeLabel),
|
|
703
|
+
);
|
|
704
|
+
return (
|
|
705
|
+
`<h1>${heading}</h1>` +
|
|
706
|
+
`<p>${body}</p>` +
|
|
707
|
+
`<p><a href="${escapeHtml(homeRoute)}">${returnLabel}</a></p>`
|
|
708
|
+
);
|
|
709
|
+
}
|