@lullabot/eleventy-decision-records 1.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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -0
  3. package/package.json +94 -0
  4. package/src/YYYYMMDD-decision-template.md +41 -0
  5. package/src/about.md +36 -0
  6. package/src/assets/fonts/OFL.txt +93 -0
  7. package/src/assets/fonts/gabarito-v9-latin-500.woff2 +0 -0
  8. package/src/assets/fonts/gabarito-v9-latin-700.woff2 +0 -0
  9. package/src/assets/fonts/gabarito-v9-latin-regular.woff2 +0 -0
  10. package/src/assets/icons/facebook.svg +1 -0
  11. package/src/assets/icons/github.svg +1 -0
  12. package/src/assets/icons/linkedin.svg +1 -0
  13. package/src/assets/icons/mastodon.svg +1 -0
  14. package/src/assets/icons/x.svg +1 -0
  15. package/src/assets/icons/youtube.svg +1 -0
  16. package/src/assets/images/horizontal.jpg +0 -0
  17. package/src/assets/images/vertical.jpg +0 -0
  18. package/src/assets/js/scrollable.js +49 -0
  19. package/src/assets/js/search.js +189 -0
  20. package/src/assets/js/time-since.js +31 -0
  21. package/src/assets/styles/content.css +149 -0
  22. package/src/assets/styles/fonts.css +33 -0
  23. package/src/assets/styles/footer.css +65 -0
  24. package/src/assets/styles/layout.css +483 -0
  25. package/src/assets/styles/pages.css +348 -0
  26. package/src/assets/styles/recent-decisions.css +104 -0
  27. package/src/assets/styles/reset.css +107 -0
  28. package/src/assets/styles/search-dialog.css +196 -0
  29. package/src/assets/styles/tags.css +16 -0
  30. package/src/assets/styles/tokens.css +181 -0
  31. package/src/assets/styles/typography.css +182 -0
  32. package/src/assets/styles/view-transitions.css +67 -0
  33. package/src/config.js +38 -0
  34. package/src/defaultConfig.json +99 -0
  35. package/src/index.js +227 -0
  36. package/src/index.md +28 -0
  37. package/src/lib/collections/adrs.js +10 -0
  38. package/src/lib/collections/contributors.js +15 -0
  39. package/src/lib/collections/index.js +14 -0
  40. package/src/lib/collections/topics.js +18 -0
  41. package/src/lib/data/index.js +19 -0
  42. package/src/lib/filters/collections.js +63 -0
  43. package/src/lib/filters/dates.js +68 -0
  44. package/src/lib/filters/index.js +26 -0
  45. package/src/lib/filters/strings.js +23 -0
  46. package/src/lib/plugins/bundle.js +23 -0
  47. package/src/lib/plugins/index.js +16 -0
  48. package/src/lib/plugins/markdown.js +10 -0
  49. package/src/lib/plugins/rss.js +24 -0
  50. package/src/lib/plugins/syntax-highlight.js +8 -0
  51. package/src/lib/plugins/toc.js +11 -0
  52. package/src/lib/shortcodes/icons.js +91 -0
  53. package/src/lib/shortcodes/index.js +11 -0
  54. package/src/lib/shortcodes/search.js +32 -0
  55. package/src/templates/assets/favicon.njk +7 -0
  56. package/src/templates/assets/search_index.njk +13 -0
  57. package/src/templates/layouts/adr.njk +59 -0
  58. package/src/templates/layouts/page.njk +136 -0
  59. package/src/templates/pages/contributor.njk +51 -0
  60. package/src/templates/pages/contributors.njk +46 -0
  61. package/src/templates/pages/decisions.njk +41 -0
  62. package/src/templates/pages/practice-areas.njk +48 -0
  63. package/src/templates/pages/topics.njk +50 -0
  64. package/src/templates/partials/footer.njk +12 -0
  65. package/src/templates/partials/recent-decisions.njk +33 -0
  66. package/src/templates/partials/site-nav.njk +61 -0
