@rathnasgala2/template 2.1.0 → 3.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/README.md +95 -59
- package/contracts/render-policy.jcs +1 -1
- package/contracts/theme-styling-contract.jcs +1 -1
- package/package.json +2 -2
- package/src/core/index.js +209 -60
- package/src/core/internal/appearance/base-layer.js +711 -137
- package/src/core/internal/appearance/bootstrap-script.js +570 -83
- package/src/core/internal/appearance/contract.js +6 -5
- package/src/core/internal/appearance/controller-markup.js +46 -55
- package/src/core/internal/appearance/minify-script.js +69 -0
- package/src/core/internal/appearance/styling-contract.js +199 -17
- package/src/core/internal/chrome.js +199 -0
- package/src/core/internal/components.js +379 -0
- package/src/core/internal/eleventy-render.js +13 -4
- package/src/core/internal/feeds.js +79 -8
- package/src/core/internal/icons.js +52 -0
- package/src/core/internal/llms.js +252 -0
- package/src/core/internal/media/pipeline.js +32 -17
- package/src/core/internal/messages.js +104 -0
- package/src/core/internal/page-kinds.js +922 -293
- package/src/core/internal/render-policy-content.js +2 -2
- package/src/core/internal/robots.js +78 -0
- package/src/core/internal/route-labels.js +31 -0
- package/src/core/internal/route.js +42 -0
- package/src/core/internal/search-index.js +5 -2
- package/src/core/internal/seo.js +57 -2
- package/src/core/internal/sitemap.js +5 -4
- package/src/core/internal/skeleton.js +73 -132
- package/src/core/internal/structured-data.js +247 -0
- package/src/core/internal/theme-assets.js +21 -2
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { escapeHtml } from './skeleton.js';
|
|
22
|
-
import {
|
|
22
|
+
import { derivePublicBasePath, joinPublicRoute } from './route.js';
|
|
23
|
+
import { deriveAuthorSlugs } from './route-labels.js';
|
|
23
24
|
import {
|
|
24
25
|
contentLastModified,
|
|
25
26
|
contentRoute,
|
|
@@ -47,6 +48,52 @@ function toRfc822(rfc3339) {
|
|
|
47
48
|
return new Date(rfc3339).toUTCString();
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} value an attribute value as it appears in HTML source
|
|
53
|
+
* @returns {string} the value with the five predefined entities decoded
|
|
54
|
+
*/
|
|
55
|
+
function decodeAttribute(value) {
|
|
56
|
+
return value
|
|
57
|
+
.replace(/"/g, '"')
|
|
58
|
+
.replace(/'/g, "'")
|
|
59
|
+
.replace(/</g, '<')
|
|
60
|
+
.replace(/>/g, '>')
|
|
61
|
+
.replace(/&/g, '&');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Make every `href`/`src` in an article body absolute against the article's
|
|
66
|
+
* own URL: a feed reader renders the body away from the site, so a relative
|
|
67
|
+
* link or image would otherwise point nowhere.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} bodyHtml sanitized body HTML
|
|
70
|
+
* @param {string} pageUrl the article's absolute URL
|
|
71
|
+
* @returns {string} the body with absolute links and image sources
|
|
72
|
+
*/
|
|
73
|
+
export function absolutizeBodyLinks(bodyHtml, pageUrl) {
|
|
74
|
+
return bodyHtml.replace(
|
|
75
|
+
/\b(href|src)="([^"]*)"/g,
|
|
76
|
+
(match, attribute, value) => {
|
|
77
|
+
try {
|
|
78
|
+
return `${attribute}="${escapeHtml(new URL(decodeAttribute(value), pageUrl).toString())}"`;
|
|
79
|
+
} catch {
|
|
80
|
+
return match;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Wrap text in a CDATA section, splitting any `]]>` it contains so the
|
|
88
|
+
* section can never be closed early.
|
|
89
|
+
*
|
|
90
|
+
* @param {string} text
|
|
91
|
+
* @returns {string}
|
|
92
|
+
*/
|
|
93
|
+
function cdata(text) {
|
|
94
|
+
return `<![CDATA[${text.replaceAll(']]>', ']]]]><![CDATA[>')}]]>`;
|
|
95
|
+
}
|
|
96
|
+
|
|
50
97
|
/**
|
|
51
98
|
* Build the deterministic Atom 1.0 and RSS 2.0 feed documents for one
|
|
52
99
|
* validated `build-input:2.0.0` instance.
|
|
@@ -59,6 +106,7 @@ function toRfc822(rfc3339) {
|
|
|
59
106
|
export function buildFeeds(validatedInput) {
|
|
60
107
|
const { publication, authors, content, basePath, baseUrl } = validatedInput;
|
|
61
108
|
const authorsById = new Map(authors.map((author) => [author.id, author]));
|
|
109
|
+
const authorSlugs = deriveAuthorSlugs(authors);
|
|
62
110
|
const articles = selectPublishedArticles(content).slice(0, FEED_ITEM_LIMIT);
|
|
63
111
|
|
|
64
112
|
/**
|
|
@@ -66,7 +114,10 @@ export function buildFeeds(validatedInput) {
|
|
|
66
114
|
* @returns {string} the absolute `https://` URL
|
|
67
115
|
*/
|
|
68
116
|
const absoluteUrl = (route) =>
|
|
69
|
-
new URL(
|
|
117
|
+
new URL(
|
|
118
|
+
joinPublicRoute(derivePublicBasePath(baseUrl, basePath), route),
|
|
119
|
+
baseUrl,
|
|
120
|
+
).toString();
|
|
70
121
|
|
|
71
122
|
const siteUrl = absoluteUrl('/');
|
|
72
123
|
const atomSelfUrl = absoluteUrl(ATOM_FEED_ROUTE);
|
|
@@ -81,12 +132,19 @@ export function buildFeeds(validatedInput) {
|
|
|
81
132
|
.map((record) => {
|
|
82
133
|
const { frontmatter } = record;
|
|
83
134
|
const entryUrl = absoluteUrl(contentRoute(frontmatter));
|
|
84
|
-
const
|
|
85
|
-
(id) =>
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
135
|
+
const authorsXml = frontmatter.authorIds
|
|
136
|
+
.map((id) => {
|
|
137
|
+
const author = authorsById.get(id);
|
|
138
|
+
const uri = author
|
|
139
|
+
? `<uri>${escapeHtml(absoluteUrl(`/authors/${authorSlugs.get(id) ?? id}`))}</uri>`
|
|
140
|
+
: '';
|
|
141
|
+
return `<author><name>${escapeHtml(author?.displayName ?? '')}</name>${uri}</author>`;
|
|
142
|
+
})
|
|
143
|
+
.join('');
|
|
144
|
+
const categoriesXml = frontmatter.tags
|
|
145
|
+
.map((tag) => `<category term="${escapeHtml(tag)}"/>`)
|
|
89
146
|
.join('');
|
|
147
|
+
const contentXml = `<content type="html">${escapeHtml(absolutizeBodyLinks(record.body, entryUrl))}</content>`;
|
|
90
148
|
const summaryXml = frontmatter.description
|
|
91
149
|
? `<summary>${escapeHtml(frontmatter.description)}</summary>`
|
|
92
150
|
: '';
|
|
@@ -98,7 +156,9 @@ export function buildFeeds(validatedInput) {
|
|
|
98
156
|
`<published>${escapeHtml(frontmatter.publishedAt)}</published>` +
|
|
99
157
|
`<link href="${escapeHtml(entryUrl)}" rel="alternate"/>` +
|
|
100
158
|
authorsXml +
|
|
159
|
+
categoriesXml +
|
|
101
160
|
summaryXml +
|
|
161
|
+
contentXml +
|
|
102
162
|
`</entry>`
|
|
103
163
|
);
|
|
104
164
|
})
|
|
@@ -123,13 +183,24 @@ export function buildFeeds(validatedInput) {
|
|
|
123
183
|
const descriptionXml = frontmatter.description
|
|
124
184
|
? `<description>${escapeHtml(frontmatter.description)}</description>`
|
|
125
185
|
: '';
|
|
186
|
+
const creatorsXml = frontmatter.authorIds
|
|
187
|
+
.map((id) => authorsById.get(id)?.displayName)
|
|
188
|
+
.filter(Boolean)
|
|
189
|
+
.map((name) => `<dc:creator>${escapeHtml(String(name))}</dc:creator>`)
|
|
190
|
+
.join('');
|
|
191
|
+
const categoriesXml = frontmatter.tags
|
|
192
|
+
.map((tag) => `<category>${escapeHtml(tag)}</category>`)
|
|
193
|
+
.join('');
|
|
126
194
|
return (
|
|
127
195
|
`<item>` +
|
|
128
196
|
`<title>${escapeHtml(frontmatter.title)}</title>` +
|
|
129
197
|
`<link>${escapeHtml(itemUrl)}</link>` +
|
|
130
198
|
`<guid isPermaLink="true">${escapeHtml(itemUrl)}</guid>` +
|
|
131
199
|
`<pubDate>${escapeHtml(toRfc822(frontmatter.publishedAt))}</pubDate>` +
|
|
200
|
+
creatorsXml +
|
|
201
|
+
categoriesXml +
|
|
132
202
|
descriptionXml +
|
|
203
|
+
`<content:encoded>${cdata(absolutizeBodyLinks(record.body, itemUrl))}</content:encoded>` +
|
|
133
204
|
`</item>`
|
|
134
205
|
);
|
|
135
206
|
})
|
|
@@ -143,7 +214,7 @@ export function buildFeeds(validatedInput) {
|
|
|
143
214
|
// review request).
|
|
144
215
|
const rssXml =
|
|
145
216
|
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
146
|
-
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">' +
|
|
217
|
+
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">' +
|
|
147
218
|
'<channel>' +
|
|
148
219
|
`<title>${escapeHtml(publication.title)}</title>` +
|
|
149
220
|
`<link>${escapeHtml(siteUrl)}</link>` +
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The template's own fixed inline icon set. Chrome (header, footer, cards,
|
|
3
|
+
* article furniture) is template-owned markup, never author content, so
|
|
4
|
+
* these inline `<svg>` elements are emitted straight into the page and never
|
|
5
|
+
* pass through the author-content sanitizer in `content-security.js`. Every
|
|
6
|
+
* icon is decorative (`aria-hidden`, not focusable); a control that carries
|
|
7
|
+
* only an icon always has its own accessible name from the message catalog.
|
|
8
|
+
* No external icon file, font or network request is involved.
|
|
9
|
+
*
|
|
10
|
+
* Path data is the approved design reference's icon set (24x24 grid,
|
|
11
|
+
* stroke-based; fill/stroke colours come from the base layer, never here).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** @type {Readonly<Record<string, string>>} */
|
|
15
|
+
export const ICONS = Object.freeze({
|
|
16
|
+
search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
|
|
17
|
+
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
|
|
18
|
+
monitor:
|
|
19
|
+
'<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/>',
|
|
20
|
+
moon: '<path d="M20.5 14.5A8.5 8.5 0 1 1 9.5 3.5a7 7 0 0 0 11 11z"/>',
|
|
21
|
+
arrow: '<path d="M5 12h14M13 6l6 6-6 6"/>',
|
|
22
|
+
back: '<path d="M19 12H5M11 6l-6 6 6 6"/>',
|
|
23
|
+
upright: '<path d="M7 17 17 7M8 7h9v9"/>',
|
|
24
|
+
chevron: '<path d="m9 6 6 6-6 6"/>',
|
|
25
|
+
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
|
26
|
+
rss: '<path d="M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1.2"/>',
|
|
27
|
+
code: '<path d="m8 8-5 4 5 4M16 8l5 4-5 4"/>',
|
|
28
|
+
mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/>',
|
|
29
|
+
link: '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>',
|
|
30
|
+
bookmark: '<path d="M6 3h12v18l-6-4-6 4z"/>',
|
|
31
|
+
menu: '<path d="M4 7h16M4 12h16M4 17h16"/>',
|
|
32
|
+
hash: '<path d="M5 9h14M5 15h14M10 4 8 20M16 4l-2 16"/>',
|
|
33
|
+
book: '<path d="M3 5h6a3 3 0 0 1 3 3v12a2 2 0 0 0-2-2H3zM21 5h-6a3 3 0 0 0-3 3v12a2 2 0 0 1 2-2h7z"/>',
|
|
34
|
+
sparkle:
|
|
35
|
+
'<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 16l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7z"/>',
|
|
36
|
+
list: '<path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/>',
|
|
37
|
+
layers: '<path d="m12 3 9 5-9 5-9-5z"/><path d="m3 13 9 5 9-5"/>',
|
|
38
|
+
pen: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13 7 4 4"/>',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Render one inline icon.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} name a key of {@link ICONS}
|
|
45
|
+
* @returns {string} the `<svg>` markup, or an empty string for an unknown
|
|
46
|
+
* name (a defect the tests catch, never an author-reachable path)
|
|
47
|
+
*/
|
|
48
|
+
export function icon(name) {
|
|
49
|
+
const paths = ICONS[name];
|
|
50
|
+
if (paths === undefined) return '';
|
|
51
|
+
return `<svg class="g-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${paths}</svg>`;
|
|
52
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `llms.txt` and `llms-full.txt` generation (the llmstxt.org format): a
|
|
3
|
+
* Markdown index of the publication for language-model assistants, and one
|
|
4
|
+
* plain-text file carrying every article in full.
|
|
5
|
+
*
|
|
6
|
+
* Only `status: "published"` content is listed. Unlisted content is
|
|
7
|
+
* reachable by direct link and marked `noindex`; it is never advertised
|
|
8
|
+
* here either.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { derivePublicBasePath, joinPublicRoute } from './route.js';
|
|
12
|
+
import { routeSegmentForLabel } from './route-labels.js';
|
|
13
|
+
import { compareUtf8Bytes } from './source-inventory.js';
|
|
14
|
+
import {
|
|
15
|
+
contentLastModified,
|
|
16
|
+
contentRoute,
|
|
17
|
+
selectPublishedArticles,
|
|
18
|
+
} from './page-kinds.js';
|
|
19
|
+
import { getMessages } from './messages.js';
|
|
20
|
+
|
|
21
|
+
/** @type {Readonly<Record<string, string>>} */
|
|
22
|
+
const NAMED_ENTITIES = Object.freeze({
|
|
23
|
+
amp: '&',
|
|
24
|
+
lt: '<',
|
|
25
|
+
gt: '>',
|
|
26
|
+
quot: '"',
|
|
27
|
+
apos: "'",
|
|
28
|
+
nbsp: ' ',
|
|
29
|
+
hellip: '…',
|
|
30
|
+
mdash: '—',
|
|
31
|
+
ndash: '–',
|
|
32
|
+
lsquo: '‘',
|
|
33
|
+
rsquo: '’',
|
|
34
|
+
ldquo: '“',
|
|
35
|
+
rdquo: '”',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Convert a sanitized HTML fragment to readable plain text. Tags are removed
|
|
40
|
+
* first and entities decoded afterwards, so escaped markup in the source
|
|
41
|
+
* stays literal text and can never be turned back into a tag.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} html a render-policy-conformant HTML fragment
|
|
44
|
+
* @returns {string} plain text with paragraph breaks, `#` headings and `-`
|
|
45
|
+
* list items
|
|
46
|
+
*/
|
|
47
|
+
export function htmlToText(html) {
|
|
48
|
+
const stripped = html
|
|
49
|
+
.replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, '')
|
|
50
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
51
|
+
.replace(
|
|
52
|
+
/<h([1-6])\b[^>]*>/gi,
|
|
53
|
+
(_match, level) => `\n\n${'#'.repeat(Number(level))} `,
|
|
54
|
+
)
|
|
55
|
+
.replace(/<li\b[^>]*>/gi, '\n- ')
|
|
56
|
+
.replace(
|
|
57
|
+
/<\/(?:p|div|h[1-6]|pre|blockquote|ul|ol|table|tr|figure|section)\s*>/gi,
|
|
58
|
+
'\n\n',
|
|
59
|
+
)
|
|
60
|
+
.replace(/<[^>]*>/g, '');
|
|
61
|
+
return stripped
|
|
62
|
+
.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, body) => {
|
|
63
|
+
if (body[0] === '#') {
|
|
64
|
+
const code =
|
|
65
|
+
body[1] === 'x' || body[1] === 'X'
|
|
66
|
+
? Number.parseInt(body.slice(2), 16)
|
|
67
|
+
: Number.parseInt(body.slice(1), 10);
|
|
68
|
+
return Number.isInteger(code) && code > 0 && code <= 0x10ffff
|
|
69
|
+
? String.fromCodePoint(code)
|
|
70
|
+
: '';
|
|
71
|
+
}
|
|
72
|
+
return NAMED_ENTITIES[body.toLowerCase()] ?? match;
|
|
73
|
+
})
|
|
74
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
75
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
76
|
+
.trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build both documents.
|
|
81
|
+
*
|
|
82
|
+
* @param {import('../../../types/index.d.ts').NormalizedBuildInput} validatedInput
|
|
83
|
+
* @param {object} urls absolute URLs of the sibling documents
|
|
84
|
+
* @param {string} urls.rss
|
|
85
|
+
* @param {string} urls.atom
|
|
86
|
+
* @param {string} urls.sitemap
|
|
87
|
+
* @param {string} urls.searchIndex
|
|
88
|
+
* @param {string} urls.full this file's `llms-full.txt` sibling
|
|
89
|
+
* @returns {{llmsTxt: string, llmsFullTxt: string}} the exact UTF-8 texts
|
|
90
|
+
*/
|
|
91
|
+
export function buildLlmsDocuments(validatedInput, urls) {
|
|
92
|
+
const { publication, authors, content, basePath, baseUrl } = validatedInput;
|
|
93
|
+
const messages = getMessages(publication.defaultLanguage);
|
|
94
|
+
const authorsById = new Map(authors.map((author) => [author.id, author]));
|
|
95
|
+
/**
|
|
96
|
+
* @param {string} route an un-joined route
|
|
97
|
+
* @returns {string} the absolute URL
|
|
98
|
+
*/
|
|
99
|
+
const absoluteUrl = (route) =>
|
|
100
|
+
new URL(
|
|
101
|
+
joinPublicRoute(derivePublicBasePath(baseUrl, basePath), route),
|
|
102
|
+
baseUrl,
|
|
103
|
+
).toString();
|
|
104
|
+
const oneLine = (/** @type {string} */ value) =>
|
|
105
|
+
value.replace(/\s+/g, ' ').trim();
|
|
106
|
+
|
|
107
|
+
const articles = selectPublishedArticles(content);
|
|
108
|
+
const pages = content
|
|
109
|
+
.filter(
|
|
110
|
+
(record) =>
|
|
111
|
+
record.frontmatter.kind === 'page' &&
|
|
112
|
+
record.frontmatter.status === 'published',
|
|
113
|
+
)
|
|
114
|
+
.sort((a, b) =>
|
|
115
|
+
compareUtf8Bytes(
|
|
116
|
+
contentRoute(a.frontmatter),
|
|
117
|
+
contentRoute(b.frontmatter),
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @param {{title: string, url: string, description?: string}} item
|
|
123
|
+
* @returns {string} one list line
|
|
124
|
+
*/
|
|
125
|
+
const link = ({ title, url, description }) =>
|
|
126
|
+
`- [${oneLine(title).replace(/[[\]]/g, '')}](${url})` +
|
|
127
|
+
(description ? `: ${oneLine(description)}` : '');
|
|
128
|
+
|
|
129
|
+
/** @type {Map<string, number>} */
|
|
130
|
+
const seriesCounts = new Map();
|
|
131
|
+
for (const record of [...articles, ...pages]) {
|
|
132
|
+
const series = record.frontmatter.series;
|
|
133
|
+
if (series) seriesCounts.set(series, (seriesCounts.get(series) ?? 0) + 1);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const authorNames = authors.map((author) => author.displayName);
|
|
137
|
+
const intro =
|
|
138
|
+
`${articles.length === 1 ? '1 article' : `${articles.length} articles`}` +
|
|
139
|
+
(authorNames.length > 0 ? ` by ${authorNames.join(', ')}` : '') +
|
|
140
|
+
`. Each article is a standalone page; the full text of every article is in llms-full.txt.`;
|
|
141
|
+
|
|
142
|
+
/** @type {string[]} */
|
|
143
|
+
const lines = [
|
|
144
|
+
`# ${oneLine(publication.title)}`,
|
|
145
|
+
'',
|
|
146
|
+
`> ${oneLine(publication.description)}`,
|
|
147
|
+
'',
|
|
148
|
+
intro,
|
|
149
|
+
'',
|
|
150
|
+
'## Articles',
|
|
151
|
+
'',
|
|
152
|
+
...articles.map((record) =>
|
|
153
|
+
link({
|
|
154
|
+
title: record.frontmatter.title,
|
|
155
|
+
url: absoluteUrl(contentRoute(record.frontmatter)),
|
|
156
|
+
description: record.frontmatter.description,
|
|
157
|
+
}),
|
|
158
|
+
),
|
|
159
|
+
];
|
|
160
|
+
if (pages.length > 0) {
|
|
161
|
+
lines.push(
|
|
162
|
+
'',
|
|
163
|
+
'## Pages',
|
|
164
|
+
'',
|
|
165
|
+
...pages.map((record) =>
|
|
166
|
+
link({
|
|
167
|
+
title: record.frontmatter.title,
|
|
168
|
+
url: absoluteUrl(contentRoute(record.frontmatter)),
|
|
169
|
+
description: record.frontmatter.description,
|
|
170
|
+
}),
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (seriesCounts.size > 0) {
|
|
175
|
+
lines.push(
|
|
176
|
+
'',
|
|
177
|
+
'## Series',
|
|
178
|
+
'',
|
|
179
|
+
...[...seriesCounts.keys()].sort().map((name) =>
|
|
180
|
+
link({
|
|
181
|
+
title: name,
|
|
182
|
+
url: absoluteUrl(`/series/${routeSegmentForLabel(name)}`),
|
|
183
|
+
description:
|
|
184
|
+
/** @type {(c: string, n: string, p: string) => string} */ (
|
|
185
|
+
messages.seriesPageDescription
|
|
186
|
+
)(String(seriesCounts.get(name)), name, publication.title),
|
|
187
|
+
}),
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
lines.push(
|
|
192
|
+
'',
|
|
193
|
+
'## Optional',
|
|
194
|
+
'',
|
|
195
|
+
...(articles.length > 0
|
|
196
|
+
? [
|
|
197
|
+
link({
|
|
198
|
+
title: 'Archive',
|
|
199
|
+
url: absoluteUrl('/archive'),
|
|
200
|
+
description: 'Every article by year',
|
|
201
|
+
}),
|
|
202
|
+
]
|
|
203
|
+
: []),
|
|
204
|
+
link({
|
|
205
|
+
title: 'Full text of every article',
|
|
206
|
+
url: urls.full,
|
|
207
|
+
description: 'Plain text',
|
|
208
|
+
}),
|
|
209
|
+
link({ title: 'RSS feed', url: urls.rss }),
|
|
210
|
+
link({ title: 'Atom feed', url: urls.atom }),
|
|
211
|
+
link({ title: 'Sitemap', url: urls.sitemap }),
|
|
212
|
+
link({
|
|
213
|
+
title: 'Search index',
|
|
214
|
+
url: urls.searchIndex,
|
|
215
|
+
description: 'JSON',
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
const fullSections = articles.map((record) => {
|
|
220
|
+
const { frontmatter } = record;
|
|
221
|
+
const names = frontmatter.authorIds
|
|
222
|
+
.map((id) => authorsById.get(id)?.displayName)
|
|
223
|
+
.filter(Boolean);
|
|
224
|
+
return [
|
|
225
|
+
`# ${oneLine(frontmatter.title)}`,
|
|
226
|
+
'',
|
|
227
|
+
`URL: ${absoluteUrl(contentRoute(frontmatter))}`,
|
|
228
|
+
`Published: ${frontmatter.publishedAt}`,
|
|
229
|
+
...(frontmatter.updatedAt
|
|
230
|
+
? [`Updated: ${contentLastModified(frontmatter)}`]
|
|
231
|
+
: []),
|
|
232
|
+
...(names.length > 0 ? [`Author: ${names.join(', ')}`] : []),
|
|
233
|
+
...(frontmatter.tags.length > 0
|
|
234
|
+
? [`Tags: ${frontmatter.tags.join(', ')}`]
|
|
235
|
+
: []),
|
|
236
|
+
...(frontmatter.description
|
|
237
|
+
? ['', `> ${oneLine(frontmatter.description)}`]
|
|
238
|
+
: []),
|
|
239
|
+
'',
|
|
240
|
+
htmlToText(record.body),
|
|
241
|
+
].join('\n');
|
|
242
|
+
});
|
|
243
|
+
const llmsFullTxt =
|
|
244
|
+
[
|
|
245
|
+
`# ${oneLine(publication.title)}`,
|
|
246
|
+
'',
|
|
247
|
+
`> ${oneLine(publication.description)}`,
|
|
248
|
+
...fullSections.flatMap((section) => ['', '---', '', section]),
|
|
249
|
+
].join('\n') + '\n';
|
|
250
|
+
|
|
251
|
+
return { llmsTxt: `${lines.join('\n')}\n`, llmsFullTxt };
|
|
252
|
+
}
|
|
@@ -192,7 +192,9 @@ function derivativePath(sourceDigest, variant, extension) {
|
|
|
192
192
|
*
|
|
193
193
|
* @param {Buffer} bytes verified source bytes
|
|
194
194
|
* @param {ImageReference} reference the reference these bytes back
|
|
195
|
-
* @returns {PipelineOutputFile[]
|
|
195
|
+
* @returns {{files: PipelineOutputFile[], width: number, height: number}}
|
|
196
|
+
* the output files this reference produces and the canonical original's
|
|
197
|
+
* pixel dimensions
|
|
196
198
|
*/
|
|
197
199
|
function processImage(bytes, reference) {
|
|
198
200
|
const format = sniffMediaFormat(bytes);
|
|
@@ -214,18 +216,21 @@ function processImage(bytes, reference) {
|
|
|
214
216
|
if (format === 'webp' || format === 'avif') {
|
|
215
217
|
// Bounded container/dimension validation only; see webp-probe.js and
|
|
216
218
|
// avif-probe.js module documentation for the documented scope decision.
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
219
|
+
const size =
|
|
220
|
+
format === 'webp'
|
|
221
|
+
? probeWebp(bytes, reference.path)
|
|
222
|
+
: probeAvif(bytes, reference.path);
|
|
222
223
|
const extension = EXTENSION_BY_MEDIA_TYPE[MEDIA_TYPE_BY_FORMAT[format]];
|
|
223
|
-
return
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
224
|
+
return {
|
|
225
|
+
files: [
|
|
226
|
+
{
|
|
227
|
+
path: derivativePath(reference.sourceDigest, 'original', extension),
|
|
228
|
+
bytes,
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
width: size.width,
|
|
232
|
+
height: size.height,
|
|
233
|
+
};
|
|
229
234
|
}
|
|
230
235
|
|
|
231
236
|
/** @type {{width: number, height: number, rgba: Buffer}} */
|
|
@@ -261,7 +266,7 @@ function processImage(bytes, reference) {
|
|
|
261
266
|
bytes: encode(resized),
|
|
262
267
|
});
|
|
263
268
|
}
|
|
264
|
-
return outputs;
|
|
269
|
+
return { files: outputs, width: oriented.width, height: oriented.height };
|
|
265
270
|
}
|
|
266
271
|
|
|
267
272
|
/**
|
|
@@ -272,8 +277,11 @@ function processImage(bytes, reference) {
|
|
|
272
277
|
* the validated build input
|
|
273
278
|
* @param {{sourceDirectory: string}} options the media pipeline's own
|
|
274
279
|
* options
|
|
275
|
-
* @returns {Promise<{assets: import('../../../../types/index.d.ts').ManifestAssetEntry[], files: PipelineOutputFile[]}>}
|
|
276
|
-
* the asset manifest rows
|
|
280
|
+
* @returns {Promise<{assets: import('../../../../types/index.d.ts').ManifestAssetEntry[], files: PipelineOutputFile[], dimensions: Record<string, {width: number, height: number}>}>}
|
|
281
|
+
* the asset manifest rows, the output files backing them, and the pixel
|
|
282
|
+
* dimensions of each image's canonical original keyed by its output path
|
|
283
|
+
* (`manifestAsset` has no width/height field, so this is the one place the
|
|
284
|
+
* renderer learns them, for `<img width height>` and structured data)
|
|
277
285
|
*/
|
|
278
286
|
export async function processMedia(buildInput, { sourceDirectory }) {
|
|
279
287
|
const imageReferences = collectImageReferences(buildInput);
|
|
@@ -295,14 +303,21 @@ export async function processMedia(buildInput, { sourceDirectory }) {
|
|
|
295
303
|
|
|
296
304
|
/** @type {PipelineOutputFile[]} */
|
|
297
305
|
const files = [];
|
|
306
|
+
/** @type {Record<string, {width: number, height: number}>} */
|
|
307
|
+
const dimensions = {};
|
|
298
308
|
let totalBytes = 0;
|
|
299
309
|
|
|
300
310
|
for (const reference of imageReferences) {
|
|
301
311
|
const bytes = await readAndVerify(sourceDirectory, reference);
|
|
302
|
-
|
|
312
|
+
const processed = processImage(bytes, reference);
|
|
313
|
+
for (const output of processed.files) {
|
|
303
314
|
totalBytes += output.bytes.byteLength;
|
|
304
315
|
files.push(output);
|
|
305
316
|
}
|
|
317
|
+
dimensions[processed.files[0].path] = {
|
|
318
|
+
width: processed.width,
|
|
319
|
+
height: processed.height,
|
|
320
|
+
};
|
|
306
321
|
}
|
|
307
322
|
|
|
308
323
|
for (const reference of fontReferences) {
|
|
@@ -349,5 +364,5 @@ export async function processMedia(buildInput, { sourceDirectory }) {
|
|
|
349
364
|
});
|
|
350
365
|
});
|
|
351
366
|
|
|
352
|
-
return { assets, files };
|
|
367
|
+
return { assets, files, dimensions };
|
|
353
368
|
}
|
|
@@ -56,6 +56,110 @@ const CATALOGS = Object.freeze({
|
|
|
56
56
|
appearanceModeLightLabel: 'Light',
|
|
57
57
|
appearanceModeDarkLabel: 'Dark',
|
|
58
58
|
appearanceModeSystemLabel: 'System',
|
|
59
|
+
searchButtonLabel: 'Search',
|
|
60
|
+
searchButtonAriaLabel: 'Search articles',
|
|
61
|
+
searchPlaceholderLabel: 'Search the publication',
|
|
62
|
+
searchEmptyLabel: 'No articles match your search.',
|
|
63
|
+
searchUnavailableLabel: 'Search is unavailable right now.',
|
|
64
|
+
copyCodeLabel: 'Copy',
|
|
65
|
+
copiedLabel: 'Copied',
|
|
66
|
+
linkCopiedLabel: 'Link copied.',
|
|
67
|
+
copyBlockedLabel:
|
|
68
|
+
'Copying is blocked here. Copy the address from your browser.',
|
|
69
|
+
codeSelectedLabel: 'Code selected. Press Ctrl C or Command C to copy.',
|
|
70
|
+
savedLabel: 'Saved for later.',
|
|
71
|
+
unsavedLabel: 'Removed from saved articles.',
|
|
72
|
+
menuSummaryLabel: 'Menu',
|
|
73
|
+
featuredLabel: 'Featured',
|
|
74
|
+
readEssayLabel: 'Read the essay',
|
|
75
|
+
latestHeading: 'Latest',
|
|
76
|
+
moreArticlesHeading: 'More articles',
|
|
77
|
+
browseByTagLabel: 'Browse by tag',
|
|
78
|
+
seriesCardLabel: 'Series',
|
|
79
|
+
/** @type {(count: string) => string} */
|
|
80
|
+
partsCountLabel: (count) => `${count} parts`,
|
|
81
|
+
startSeriesLabel: 'Start with part 1',
|
|
82
|
+
newsletterLabel: 'Newsletter',
|
|
83
|
+
newsletterActionLabel: 'Subscribe',
|
|
84
|
+
/** @type {(minutes: string) => string} */
|
|
85
|
+
readingTimeLabel: (minutes) => `${minutes} min read`,
|
|
86
|
+
/** @type {(part: string, total: string) => string} */
|
|
87
|
+
seriesPartLabel: (part, total) => `Part ${part} of ${total}`,
|
|
88
|
+
onThisPageLabel: 'On this page',
|
|
89
|
+
writtenByLabel: 'Written by',
|
|
90
|
+
aboutTheAuthorLabel: 'About the author',
|
|
91
|
+
youAreHereLabel: 'You are here',
|
|
92
|
+
newerArticleLabel: 'Newer',
|
|
93
|
+
olderArticleLabel: 'Older',
|
|
94
|
+
moreArticlesNavigationLabel: 'More articles',
|
|
95
|
+
keepReadingHeading: 'Keep reading',
|
|
96
|
+
/** @type {(tag: string) => string} */
|
|
97
|
+
moreInTagLabel: (tag) => `More in ${tag}`,
|
|
98
|
+
shareLabel: 'Share',
|
|
99
|
+
copyLinkLabel: 'Copy link to this article',
|
|
100
|
+
saveForLaterLabel: 'Save for later',
|
|
101
|
+
tagListLabel: 'Tags',
|
|
102
|
+
tagKickerLabel: 'Tag',
|
|
103
|
+
seriesKickerLabel: 'Series',
|
|
104
|
+
archiveKickerLabel: 'Archive',
|
|
105
|
+
authorKickerLabel: 'Author',
|
|
106
|
+
/** @type {(count: string) => string} */
|
|
107
|
+
articleCountLabel: (count) =>
|
|
108
|
+
count === '1' ? '1 article' : `${count} articles`,
|
|
109
|
+
emptyListingLabel: 'No articles yet.',
|
|
110
|
+
aboutPageTitle: 'About',
|
|
111
|
+
/** @type {(date: string) => string} */
|
|
112
|
+
updatedLabel: (date) => `Updated ${date}`,
|
|
113
|
+
/** @type {(tag: string) => string} */
|
|
114
|
+
tagPageTitle: (tag) => `Posts tagged \u201c${tag}\u201d`,
|
|
115
|
+
/** @type {(count: string, tag: string, publication: string) => string} */
|
|
116
|
+
tagPageDescription: (count, tag, publication) =>
|
|
117
|
+
`${count === '1' ? '1 article' : `${count} articles`} tagged ${tag} on ${publication}.`,
|
|
118
|
+
/** @type {(count: string, name: string, publication: string) => string} */
|
|
119
|
+
seriesPageDescription: (count, name, publication) =>
|
|
120
|
+
`${name}, a series of ${count === '1' ? '1 part' : `${count} parts`} on ${publication}.`,
|
|
121
|
+
/** @type {(year: string) => string} */
|
|
122
|
+
archivePageTitle: (year) => `Archive ${year}`,
|
|
123
|
+
/** @type {(count: string, year: string, publication: string) => string} */
|
|
124
|
+
archivePageDescription: (count, year, publication) =>
|
|
125
|
+
`${count === '1' ? '1 article' : `${count} articles`} published in ${year} on ${publication}.`,
|
|
126
|
+
/** @type {(name: string, publication: string) => string} */
|
|
127
|
+
authorPageDescription: (name, publication) =>
|
|
128
|
+
`Articles written by ${name} on ${publication}.`,
|
|
129
|
+
/** @type {(count: string, publication: string) => string} */
|
|
130
|
+
tagsRootDescription: (count, publication) =>
|
|
131
|
+
`Browse the ${count} topics covered on ${publication}.`,
|
|
132
|
+
/** @type {(count: string, publication: string) => string} */
|
|
133
|
+
seriesRootDescription: (count, publication) =>
|
|
134
|
+
`Browse the ${count} series published on ${publication}.`,
|
|
135
|
+
/** @type {(count: string, publication: string) => string} */
|
|
136
|
+
archiveRootDescription: (count, publication) =>
|
|
137
|
+
`Every article on ${publication}, ${count} in all, by year.`,
|
|
138
|
+
/** @type {(publication: string) => string} */
|
|
139
|
+
indexPageDescription: (publication) =>
|
|
140
|
+
`All articles on ${publication}, newest first.`,
|
|
141
|
+
exploreColumnLabel: 'Explore',
|
|
142
|
+
rssFeedLabel: 'RSS feed',
|
|
143
|
+
/** @type {(iso: string) => string} */
|
|
144
|
+
formatDate: (iso) => {
|
|
145
|
+
const months = [
|
|
146
|
+
'Jan',
|
|
147
|
+
'Feb',
|
|
148
|
+
'Mar',
|
|
149
|
+
'Apr',
|
|
150
|
+
'May',
|
|
151
|
+
'Jun',
|
|
152
|
+
'Jul',
|
|
153
|
+
'Aug',
|
|
154
|
+
'Sep',
|
|
155
|
+
'Oct',
|
|
156
|
+
'Nov',
|
|
157
|
+
'Dec',
|
|
158
|
+
];
|
|
159
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
|
|
160
|
+
if (!match) return iso;
|
|
161
|
+
return `${months[Number(match[2]) - 1]} ${Number(match[3])}, ${match[1]}`;
|
|
162
|
+
},
|
|
59
163
|
}),
|
|
60
164
|
});
|
|
61
165
|
|