@rathnasgala/theme 0.0.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.
Files changed (37) hide show
  1. package/package.json +20 -0
  2. package/payload/.gala/artifact-files/gitignore +5 -0
  3. package/payload/.gala/managed-files.json +51 -0
  4. package/payload/.gala/publish.yml.template +35 -0
  5. package/payload/eleventy.config.js +47 -0
  6. package/payload/lib/build-manifest.js +189 -0
  7. package/payload/lib/engagement-snapshot.js +43 -0
  8. package/payload/lib/publication-state.js +61 -0
  9. package/payload/lib/render-markdown.js +96 -0
  10. package/payload/lib/seo.js +287 -0
  11. package/payload/lib/site-config.js +23 -0
  12. package/payload/package-lock.json +2042 -0
  13. package/payload/package.json +21 -0
  14. package/payload/src/404.njk +12 -0
  15. package/payload/src/_data/buildManifest.js +3 -0
  16. package/payload/src/_data/engagementSnapshot.js +5 -0
  17. package/payload/src/_data/feedLinks.js +15 -0
  18. package/payload/src/_data/languages.js +10 -0
  19. package/payload/src/_data/site.js +5 -0
  20. package/payload/src/_includes/components/ui.njk +17 -0
  21. package/payload/src/_includes/layouts/base.njk +40 -0
  22. package/payload/src/_includes/layouts/post.njk +11 -0
  23. package/payload/src/assets/interactions.js +54 -0
  24. package/payload/src/assets/preferences.js +30 -0
  25. package/payload/src/assets/search.js +81 -0
  26. package/payload/src/assets/theme-mode.js +35 -0
  27. package/payload/src/assets/theme.css +125 -0
  28. package/payload/src/feed.11ty.js +46 -0
  29. package/payload/src/index.njk +8 -0
  30. package/payload/src/languages.11ty.js +38 -0
  31. package/payload/src/posts.11ty.js +35 -0
  32. package/payload/src/redirects.11ty.js +37 -0
  33. package/payload/src/search-index.11ty.js +23 -0
  34. package/payload/src/search.njk +16 -0
  35. package/payload/src/settings.njk +19 -0
  36. package/payload/src/sitemap.11ty.js +26 -0
  37. package/payload/static/robots.txt +2 -0