@@ -0,0 +1,99 @@
1
+ {
2
+ "site": {
3
+ "organization": "Lullabot",
4
+ "url": "https://architecture.lullabot.com/",
5
+ "legalName": "Lullabot, Inc.",
6
+ "title": "Decision Records",
7
+ "description": "Documented architectural decisions and best practices for Lullabot's team",
8
+ "icon": "brand_family-fill",
9
+ "social": [
10
+ {
11
+ "label": "RSS",
12
+ "url": "/feed.xml",
13
+ "icon": "rss_feed"
14
+ },
15
+ {
16
+ "label": "GitHub",
17
+ "url": "https://www.github.com/Lullabot",
18
+ "icon": "github"
19
+ },
20
+ {
21
+ "label": "X",
22
+ "url": "https://x.com/lullabot",
23
+ "icon": "x"
24
+ },
25
+ {
26
+ "label": "LinkedIn",
27
+ "url": "https://www.linkedin.com/company/lullabot",
28
+ "icon": "linkedin"
29
+ },
30
+ {
31
+ "label": "YouTube",
32
+ "url": "https://www.youtube.com/c/lullabot",
33
+ "icon": "youtube"
34
+ },
35
+ {
36
+ "label": "Mastodon",
37
+ "url": "https://toot.cafe/@lullabot",
38
+ "icon": "mastodon"
39
+ },
40
+ {
41
+ "label": "Facebook",
42
+ "url": "https://www.facebook.com/lullabot",
43
+ "icon": "facebook"
44
+ },
45
+ {
46
+ "label": "Terms of Service",
47
+ "url": "https://www.lullabot.com/terms",
48
+ "icon": "contract"
49
+ }
50
+ ]
51
+ },
52
+ "navigation": {
53
+ "primary": [
54
+ {
55
+ "label": "Home",
56
+ "url": "/",
57
+ "icon": "home-fill"
58
+ },
59
+ {
60
+ "label": "All decisions",
61
+ "url": "/{decisions}/",
62
+ "icon": "verified"
63
+ }
64
+ ],
65
+ "utility": [
66
+ {
67
+ "label": "Contributors",
68
+ "url": "/contributors/",
69
+ "icon": "group"
70
+ },
71
+ {
72
+ "label": "About",
73
+ "url": "/about/",
74
+ "icon": "info"
75
+ }
76
+ ]
77
+ },
78
+ "practiceAreas": [
79
+ {
80
+ "name": "Project Management",
81
+ "icon": "checklist"
82
+ },
83
+ {
84
+ "name": "Strategy",
85
+ "icon": "lightbulb"
86
+ },
87
+ {
88
+ "name": "Design",
89
+ "icon": "design_services"
90
+ },
91
+ {
92
+ "name": "Engineering",
93
+ "icon": "settings"
94
+ }
95
+ ],
96
+ "dirs": {
97
+ "decisions": "decisions"
98
+ }
99
+ }
package/src/index.js ADDED
@@ -0,0 +1,227 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { join, relative, dirname, sep } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { createRequire } from 'node:module';
5
+
6
+ import collections from './lib/collections/index.js';
7
+ import data from './lib/data/index.js';
8
+ import filters from './lib/filters/index.js';
9
+ import plugins from './lib/plugins/index.js';
10
+ import shortcodes from './lib/shortcodes/index.js';
11
+ import themeConfig from './config.js';
12
+
13
+ const themeRoot = dirname(fileURLToPath(import.meta.url));
14
+ const require = createRequire(import.meta.url);
15
+
16
+ /**
17
+ * Resolves the on-disk root of an installed package, regardless of
18
+ * whether its export map exposes package.json.
19
+ */
20
+ function packageRoot(name, probe = name) {
21
+ const entry = require.resolve(probe);
22
+ const marker = join('node_modules', ...name.split('/'));
23
+ return entry.slice(0, entry.indexOf(marker) + marker.length);
24
+ }
25
+
26
+ /**
27
+ * Adds the theme's template directories to the Nunjucks environment
28
+ * Eleventy builds for the project.
29
+ *
30
+ * The theme deliberately extends that environment rather than supplying
31
+ * one of its own. Constructing an environment here would mean loading a
32
+ * second copy of Nunjucks, which will conflict with Eleventy's.
33
+ *
34
+ * Eleventy's own search paths (the project's includes directory, then
35
+ * its working directory) stay ahead of the theme's, so a project file
36
+ * of the same name still takes precedence.
37
+ */
38
+ function addThemeSearchPaths(eleventyConfig, searchPaths) {
39
+ eleventyConfig.on('eleventy.engine.njk', ({ environment }) => {
40
+ for (const loader of environment.loaders ?? []) {
41
+ if (Array.isArray(loader.searchPaths)) {
42
+ loader.searchPaths.push(...searchPaths);
43
+ }
44
+ }
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Pages the theme provides. Each is registered as a virtual template
50
+ * unless the project has a real file at the same input path.
51
+ */
52
+ const PAGES = [
53
+ 'decisions.njk',
54
+ 'topics.njk',
55
+ 'practice-areas.njk',
56
+ 'contributors.njk',
57
+ 'contributor.njk',
58
+ ];
59
+
60
+ /**
61
+ * Layouts the theme provides, registered as virtual templates in the
62
+ * includes directory unless the project overrides them on disk.
63
+ */
64
+ const LAYOUTS = ['page.njk', 'adr.njk'];
65
+
66
+ const ASSETS = ['favicon.njk', 'search_index.njk'];
67
+
68
+ /**
69
+ * Default content the theme ships so a fresh project has a homepage and
70
+ * an about page. These live at the theme root rather than under
71
+ * templates/ because they are prose a project is expected to replace.
72
+ */
73
+ const CONTENT = ['index.md', 'about.md'];
74
+
75
+ /**
76
+ * Extensions Eleventy will render a page from, used to decide whether a
77
+ * project already provides its own version of a theme page.
78
+ */
79
+ const TEMPLATE_EXTENSIONS = ['md', 'njk', 'html', 'liquid', '11ty.js'];
80
+
81
+ /**
82
+ * True when the project has its own template that would take the theme
83
+ * page's place. The extension is ignored so a project's about.njk
84
+ * replaces the theme's about.md rather than fighting it for /about/.
85
+ */
86
+ function hasOverride(dir, name) {
87
+ const base = name.slice(0, name.indexOf('.'));
88
+ return TEMPLATE_EXTENSIONS.some((ext) =>
89
+ existsSync(join(dir, `${base}.${ext}`)),
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Development only (DECISION_RECORDS_DEV=1, set by `npm start`): watches
95
+ * the theme's own files so a linked checkout rebuilds on edit. The virtual
96
+ * templates are read at config time, so the watch also resets the config.
97
+ *
98
+ * Registered as a glob because Eleventy 4 keeps directory targets bare and
99
+ * then rejects every file under them as unmatched. Eleventy 3 has a
100
+ * different problem: it reports changes outside the project relative to
101
+ * the nearest shared parent directory, so the reset target is registered
102
+ * a second time in that form, without its leading `../` segments.
103
+ */
104
+ function watchTheme(eleventyConfig) {
105
+ if (!process.env.DECISION_RECORDS_DEV) {
106
+ return;
107
+ }
108
+ const rel = relative('.', themeRoot).split(sep).join('/');
109
+ eleventyConfig.addWatchTarget(`${rel}/**`, { resetConfig: true });
110
+
111
+ const remapped = rel.replace(/^(\.\.\/)+/, '');
112
+ if (remapped !== rel && !isEleventy4(eleventyConfig)) {
113
+ eleventyConfig.addWatchTarget(`${remapped}/**`, { resetConfig: true });
114
+ }
115
+ }
116
+
117
+ function isEleventy4(eleventyConfig) {
118
+ try {
119
+ eleventyConfig.versionCheck('>=4.0.0-0');
120
+ return true;
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ export default function (eleventyConfig, options = {}) {
127
+ const opts = themeConfig(options);
128
+
129
+ const inputDir = eleventyConfig.directories?.input ?? './src/';
130
+ const layoutsDir =
131
+ eleventyConfig.directories?.layouts ??
132
+ eleventyConfig.directories?.includes ??
133
+ './src/_includes/';
134
+
135
+ opts.iconDirs = [
136
+ ...(options.iconDirs ?? [join(inputDir, 'assets/icons')]),
137
+ join(themeRoot, 'assets/icons'),
138
+ join(
139
+ packageRoot(
140
+ '@material-symbols/svg-400',
141
+ '@material-symbols/svg-400/rounded/search.svg',
142
+ ),
143
+ 'rounded',
144
+ ),
145
+ ];
146
+
147
+ eleventyConfig.addGlobalData('site', opts.site);
148
+ eleventyConfig.addGlobalData('navigation', opts.navigation);
149
+ eleventyConfig.addGlobalData('practiceAreas', opts.practiceAreas);
150
+ eleventyConfig.addGlobalData('dirs', opts.dirs);
151
+
152
+ // Nunjucks resolves includes from the project first, then the theme,
153
+ // so any theme partial or stylesheet can be overridden by creating a
154
+ // file of the same name in the project's includes directory.
155
+ addThemeSearchPaths(eleventyConfig, [
156
+ join(themeRoot, 'templates/partials'),
157
+ join(themeRoot, 'assets'),
158
+ ]);
159
+
160
+ collections(eleventyConfig, join(inputDir, opts.dirs.decisions, '*.md'));
161
+ data(eleventyConfig, opts.dirs.decisions);
162
+ filters(eleventyConfig);
163
+ plugins(eleventyConfig, opts);
164
+ shortcodes(eleventyConfig, opts);
165
+
166
+ // Theme assets copy file-by-file so a project file at the same path
167
+ // under src/assets/ replaces the theme's copy instead of colliding.
168
+ const projectAssets = join(inputDir, 'assets');
169
+ const themeAssets = join(themeRoot, 'assets');
170
+ for (const entry of readdirSync(themeAssets, {
171
+ recursive: true,
172
+ withFileTypes: true,
173
+ })) {
174
+ if (!entry.isFile()) continue;
175
+ const rel = relative(themeAssets, join(entry.parentPath, entry.name));
176
+ if (existsSync(join(projectAssets, rel))) continue;
177
+ eleventyConfig.addPassthroughCopy({
178
+ [relative('.', join(themeAssets, rel))]: `/${rel}`,
179
+ });
180
+ }
181
+ if (existsSync(projectAssets)) {
182
+ eleventyConfig.addPassthroughCopy({ [projectAssets]: '/' });
183
+ }
184
+ eleventyConfig.addPassthroughCopy({
185
+ [relative('.', join(packageRoot('@orama/orama'), 'dist/browser'))]:
186
+ '/js/orama',
187
+ });
188
+
189
+ watchTheme(eleventyConfig);
190
+
191
+ for (const name of LAYOUTS) {
192
+ if (!existsSync(join(layoutsDir, name))) {
193
+ eleventyConfig.addTemplate(
194
+ relative(inputDir, join(layoutsDir, name)),
195
+ readFileSync(join(themeRoot, 'templates/layouts', name), 'utf8'),
196
+ );
197
+ }
198
+ }
199
+
200
+ for (const name of PAGES) {
201
+ if (!hasOverride(inputDir, name)) {
202
+ eleventyConfig.addTemplate(
203
+ name,
204
+ readFileSync(join(themeRoot, 'templates/pages', name), 'utf8'),
205
+ );
206
+ }
207
+ }
208
+
209
+ for (const name of CONTENT) {
210
+ if (!hasOverride(inputDir, name)) {
211
+ eleventyConfig.addTemplate(
212
+ name,
213
+ readFileSync(join(themeRoot, name), 'utf8'),
214
+ { templateEngineOverride: 'njk,md' },
215
+ );
216
+ }
217
+ }
218
+
219
+ for (const name of ASSETS) {
220
+ if (!hasOverride(inputDir, name)) {
221
+ eleventyConfig.addTemplate(
222
+ name,
223
+ readFileSync(join(themeRoot, 'templates/assets', name), 'utf8'),
224
+ );
225
+ }
226
+ }
227
+ }
package/src/index.md ADDED
@@ -0,0 +1,28 @@
1
+ ---
2
+ title: Decision Records
3
+ description: Documented architectural decisions and best practices
4
+ eleventyExcludeFromCollections: true
5
+ ---
6
+
7
+ # {{ title }}
8
+
9
+ An Architectural Decision Record (ADR) captures a single software design choice that addresses an architecturally significant requirement. Our collection of ADRs forms a decision log that helps the team understand the history and reasoning behind key choices.
10
+
11
+ Maintaining these records helps us:
12
+
13
+ - Speed up onboarding for new team members
14
+ - Avoid blindly accepting or reversing past decisions
15
+ - Formalize the decision-making process across the team
16
+
17
+ <h2 class="section-heading" id="practice-areas">Explore by Practice Area</h2>
18
+
19
+ <div class="practice-areas">
20
+ {%- for area in practiceAreas %}
21
+ {%- set areaAdrs = collections.adrs | withPracticeArea(area.name) %}
22
+ <a href="/practice-areas/{{ area.name | slugify }}/" class="practice-area">
23
+ <span class="icon">{% icon area.icon %}</span>
24
+ <span class="name">{{ area.name }}</span>
25
+ <span class="count">{{ areaAdrs | length }}</span>
26
+ </a>
27
+ {%- endfor %}
28
+ </div>
@@ -0,0 +1,10 @@
1
+ /**
2
+ * All ADRs sorted by date descending.
3
+ */
4
+ export function makeAdrs(glob) {
5
+ return function adrs(collectionApi) {
6
+ return collectionApi
7
+ .getFilteredByGlob(glob)
8
+ .sort((a, b) => b.date - a.date);
9
+ };
10
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Unique set of all contributors across ADRs.
3
+ */
4
+ export function makeContributors(glob) {
5
+ return function contributors(collectionApi) {
6
+ const adrs = collectionApi.getFilteredByGlob(glob);
7
+ const contributors = new Set();
8
+ for (const adr of adrs) {
9
+ for (const contributor of adr.data.contributors || []) {
10
+ contributors.add(contributor);
11
+ }
12
+ }
13
+ return [...contributors].sort();
14
+ };
15
+ }
@@ -0,0 +1,14 @@
1
+ import { makeAdrs } from './adrs.js';
2
+ import { makeTopics } from './topics.js';
3
+ import { makeContributors } from './contributors.js';
4
+
5
+ /**
6
+ * Register all custom collections with Eleventy. All three derive from the
7
+ * same glob, so `dirs.decisions` is the single place a project changes to
8
+ * move its records.
9
+ */
10
+ export default function (eleventyConfig, glob) {
11
+ eleventyConfig.addCollection('adrs', makeAdrs(glob));
12
+ eleventyConfig.addCollection('topics', makeTopics(glob));
13
+ eleventyConfig.addCollection('contributors', makeContributors(glob));
14
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Topics with counts, sorted by count descending.
3
+ */
4
+ export function makeTopics(glob) {
5
+ return function topics(collectionApi) {
6
+ const adrs = collectionApi.getFilteredByGlob(glob);
7
+ const counts = new Map();
8
+ for (const adr of adrs) {
9
+ for (const topic of adr.data.topics || []) {
10
+ const key = topic.toLowerCase();
11
+ counts.set(key, (counts.get(key) || 0) + 1);
12
+ }
13
+ }
14
+ return [...counts.entries()]
15
+ .map(([label, count]) => ({ label, count }))
16
+ .sort((a, b) => b.count - a.count);
17
+ };
18
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Register all global data with Eleventy. Markdown files inside the
3
+ * decisions directory get the ADR layout; everything else gets the
4
+ * plain page layout.
5
+ */
6
+ export default function (eleventyConfig, decisionsDir) {
7
+ eleventyConfig.addGlobalData('layout', 'page.njk');
8
+ eleventyConfig.addGlobalData('eleventyComputed', {
9
+ layout: (data) => {
10
+ if (
11
+ data.page.inputPath.includes(`/${decisionsDir}/`) &&
12
+ data.page.inputPath.endsWith('.md')
13
+ ) {
14
+ return 'adr.njk';
15
+ }
16
+ return data.layout;
17
+ },
18
+ });
19
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Filters a collection of ADRs to those with a given contributor.
3
+ *
4
+ * @example
5
+ * {{ collections.adrs | byContributor("Jane Doe") }}
6
+ * → [adr1, adr3]
7
+ */
8
+ export function byContributor(collection, contributor) {
9
+ return collection.filter((item) =>
10
+ (item.data.contributors || []).includes(contributor),
11
+ );
12
+ }
13
+
14
+ /**
15
+ * Filters a collection of ADRs to those with a given topic.
16
+ *
17
+ * @example
18
+ * {{ collections.adrs | withTopic("accessibility") }}
19
+ * → [adr2, adr5]
20
+ */
21
+ export function withTopic(collection, topic) {
22
+ const needle = topic.toLowerCase();
23
+ return collection.filter((item) =>
24
+ (item.data.topics || []).some((t) => t.toLowerCase() === needle),
25
+ );
26
+ }
27
+
28
+ /**
29
+ * Filters a collection of ADRs to those with a given practice area.
30
+ *
31
+ * @example
32
+ * {{ collections.adrs | withPracticeArea("Engineering") }}
33
+ * → [adr1, adr4]
34
+ */
35
+ export function withPracticeArea(collection, practiceArea) {
36
+ return collection.filter((item) => item.data.practiceArea === practiceArea);
37
+ }
38
+
39
+ /**
40
+ * Returns a new object with the given key set to the given value.
41
+ * Useful for accumulating counts in Nunjucks loops.
42
+ *
43
+ * @example
44
+ * {% set counts = counts | setKey("css", 3) %}
45
+ */
46
+ export function setKey(obj, key, value) {
47
+ return { ...obj, [key]: value };
48
+ }
49
+
50
+ /**
51
+ * Returns the top N topic labels from a { topic: count } map,
52
+ * sorted by count descending.
53
+ *
54
+ * @example
55
+ * {{ topicCounts | topTopics(3) }}
56
+ * → ["css", "accessibility", "react"]
57
+ */
58
+ export function topTopics(counts, n) {
59
+ return Object.entries(counts)
60
+ .sort((a, b) => b[1] - a[1])
61
+ .slice(0, n)
62
+ .map(([label]) => label);
63
+ }
@@ -0,0 +1,68 @@
1
+ const Temporal =
2
+ globalThis.Temporal ?? (await import('@js-temporal/polyfill')).Temporal;
3
+
4
+ /**
5
+ * Returns a relative time string (e.g. "3 months ago").
6
+ *
7
+ * @example
8
+ * {{ adr.data.date | timeSince }}
9
+ * → "3 months ago"
10
+ */
11
+ export function timeSince(date) {
12
+ const from = Temporal.PlainDate.from(
13
+ new Date(date).toISOString().slice(0, 10),
14
+ );
15
+ const now = Temporal.Now.plainDateISO();
16
+ const duration = from.until(now, { largestUnit: 'years' });
17
+ const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'always' });
18
+
19
+ if (duration.years > 0) {
20
+ return rtf.format(-duration.years, 'year');
21
+ }
22
+ if (duration.months > 0) {
23
+ return rtf.format(-duration.months, 'month');
24
+ }
25
+ if (duration.days > 0) {
26
+ return rtf.format(-duration.days, 'day');
27
+ }
28
+ return 'today';
29
+ }
30
+
31
+ /**
32
+ * Formats a date as "Month DD, YYYY".
33
+ *
34
+ * @example
35
+ * {{ adr.data.date | datetimeFormat }}
36
+ * → "January 1, 2024"
37
+ */
38
+ export function datetimeFormat(date) {
39
+ const d = new Date(date);
40
+ return d.toLocaleDateString('en-US', {
41
+ year: 'numeric',
42
+ month: 'long',
43
+ day: 'numeric',
44
+ timeZone: 'UTC',
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Returns the current four-digit year.
50
+ *
51
+ * @example
52
+ * {{ null | year }}
53
+ * → "2026"
54
+ */
55
+ export function year() {
56
+ return new Date().getFullYear().toString();
57
+ }
58
+
59
+ /**
60
+ * Returns an ISO date string (YYYY-MM-DD) for use in <time datetime>.
61
+ *
62
+ * @example
63
+ * {{ adr.data.date | isoDate }}
64
+ * → "2024-01-01"
65
+ */
66
+ export function isoDate(date) {
67
+ return new Date(date).toISOString().slice(0, 10);
68
+ }
@@ -0,0 +1,26 @@
1
+ import { timeSince, datetimeFormat, year, isoDate } from './dates.js';
2
+ import {
3
+ byContributor,
4
+ withTopic,
5
+ withPracticeArea,
6
+ setKey,
7
+ topTopics,
8
+ } from './collections.js';
9
+ import { spaceless, scrollable } from './strings.js';
10
+
11
+ /**
12
+ * Register all custom filters with Eleventy.
13
+ */
14
+ export default function (eleventyConfig) {
15
+ eleventyConfig.addFilter('timeSince', timeSince);
16
+ eleventyConfig.addFilter('datetimeFormat', datetimeFormat);
17
+ eleventyConfig.addFilter('year', year);
18
+ eleventyConfig.addFilter('isoDate', isoDate);
19
+ eleventyConfig.addFilter('byContributor', byContributor);
20
+ eleventyConfig.addFilter('withTopic', withTopic);
21
+ eleventyConfig.addFilter('withPracticeArea', withPracticeArea);
22
+ eleventyConfig.addFilter('setKey', setKey);
23
+ eleventyConfig.addFilter('topTopics', topTopics);
24
+ eleventyConfig.addFilter('spaceless', spaceless);
25
+ eleventyConfig.addFilter('scrollable', scrollable);
26
+ }
@@ -0,0 +1,23 @@
1
+ import { parse } from 'node-html-parser';
2
+
3
+ /**
4
+ * Strips whitespace at the start/end and between HTML tags.
5
+ */
6
+ export function spaceless(html) {
7
+ return html.replace(/^\s+/, '').replace(/>\s+</g, '><').replace(/\s+$/, '');
8
+ }
9
+
10
+ /**
11
+ * Wraps each table in a box that can scroll sideways, so a wide table is
12
+ * not cut off by the content column. js/scrollable.js makes the box
13
+ * keyboard-reachable only while it actually overflows.
14
+ *
15
+ * {{ content | scrollable | safe }}
16
+ */
17
+ export function scrollable(html) {
18
+ const root = parse(html);
19
+ for (const table of root.querySelectorAll('table')) {
20
+ table.replaceWith(`<div class="table-scroll">${table.toString()}</div>`);
21
+ }
22
+ return root.toString();
23
+ }
@@ -0,0 +1,23 @@
1
+ import { transform } from 'lightningcss';
2
+ import pluginBundle from '@11ty/eleventy-plugin-bundle';
3
+
4
+ /**
5
+ * Register the bundle plugin with lightningcss minification.
6
+ */
7
+ export function bundle(eleventyConfig) {
8
+ eleventyConfig.addPlugin(pluginBundle, {
9
+ transforms: [
10
+ function (content) {
11
+ if (this.type === 'css') {
12
+ const { code } = transform({
13
+ filename: 'bundle.css',
14
+ code: Buffer.from(content),
15
+ minify: true,
16
+ });
17
+ return code.toString();
18
+ }
19
+ return content;
20
+ },
21
+ ],
22
+ });
23
+ }
@@ -0,0 +1,16 @@
1
+ import { bundle } from './bundle.js';
2
+ import { markdown } from './markdown.js';
3
+ import { toc } from './toc.js';
4
+ import { rss } from './rss.js';
5
+ import { syntaxHighlight } from './syntax-highlight.js';
6
+
7
+ /**
8
+ * Register all plugins with Eleventy.
9
+ */
10
+ export default function (eleventyConfig, opts) {
11
+ bundle(eleventyConfig);
12
+ markdown(eleventyConfig);
13
+ toc(eleventyConfig);
14
+ rss(eleventyConfig, opts.site);
15
+ syntaxHighlight(eleventyConfig);
16
+ }
@@ -0,0 +1,10 @@
1
+ import MarkdownIt from 'markdown-it';
2
+ import markdownItAnchor from 'markdown-it-anchor';
3
+
4
+ /**
5
+ * Configure the Markdown library with anchor links.
6
+ */
7
+ export function markdown(eleventyConfig) {
8
+ const mdLib = new MarkdownIt({ html: true }).use(markdownItAnchor);
9
+ eleventyConfig.setLibrary('md', mdLib);
10
+ }