@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
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@rathnasgala/theme",
3
+ "version": "0.0.1",
4
+ "description": "Managed Gala static-site theme payload",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/rathnasgala/site-template.git"
9
+ },
10
+ "files": [
11
+ "payload"
12
+ ],
13
+ "publishConfig": {
14
+ "access": "public",
15
+ "provenance": true
16
+ },
17
+ "engines": {
18
+ "node": ">=18"
19
+ }
20
+ }
@@ -0,0 +1,5 @@
1
+ _site/
2
+ node_modules/
3
+ .gala/build/
4
+ scrap/
5
+ .DS_Store
@@ -0,0 +1,51 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "themePackage": {
4
+ "name": "@rathnasgala/theme",
5
+ "version": "0.0.1",
6
+ "availableDesignThemes": [
7
+ "editorial"
8
+ ],
9
+ "securityAdvisories": []
10
+ },
11
+ "files": {
12
+ ".gitignore": "b5fa24e10155097a0d372926fd2e59e6072e7cc2cb6399bbfb4beb8b33ea315b",
13
+ ".gala/publish.yml.template": "73080241d89bd09964b797a8bcc8e18471412e11bc873efad76305b60f497d08",
14
+ "eleventy.config.js": "0b8c753a28afecdf733ea650cab5fd0691a87f996bc186820c5f949003891cc9",
15
+ "package.json": "86e1e9922fda15122fe4eb5325d30d145cbdb2877e24307914702d0b64cf1ce9",
16
+ "package-lock.json": "92372b1bfbdeedf53a8942b3a4e774da3f708ebbf177d92f2b6280e83981df67",
17
+ "lib/build-manifest.js": "88949f0112d683ca809bb67ca969e15634792423559a67bc79a534cd807e5535",
18
+ "lib/site-config.js": "9eebf302e1ee5e7fa7b41e50cd0540cf71f917fda25c511fe1db3b035359b091",
19
+ "lib/engagement-snapshot.js": "298c11b1ab135a3a98a4e31b6320ab60dfd7fda6a9d9053df37d86282a5a6687",
20
+ "lib/publication-state.js": "1b59c29a18bbb1193bddea898dac3a4fb832c7bc4751263be086f3d64392dea0",
21
+ "lib/render-markdown.js": "3c493e50264711ee537bf34db826ee39780a6caad5c82174919f48e18fc76ae8",
22
+ "lib/seo.js": "609ae1c3a1b484dd7c37592d672d13c9aad8005471e2223caaabd2a8e800d194",
23
+ "src/404.njk": "be0d44370ac5402156aaf3d32ea581057bc61ec94c1a5f92bb809733af7a8a20",
24
+ "src/_includes/layouts/base.njk": "4def165838c1314c5d7b3759830d268ad48ee6a966dafe492ae7b0bb5018a7c7",
25
+ "src/_includes/layouts/post.njk": "ced9783eae1b4ca51b950edf19224cd4022294775c9dcc78964b65e504e9d497",
26
+ "src/_includes/components/ui.njk": "774a82eade4fa21b9203f6455995de4a274eb0b2a452a54288f5fc4b5c16f233",
27
+ "src/_data/buildManifest.js": "628163305984e0d3537e27b0d4050f5ba5ead12a834878266d2ce8004ffe1b1e",
28
+ "src/_data/engagementSnapshot.js": "9217dacebfaf008f7a0ad67ff4e8d1fa8fba609a9dc7e3430c78c5b32024da0e",
29
+ "src/_data/feedLinks.js": "06c93352ebd888acb431a1c9a8be6ecff757895468e9f2d336761038751c2d5e",
30
+ "src/_data/languages.js": "1358748f7fa2f74406d3295a030827d6229a53e94da72cf712e719d7a356aeac",
31
+ "src/_data/site.js": "d9c001f4cc12a34027dd92303530b11b5402d47613459bb997e22c4bcbea326d",
32
+ "src/assets/theme-mode.js": "67cfd8f950ee002d9b6364b7c90db0ad30341d78ac331b649d884ac00a3d6089",
33
+ "src/assets/interactions.js": "1f68c7e80208c43a40390356009d6101f76f56200f23c654e19ddd253b10b677",
34
+ "src/assets/preferences.js": "789127bee013ed6a1bbf8cefd7b9109291c16220e7a6e09314e3c93c54731d1d",
35
+ "src/assets/search.js": "5571697e2a5377c465e5446f77f2225948a5c0bdc58d0be44b579341e756909f",
36
+ "src/assets/theme.css": "c1ea914cd4a4a23afce8265548feafcb730254c993fe55853779915491356a5c",
37
+ "src/index.njk": "5c313f0fcdad3ddbfb79376bc9671b3f37539ef70c14bdbbd26d6e2e6de250e8",
38
+ "src/feed.11ty.js": "009bd2384e08e02a9405683bab1653bab3aeaec16accb0fd01ba69b24c777da9",
39
+ "src/languages.11ty.js": "399559199c65570d7d5efd88e20254838e316f81c06d7b28ec34990e963db915",
40
+ "src/posts.11ty.js": "2967f9e17fa7fcec5bf44387ab5bdee7bf8b8c85cfdc589f2105829995fc1d0e",
41
+ "src/redirects.11ty.js": "3c163246a59f5364861db6a6f05ae88afc938a5379d86cf42776d8d49b0031f3",
42
+ "src/search-index.11ty.js": "700dc2a2be0ef20de3a828a4612fcf7a082691eade29c6e1d75f09c24b6c6024",
43
+ "src/search.njk": "63f2e8aeebb63c90da2e375eece639ce096cad1eff7f28628cafc46faf7ceee6",
44
+ "src/settings.njk": "9e6babf4d2774687558618c4fbd109ddfc0f479f52ec2e5d00c373c44ad16676",
45
+ "src/sitemap.11ty.js": "4f233e94fb4a913ad5659116518432ed264984a7b622eba3558c77b4ec6435dc",
46
+ "static/robots.txt": "16ceb5ee3e0dc13aa9adf31a3ebbe45a1d965b8c2b9f72eaf84e5911e140ed95"
47
+ },
48
+ "artifactSources": {
49
+ ".gitignore": ".gala/artifact-files/gitignore"
50
+ }
51
+ }
@@ -0,0 +1,35 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - __DEFAULT_BRANCH__
7
+ schedule:
8
+ - cron: '__CRON__'
9
+ timezone: '__TIMEZONE__'
10
+ workflow_dispatch:
11
+
12
+ concurrency:
13
+ group: gala-publish-${{ github.repository }}
14
+ cancel-in-progress: false
15
+
16
+ permissions:
17
+ contents: write
18
+
19
+ jobs:
20
+ publish:
21
+ uses: __ACTION_REF__
22
+ with:
23
+ operation: build
24
+ mode: __BUILD_MODE__
25
+ site-id: __SITE_ID__
26
+ api-base-url: ${{ vars.GALA_API_BASE_URL }}
27
+ output-directory: _site
28
+ timezone: __TIMEZONE__
29
+ config-path: site.config.yml
30
+ floor-guard-percent: ${{ vars.GALA_FLOOR_GUARD_PERCENT || '20' }}
31
+ floor-guard-pages: ${{ vars.GALA_FLOOR_GUARD_PAGES || '25' }}
32
+ keepalive-threshold-days: ${{ vars.GALA_KEEPALIVE_THRESHOLD_DAYS || '50' }}
33
+ floor-guard-override-commit-sha: ${{ contains(github.event.head_commit.message || '', 'Gala-Floor-Override:') && github.sha || '' }}
34
+ secrets:
35
+ site-secret: ${{ secrets.GALA_SITE_SECRET }}
@@ -0,0 +1,47 @@
1
+ import { markdownLibrary } from './lib/render-markdown.js';
2
+ import { readBuildManifest } from './lib/build-manifest.js';
3
+ import { lstat, realpath } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ async function verifiedMediaSource(postSource, mediaSource) {
7
+ const metadata = await lstat(mediaSource);
8
+ if (!metadata.isFile() || metadata.isSymbolicLink()) {
9
+ throw new Error(`Validated media source is no longer a regular file: ${mediaSource}`);
10
+ }
11
+ const [postDirectory, source] = await Promise.all([
12
+ realpath(path.dirname(postSource)),
13
+ realpath(mediaSource)
14
+ ]);
15
+ const relative = path.relative(postDirectory, source);
16
+ if (relative === '' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
17
+ throw new Error(`Validated media source escaped its post folder: ${mediaSource}`);
18
+ }
19
+ return source;
20
+ }
21
+
22
+ export default async function (eleventyConfig) {
23
+ const manifest = await readBuildManifest();
24
+ eleventyConfig.setLibrary('md', markdownLibrary);
25
+ eleventyConfig.addPassthroughCopy({ static: '/' });
26
+ eleventyConfig.addPassthroughCopy({ 'src/assets': 'assets' });
27
+ eleventyConfig.addPassthroughCopy('custom.css');
28
+ for (const post of manifest.posts) {
29
+ for (const copy of post.media ?? []) {
30
+ const source = await verifiedMediaSource(post.source, copy.source);
31
+ eleventyConfig.addPassthroughCopy({ [source]: copy.output });
32
+ }
33
+ }
34
+ eleventyConfig.addCollection('posts', (collectionApi) =>
35
+ collectionApi.getAll().filter((item) => item.data.post?.publicationState === 'published')
36
+ );
37
+
38
+ return {
39
+ dir: {
40
+ input: 'src',
41
+ output: '_site',
42
+ includes: '_includes',
43
+ data: '_data'
44
+ },
45
+ pathPrefix: process.env.GALA_PATH_PREFIX ?? '/'
46
+ };
47
+ }
@@ -0,0 +1,189 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
5
+ const LANGUAGE = /^[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$/;
6
+ const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7
+ const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
8
+ const STATES = new Set(['published', 'tombstoned']);
9
+ const ASSIGNED_SOURCE = /^content\/posts\/[a-z0-9]+(?:-[a-z0-9]+)*\/index\.[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*\.md$/;
10
+
11
+ function absoluteHttps(value, field) {
12
+ let parsed;
13
+ try {
14
+ parsed = new URL(value);
15
+ } catch {
16
+ throw new TypeError(`${field} must be an absolute URL`);
17
+ }
18
+ if (parsed.protocol !== 'https:' || parsed.username !== '' || parsed.password !== '') {
19
+ throw new TypeError(`${field} must use HTTPS without credentials`);
20
+ }
21
+ }
22
+
23
+ function validatedPost(post, index, seen) {
24
+ if (post == null || Array.isArray(post) || typeof post !== 'object') {
25
+ throw new TypeError(`build manifest posts[${index}] must be a mapping`);
26
+ }
27
+ if (typeof post.source !== 'string' || !post.source.startsWith('content/posts/')
28
+ || post.source.split('/').some((part) => part === '.' || part === '..')) {
29
+ throw new TypeError(`build manifest posts[${index}].source is invalid`);
30
+ }
31
+ if (!ULID.test(post.id)) {
32
+ throw new TypeError(`build manifest posts[${index}].id is invalid`);
33
+ }
34
+ if (!SLUG.test(post.slug) || !LANGUAGE.test(post.language)) {
35
+ throw new TypeError(`build manifest posts[${index}] effective slug or language is invalid`);
36
+ }
37
+ if (typeof post.relativeUrl !== 'string' || !post.relativeUrl.startsWith('/')
38
+ || !post.relativeUrl.endsWith('/') || post.relativeUrl.includes('?')
39
+ || post.relativeUrl.includes('#') || post.relativeUrl.split('/').includes('..')) {
40
+ throw new TypeError(`build manifest posts[${index}].relativeUrl is invalid`);
41
+ }
42
+ absoluteHttps(post.canonicalUrl, `build manifest posts[${index}].canonicalUrl`);
43
+ absoluteHttps(post.pageUrl, `build manifest posts[${index}].pageUrl`);
44
+ if (!STATES.has(post.publicationState)) {
45
+ throw new TypeError(`build manifest posts[${index}].publicationState is invalid`);
46
+ }
47
+ if (post.frontmatter == null || Array.isArray(post.frontmatter)
48
+ || typeof post.frontmatter !== 'object') {
49
+ throw new TypeError(`build manifest posts[${index}].frontmatter must be a mapping`);
50
+ }
51
+ if (post.rawFrontmatter == null || Array.isArray(post.rawFrontmatter)
52
+ || typeof post.rawFrontmatter !== 'object') {
53
+ throw new TypeError(`build manifest posts[${index}].rawFrontmatter must be a mapping`);
54
+ }
55
+ if (typeof post.contentBody !== 'string') {
56
+ throw new TypeError(`build manifest posts[${index}].contentBody must be a string`);
57
+ }
58
+ if (post.publicationState === 'published' && typeof post.body !== 'string') {
59
+ throw new TypeError(`build manifest posts[${index}].body is required when published`);
60
+ }
61
+ if (post.publicationState === 'tombstoned' && post.body !== null) {
62
+ throw new TypeError(`build manifest posts[${index}].body must be null when tombstoned`);
63
+ }
64
+ let media;
65
+ if (post.media != null) {
66
+ if (!Array.isArray(post.media)) {
67
+ throw new TypeError(`build manifest posts[${index}].media must be a list`);
68
+ }
69
+ const sourcePrefix = `${path.posix.dirname(post.source)}/`;
70
+ const outputPrefix = post.relativeUrl.slice(1);
71
+ const outputs = new Set();
72
+ media = post.media.map((copy, mediaIndex) => {
73
+ const source = copy?.source;
74
+ const output = copy?.output;
75
+ if (typeof source !== 'string' || typeof output !== 'string'
76
+ || !source.startsWith(sourcePrefix) || !output.startsWith(outputPrefix)
77
+ || source.includes('\\') || output.includes('\\')
78
+ || source.split('/').some((part) => part === '.' || part === '..')
79
+ || output.split('/').some((part) => part === '.' || part === '..')
80
+ || outputs.has(output)) {
81
+ throw new TypeError(`build manifest posts[${index}].media[${mediaIndex}] is invalid`);
82
+ }
83
+ outputs.add(output);
84
+ return Object.freeze({ source, output });
85
+ });
86
+ }
87
+ const identityVariant = `${post.id}\u0000${post.language.toLowerCase()}`;
88
+ for (const [type, value] of [
89
+ ['source', post.source],
90
+ ['relativeUrl', post.relativeUrl],
91
+ ['pageUrl', post.pageUrl],
92
+ ['identity-language', identityVariant]
93
+ ]) {
94
+ const values = seen[type];
95
+ if (values.has(value)) throw new TypeError(`build manifest duplicate ${type}: ${value}`);
96
+ values.add(value);
97
+ }
98
+ return Object.freeze({
99
+ ...post,
100
+ ...(media == null ? {} : { media: Object.freeze(media) }),
101
+ rawFrontmatter: Object.freeze({ ...post.rawFrontmatter }),
102
+ frontmatter: Object.freeze({ ...post.frontmatter })
103
+ });
104
+ }
105
+
106
+ function validatedRedirect(redirect, index, seen) {
107
+ if (redirect == null || Array.isArray(redirect) || typeof redirect !== 'object') {
108
+ throw new TypeError(`build manifest redirects[${index}] must be a mapping`);
109
+ }
110
+ if (!ULID.test(redirect.id) || !LANGUAGE.test(redirect.language)) {
111
+ throw new TypeError(`build manifest redirects[${index}] identity is invalid`);
112
+ }
113
+ if (typeof redirect.relativeUrl !== 'string' || !redirect.relativeUrl.startsWith('/')
114
+ || !redirect.relativeUrl.endsWith('/') || redirect.relativeUrl.includes('?')
115
+ || redirect.relativeUrl.includes('#') || redirect.relativeUrl.split('/').includes('..')) {
116
+ throw new TypeError(`build manifest redirects[${index}].relativeUrl is invalid`);
117
+ }
118
+ absoluteHttps(redirect.pageUrl, `build manifest redirects[${index}].pageUrl`);
119
+ absoluteHttps(redirect.targetUrl, `build manifest redirects[${index}].targetUrl`);
120
+ if (redirect.pageUrl === redirect.targetUrl) {
121
+ throw new TypeError(`build manifest redirects[${index}] must change URL`);
122
+ }
123
+ for (const [type, value] of [['relativeUrl', redirect.relativeUrl], ['pageUrl', redirect.pageUrl]]) {
124
+ if (seen[type].has(value)) throw new TypeError(`build manifest duplicate ${type}: ${value}`);
125
+ seen[type].add(value);
126
+ }
127
+ return Object.freeze({ ...redirect });
128
+ }
129
+
130
+ export function validateBuildManifest(value) {
131
+ if (value == null || Array.isArray(value) || typeof value !== 'object'
132
+ || value.schemaVersion !== 1 || !DATE.test(value.evaluationDate)
133
+ || !Array.isArray(value.posts) || !Array.isArray(value.redirects)) {
134
+ throw new TypeError('Unsupported build manifest schema');
135
+ }
136
+ const seen = {
137
+ source: new Set(),
138
+ relativeUrl: new Set(),
139
+ pageUrl: new Set(),
140
+ 'identity-language': new Set()
141
+ };
142
+ const posts = value.posts.map((post, index) => validatedPost(post, index, seen));
143
+ const redirects = value.redirects.map((redirect, index) =>
144
+ validatedRedirect(redirect, index, seen)
145
+ );
146
+ let assignedContentIds;
147
+ if (value.assignedContentIds != null) {
148
+ if (!Array.isArray(value.assignedContentIds)) {
149
+ throw new TypeError('build manifest assignedContentIds must be a list');
150
+ }
151
+ const sources = new Set();
152
+ assignedContentIds = value.assignedContentIds.map((assigned, index) => {
153
+ if (!ASSIGNED_SOURCE.test(assigned?.source) || !ULID.test(assigned?.id)
154
+ || typeof assigned?.fileHash !== 'string' || !/^[a-f0-9]{64}$/.test(assigned.fileHash)
155
+ || sources.has(assigned.source)) {
156
+ throw new TypeError(`build manifest assignedContentIds[${index}] is invalid`);
157
+ }
158
+ sources.add(assigned.source);
159
+ return Object.freeze({ ...assigned });
160
+ });
161
+ }
162
+ return Object.freeze({
163
+ schemaVersion: 1,
164
+ evaluationDate: value.evaluationDate,
165
+ ...(assignedContentIds == null
166
+ ? {}
167
+ : { assignedContentIds: Object.freeze(assignedContentIds) }),
168
+ posts: Object.freeze(posts),
169
+ redirects: Object.freeze(redirects)
170
+ });
171
+ }
172
+
173
+ export async function readBuildManifest(root = process.cwd()) {
174
+ const manifestPath = path.resolve(root, '.gala', 'build', 'validated-posts.json');
175
+ let source;
176
+ try {
177
+ source = await readFile(manifestPath, 'utf8');
178
+ } catch (error) {
179
+ if (error.code === 'ENOENT') {
180
+ throw new Error('Validated build manifest is missing; run Gala validation before Eleventy');
181
+ }
182
+ throw error;
183
+ }
184
+ try {
185
+ return validateBuildManifest(JSON.parse(source));
186
+ } catch (error) {
187
+ throw new TypeError(`Invalid validated build manifest: ${error.message}`);
188
+ }
189
+ }
@@ -0,0 +1,43 @@
1
+ import { readFile } from 'node:fs/promises';
2
+
3
+ export const EMPTY_ENGAGEMENT = Object.freeze({
4
+ available: false,
5
+ reactions: null,
6
+ comments: null,
7
+ views: null
8
+ });
9
+
10
+ export async function readEngagementSnapshot(file) {
11
+ let source;
12
+ try {
13
+ source = await readFile(file, 'utf8');
14
+ } catch (error) {
15
+ if (error.code === 'ENOENT') return { schemaVersion: 1, refreshedAt: null, articles: {} };
16
+ throw error;
17
+ }
18
+
19
+ let snapshot;
20
+ try {
21
+ snapshot = JSON.parse(source);
22
+ } catch (error) {
23
+ throw new TypeError(`Invalid engagement snapshot JSON: ${error.message}`);
24
+ }
25
+
26
+ if (snapshot?.schemaVersion !== 1 || snapshot.articles == null || Array.isArray(snapshot.articles)) {
27
+ throw new TypeError('Unsupported engagement snapshot schema');
28
+ }
29
+ return snapshot;
30
+ }
31
+
32
+ export function engagementFor(snapshot, articleId) {
33
+ const entry = snapshot.articles[articleId];
34
+ if (entry == null) return EMPTY_ENGAGEMENT;
35
+
36
+ for (const field of ['reactions', 'comments', 'views']) {
37
+ if (!Number.isSafeInteger(entry[field]) || entry[field] < 0) {
38
+ throw new TypeError(`Invalid ${field} count for article ${articleId}`);
39
+ }
40
+ }
41
+
42
+ return Object.freeze({ available: true, ...entry });
43
+ }
@@ -0,0 +1,61 @@
1
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
2
+
3
+ export const PublicationState = Object.freeze({
4
+ PUBLISHED: 'published',
5
+ TOMBSTONED: 'tombstoned',
6
+ NOT_EMITTED: 'not-emitted'
7
+ });
8
+
9
+ function requireCalendarDate(value, fieldName) {
10
+ if (typeof value !== 'string' || !DATE_PATTERN.test(value)) {
11
+ throw new TypeError(`${fieldName} must use YYYY-MM-DD`);
12
+ }
13
+
14
+ const [year, month, day] = value.split('-').map(Number);
15
+ const parsed = new Date(Date.UTC(year, month - 1, day));
16
+ if (
17
+ parsed.getUTCFullYear() !== year ||
18
+ parsed.getUTCMonth() !== month - 1 ||
19
+ parsed.getUTCDate() !== day
20
+ ) {
21
+ throw new TypeError(`${fieldName} must be a valid calendar date`);
22
+ }
23
+
24
+ return value;
25
+ }
26
+
27
+ export function localDateAt(instant, timezone) {
28
+ if (!(instant instanceof Date) || Number.isNaN(instant.valueOf())) {
29
+ throw new TypeError('instant must be a valid Date');
30
+ }
31
+
32
+ const parts = new Intl.DateTimeFormat('en-CA', {
33
+ timeZone: timezone,
34
+ year: 'numeric',
35
+ month: '2-digit',
36
+ day: '2-digit'
37
+ }).formatToParts(instant);
38
+
39
+ const values = Object.fromEntries(parts.map(({ type, value }) => [type, value]));
40
+ return `${values.year}-${values.month}-${values.day}`;
41
+ }
42
+
43
+ export function evaluatePublicationState(post, today) {
44
+ const publishAfterDate = requireCalendarDate(post.publishAfterDate, 'publishAfterDate');
45
+ const evaluationDate = requireCalendarDate(today, 'today');
46
+
47
+ // FR-501 and FR-504 take precedence over deletion: never-published content
48
+ // must leave no public artifact, even when its deletion date has passed.
49
+ if (publishAfterDate > evaluationDate) {
50
+ return PublicationState.NOT_EMITTED;
51
+ }
52
+
53
+ if (post.deleteDate != null) {
54
+ const deleteDate = requireCalendarDate(post.deleteDate, 'deleteDate');
55
+ if (deleteDate <= evaluationDate) {
56
+ return PublicationState.TOMBSTONED;
57
+ }
58
+ }
59
+
60
+ return PublicationState.PUBLISHED;
61
+ }
@@ -0,0 +1,96 @@
1
+ import MarkdownIt from 'markdown-it';
2
+ import footnote from 'markdown-it-footnote';
3
+ import sanitizeHtml from 'sanitize-html';
4
+
5
+ const ADMONITION_TYPES = new Set(['NOTE', 'TIP', 'IMPORTANT', 'WARNING', 'CAUTION']);
6
+
7
+ function admonitions(markdown) {
8
+ markdown.core.ruler.after('block', 'gala_admonitions', (state) => {
9
+ for (let index = 0; index < state.tokens.length - 2; index += 1) {
10
+ const open = state.tokens[index];
11
+ const inline = state.tokens[index + 2];
12
+ if (open.type !== 'blockquote_open' || inline?.type !== 'inline') continue;
13
+
14
+ const match = inline.content.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)](?:\n|$)/);
15
+ if (!match || !ADMONITION_TYPES.has(match[1])) continue;
16
+
17
+ open.tag = 'aside';
18
+ open.attrSet('class', `admonition admonition-${match[1].toLowerCase()}`);
19
+ open.attrSet('role', 'note');
20
+ inline.content = inline.content.slice(match[0].length);
21
+ if (inline.children?.[0]?.type === 'text') {
22
+ inline.children[0].content = inline.children[0].content.replace(
23
+ /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)](?:\n|$)/,
24
+ ''
25
+ );
26
+ }
27
+
28
+ let depth = 1;
29
+ for (let cursor = index + 1; cursor < state.tokens.length; cursor += 1) {
30
+ if (state.tokens[cursor].type === 'blockquote_open') depth += 1;
31
+ if (state.tokens[cursor].type === 'blockquote_close') depth -= 1;
32
+ if (depth === 0) {
33
+ state.tokens[cursor].tag = 'aside';
34
+ break;
35
+ }
36
+ }
37
+ }
38
+ });
39
+ }
40
+
41
+ const markdown = new MarkdownIt({ html: true, linkify: true, typographer: false })
42
+ .use(footnote)
43
+ .use(admonitions);
44
+
45
+ const sanitizeOptions = {
46
+ allowedTags: [
47
+ 'a', 'abbr', 'aside', 'blockquote', 'br', 'code', 'del', 'div', 'em',
48
+ 'figcaption', 'figure', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'img',
49
+ 'li', 'mark', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table',
50
+ 'tbody', 'td', 'th', 'thead', 'tr', 'ul'
51
+ ],
52
+ allowedAttributes: {
53
+ a: ['href', 'title', 'rel'],
54
+ aside: ['class', 'role'],
55
+ code: ['class'],
56
+ div: ['class', 'id'],
57
+ img: ['src', 'alt', 'title', 'width', 'height', 'loading', 'decoding'],
58
+ li: ['id'],
59
+ ol: ['class'],
60
+ span: ['class'],
61
+ sup: ['class'],
62
+ td: ['colspan', 'rowspan'],
63
+ th: ['colspan', 'rowspan', 'scope']
64
+ },
65
+ allowedClasses: {
66
+ aside: [
67
+ 'admonition', 'admonition-note', 'admonition-tip', 'admonition-important',
68
+ 'admonition-warning', 'admonition-caution'
69
+ ],
70
+ code: [/^language-[a-z0-9_-]+$/],
71
+ ol: ['footnotes-list'],
72
+ span: ['footnote-backref'],
73
+ sup: ['footnote-ref']
74
+ },
75
+ allowedSchemes: ['https', 'mailto'],
76
+ allowedSchemesByTag: {
77
+ a: ['https', 'mailto'],
78
+ img: ['https']
79
+ },
80
+ allowProtocolRelative: false,
81
+ enforceHtmlBoundary: true
82
+ };
83
+
84
+ export function renderMarkdown(source) {
85
+ if (typeof source !== 'string') throw new TypeError('Markdown source must be a string');
86
+ return sanitizeHtml(markdown.render(source), sanitizeOptions);
87
+ }
88
+
89
+ export const markdownLibrary = Object.assign(Object.create(markdown), {
90
+ render(source, environment) {
91
+ return sanitizeHtml(markdown.render(source, environment), sanitizeOptions);
92
+ },
93
+ renderInline(source, environment) {
94
+ return sanitizeHtml(markdown.renderInline(source, environment), sanitizeOptions);
95
+ }
96
+ });