@@ -0,0 +1,287 @@
1
+ import sanitizeHtml from 'sanitize-html';
2
+
3
+ import { markdownLibrary } from './render-markdown.js';
4
+
5
+ function xml(value) {
6
+ return String(value)
7
+ .replaceAll('&', '&')
8
+ .replaceAll('<', '&lt;')
9
+ .replaceAll('>', '&gt;')
10
+ .replaceAll('"', '&quot;')
11
+ .replaceAll("'", '&apos;');
12
+ }
13
+
14
+ function requiredText(value, field) {
15
+ if (typeof value !== 'string' || value.trim() === '') {
16
+ throw new TypeError(`${field} is required`);
17
+ }
18
+ return value.trim();
19
+ }
20
+
21
+ function jsonForHtml(value) {
22
+ return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (character) =>
23
+ `\\u${character.codePointAt(0).toString(16).padStart(4, '0')}`
24
+ );
25
+ }
26
+
27
+ function fallbackDescription(renderedHtml, maximum = 160) {
28
+ if (typeof renderedHtml !== 'string') throw new TypeError('renderedHtml must be a string');
29
+ const text = markdownLibrary.utils.unescapeAll(sanitizeHtml(renderedHtml, {
30
+ allowedTags: [],
31
+ allowedAttributes: {}
32
+ })).replace(/\s+/g, ' ').trim();
33
+ if (text.length <= maximum) return text;
34
+ const boundary = text.lastIndexOf(' ', maximum);
35
+ return text.slice(0, boundary > 0 ? boundary : maximum).trimEnd();
36
+ }
37
+
38
+ function postLocalUrl(pageUrl, reference) {
39
+ if (typeof reference !== 'string' || reference.trim() === ''
40
+ || reference.startsWith('/') || reference.includes('\\')) {
41
+ throw new TypeError('coverImage must be a post-relative URL');
42
+ }
43
+ let parsed;
44
+ try {
45
+ parsed = new URL(reference, pageUrl);
46
+ } catch {
47
+ throw new TypeError('coverImage must be a post-relative URL');
48
+ }
49
+ const page = absoluteHttpsUrl(pageUrl, 'page URL');
50
+ if (parsed.origin !== page.origin || !parsed.pathname.startsWith(page.pathname)
51
+ || parsed.username !== '' || parsed.password !== '') {
52
+ throw new TypeError('coverImage must stay within the post URL');
53
+ }
54
+ parsed.search = '';
55
+ parsed.hash = '';
56
+ return parsed.toString();
57
+ }
58
+
59
+ function absoluteHttpsUrl(value, field) {
60
+ let parsed;
61
+ try {
62
+ parsed = new URL(value);
63
+ } catch {
64
+ throw new TypeError(`${field} must be an absolute URL`);
65
+ }
66
+ if (parsed.protocol !== 'https:') throw new TypeError(`${field} must use HTTPS`);
67
+ if (parsed.username !== '' || parsed.password !== '') {
68
+ throw new TypeError(`${field} must not contain credentials`);
69
+ }
70
+ parsed.hash = '';
71
+ return parsed;
72
+ }
73
+
74
+ function urlPath(value, field) {
75
+ if (typeof value !== 'string') throw new TypeError(`${field} must be a string`);
76
+ if (value.includes('?') || value.includes('#')) {
77
+ throw new TypeError(`${field} must not contain a query or fragment`);
78
+ }
79
+ const segments = value.split('/').filter(Boolean);
80
+ if (segments.some((segment) => segment === '.' || segment === '..')) {
81
+ throw new TypeError(`${field} must not contain dot segments`);
82
+ }
83
+ return segments;
84
+ }
85
+
86
+ export function siteUrl({ canonicalBaseUrl, pathPrefix = '/', relativePath = '/' }) {
87
+ const base = absoluteHttpsUrl(canonicalBaseUrl, 'canonicalBaseUrl');
88
+ const parts = [
89
+ ...urlPath(pathPrefix, 'pathPrefix'),
90
+ ...urlPath(relativePath, 'relativePath')
91
+ ].map(encodeURIComponent);
92
+ base.pathname = parts.length === 0
93
+ ? '/'
94
+ : `/${parts.join('/')}${relativePath.endsWith('/') ? '/' : ''}`;
95
+ base.search = '';
96
+ return base.toString();
97
+ }
98
+
99
+ export function canonicalUrl({ pageUrl, canonicalOverride }) {
100
+ const selected = canonicalOverride == null ? pageUrl : canonicalOverride;
101
+ return absoluteHttpsUrl(selected, 'canonical URL').toString();
102
+ }
103
+
104
+ export function sortFeedPosts(posts) {
105
+ if (!Array.isArray(posts)) throw new TypeError('posts must be an array');
106
+ return [...posts].sort((left, right) => {
107
+ const dateOrder = requiredText(
108
+ right?.frontmatter?.publishAfterDate,
109
+ 'publishAfterDate'
110
+ ).localeCompare(requiredText(left?.frontmatter?.publishAfterDate, 'publishAfterDate'));
111
+ if (dateOrder !== 0) return dateOrder;
112
+ return requiredText(right?.id, 'article id').localeCompare(requiredText(left?.id, 'article id'));
113
+ });
114
+ }
115
+
116
+ export function postSeo({ post, site, renderedHtml }) {
117
+ if (post == null || site == null) throw new TypeError('post and site are required');
118
+ const title = requiredText(post.frontmatter?.title, 'post title');
119
+ const authoredDescription = typeof post.frontmatter?.description === 'string'
120
+ ? post.frontmatter.description.trim()
121
+ : '';
122
+ const descriptionFallback = authoredDescription === '';
123
+ const description = descriptionFallback
124
+ ? fallbackDescription(renderedHtml)
125
+ : authoredDescription;
126
+ const siteName = requiredText(site.site?.name, 'site name');
127
+ const author = [post.frontmatter?.author, site.site?.author, siteName]
128
+ .find((value) => typeof value === 'string' && value.trim() !== '')
129
+ .trim();
130
+ const pageUrl = absoluteHttpsUrl(post.pageUrl, 'page URL').toString();
131
+ const selectedCanonical = canonicalUrl({ pageUrl, canonicalOverride: post.canonicalUrl });
132
+ const language = Intl.getCanonicalLocales(requiredText(post.language, 'post language'))[0];
133
+ const datePublished = requiredText(post.frontmatter?.publishAfterDate, 'publishAfterDate');
134
+ const historyDates = (post.frontmatter?.editHistory ?? [])
135
+ .map((entry) => typeof entry === 'string' ? entry.slice(0, 10) : '')
136
+ .filter(Boolean);
137
+ const dateModified = historyDates.sort().at(-1) ?? datePublished;
138
+ const imageUrl = post.frontmatter?.coverImage == null
139
+ ? null
140
+ : postLocalUrl(pageUrl, post.frontmatter.coverImage);
141
+ const rootUrl = siteUrl({
142
+ canonicalBaseUrl: site.hosting?.canonicalBaseUrl,
143
+ pathPrefix: site.hosting?.pathPrefix ?? '/',
144
+ relativePath: '/'
145
+ });
146
+ const languageUrl = siteUrl({
147
+ canonicalBaseUrl: site.hosting?.canonicalBaseUrl,
148
+ pathPrefix: site.hosting?.pathPrefix ?? '/',
149
+ relativePath: `/${language}/`
150
+ });
151
+ const blogPosting = {
152
+ '@context': 'https://schema.org',
153
+ '@type': 'BlogPosting',
154
+ headline: title,
155
+ description,
156
+ url: selectedCanonical,
157
+ mainEntityOfPage: { '@type': 'WebPage', '@id': selectedCanonical },
158
+ datePublished,
159
+ dateModified,
160
+ inLanguage: language,
161
+ author: { '@type': 'Person', name: author },
162
+ publisher: { '@type': 'Organization', name: siteName },
163
+ ...(imageUrl == null ? {} : { image: imageUrl })
164
+ };
165
+ const breadcrumbList = {
166
+ '@context': 'https://schema.org',
167
+ '@type': 'BreadcrumbList',
168
+ itemListElement: [
169
+ { '@type': 'ListItem', position: 1, name: siteName, item: rootUrl },
170
+ { '@type': 'ListItem', position: 2, name: language, item: languageUrl },
171
+ { '@type': 'ListItem', position: 3, name: title, item: pageUrl }
172
+ ]
173
+ };
174
+ return Object.freeze({
175
+ title,
176
+ description,
177
+ descriptionFallback,
178
+ author,
179
+ pageUrl,
180
+ canonicalUrl: selectedCanonical,
181
+ imageUrl,
182
+ twitterCard: imageUrl == null ? 'summary' : 'summary_large_image',
183
+ datePublished,
184
+ dateModified,
185
+ blogPosting: Object.freeze(blogPosting),
186
+ breadcrumbList: Object.freeze(breadcrumbList),
187
+ structuredDataJson: jsonForHtml([blogPosting, breadcrumbList])
188
+ });
189
+ }
190
+
191
+ export function hreflangCluster(variants, xDefaultUrl) {
192
+ if (!Array.isArray(variants) || variants.length === 0) {
193
+ throw new TypeError('At least one language variant is required');
194
+ }
195
+ const seen = new Set();
196
+ const links = variants.map(({ language, url }) => {
197
+ let canonicalLanguage;
198
+ try {
199
+ canonicalLanguage = Intl.getCanonicalLocales(language)[0];
200
+ } catch {
201
+ throw new TypeError(`Duplicate or invalid hreflang: ${language}`);
202
+ }
203
+ if (canonicalLanguage == null || seen.has(canonicalLanguage)) {
204
+ throw new TypeError(`Duplicate or invalid hreflang: ${language}`);
205
+ }
206
+ seen.add(canonicalLanguage);
207
+ return Object.freeze({
208
+ hreflang: canonicalLanguage,
209
+ href: absoluteHttpsUrl(url, 'variant URL').toString()
210
+ });
211
+ });
212
+ const fallback = absoluteHttpsUrl(xDefaultUrl, 'x-default URL').toString();
213
+ return Object.freeze([...links, Object.freeze({ hreflang: 'x-default', href: fallback })]);
214
+ }
215
+
216
+ export function articleHreflang(posts, site) {
217
+ if (!Array.isArray(posts)) throw new TypeError('posts must be an array');
218
+ let defaultLanguage;
219
+ try {
220
+ defaultLanguage = Intl.getCanonicalLocales(site?.site?.defaultLanguage)[0];
221
+ } catch {
222
+ throw new TypeError('site.defaultLanguage must be a valid BCP-47 language tag');
223
+ }
224
+ if (defaultLanguage == null) throw new TypeError('site.defaultLanguage is required');
225
+ let siteRoot;
226
+ const fallbackSiteRoot = () => {
227
+ siteRoot ??= siteUrl({
228
+ canonicalBaseUrl: site?.hosting?.canonicalBaseUrl,
229
+ pathPrefix: site?.hosting?.pathPrefix ?? '/',
230
+ relativePath: '/'
231
+ });
232
+ return siteRoot;
233
+ };
234
+ const groups = new Map();
235
+ for (const post of posts.filter(({ publicationState }) => publicationState === 'published')) {
236
+ const variants = groups.get(post.id) ?? [];
237
+ variants.push({ language: post.language, url: post.pageUrl, source: post.source });
238
+ groups.set(post.id, variants);
239
+ }
240
+ const bySource = new Map();
241
+ for (const variants of groups.values()) {
242
+ const defaultVariant = variants.find(({ language }) => {
243
+ try {
244
+ return Intl.getCanonicalLocales(language)[0] === defaultLanguage;
245
+ } catch {
246
+ return false;
247
+ }
248
+ });
249
+ const links = hreflangCluster(variants, defaultVariant?.url ?? fallbackSiteRoot());
250
+ variants.forEach(({ source }) => bySource.set(source, links));
251
+ }
252
+ return bySource;
253
+ }
254
+
255
+ export function renderSitemap(entries) {
256
+ const urls = entries.map((entry) => {
257
+ const alternates = hreflangCluster(entry.variants, entry.xDefaultUrl)
258
+ .map(({ hreflang, href }) =>
259
+ ` <xhtml:link rel="alternate" hreflang="${xml(hreflang)}" href="${xml(href)}"/>`
260
+ )
261
+ .join('\n');
262
+ const location = canonicalUrl({ pageUrl: entry.url });
263
+ const lastModified = entry.lastModified == null ? '' : `\n <lastmod>${xml(entry.lastModified)}</lastmod>`;
264
+ return ` <url>\n <loc>${xml(location)}</loc>${lastModified}\n${alternates}\n </url>`;
265
+ });
266
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n${urls.join('\n')}\n</urlset>\n`;
267
+ }
268
+
269
+ export function renderAtomFeed({ id, title, author, updated, selfUrl, entries }) {
270
+ const feedEntries = entries.map((entry) => ` <entry>
271
+ <id>${xml(entry.id)}</id>
272
+ <title>${xml(entry.title)}</title>
273
+ <updated>${xml(entry.updated)}</updated>
274
+ <link href="${xml(canonicalUrl({ pageUrl: entry.url }))}"/>
275
+ <content type="html">${xml(entry.html)}</content>
276
+ </entry>`).join('\n');
277
+ return `<?xml version="1.0" encoding="UTF-8"?>
278
+ <feed xmlns="http://www.w3.org/2005/Atom">
279
+ <id>${xml(id)}</id>
280
+ <title>${xml(title)}</title>
281
+ <author><name>${xml(requiredText(author, 'feed author'))}</name></author>
282
+ <updated>${xml(updated)}</updated>
283
+ <link rel="self" href="${xml(canonicalUrl({ pageUrl: selfUrl }))}"/>
284
+ ${feedEntries}
285
+ </feed>
286
+ `;
287
+ }
@@ -0,0 +1,23 @@
1
+ import { lstat, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { parse } from 'yaml';
4
+
5
+ export async function loadSiteConfiguration({
6
+ root = process.cwd(),
7
+ configPath = process.env.GALA_CONFIG_PATH ?? 'site.config.yml'
8
+ } = {}) {
9
+ if (path.isAbsolute(configPath) || configPath.split(/[\\/]/).includes('..')) {
10
+ throw new TypeError('GALA_CONFIG_PATH must stay within the checkout');
11
+ }
12
+ const checkout = path.resolve(root);
13
+ const file = path.resolve(checkout, configPath);
14
+ const relative = path.relative(checkout, file);
15
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
16
+ throw new TypeError('GALA_CONFIG_PATH must stay within the checkout');
17
+ }
18
+ const metadata = await lstat(file);
19
+ if (!metadata.isFile() || metadata.isSymbolicLink()) {
20
+ throw new TypeError('site configuration must be a regular file');
21
+ }
22
+ return parse(await readFile(file, 'utf8'));
23
+ }