@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.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/package.json +94 -0
- package/src/YYYYMMDD-decision-template.md +41 -0
- package/src/about.md +36 -0
- package/src/assets/fonts/OFL.txt +93 -0
- package/src/assets/fonts/gabarito-v9-latin-500.woff2 +0 -0
- package/src/assets/fonts/gabarito-v9-latin-700.woff2 +0 -0
- package/src/assets/fonts/gabarito-v9-latin-regular.woff2 +0 -0
- package/src/assets/icons/facebook.svg +1 -0
- package/src/assets/icons/github.svg +1 -0
- package/src/assets/icons/linkedin.svg +1 -0
- package/src/assets/icons/mastodon.svg +1 -0
- package/src/assets/icons/x.svg +1 -0
- package/src/assets/icons/youtube.svg +1 -0
- package/src/assets/images/horizontal.jpg +0 -0
- package/src/assets/images/vertical.jpg +0 -0
- package/src/assets/js/scrollable.js +49 -0
- package/src/assets/js/search.js +189 -0
- package/src/assets/js/time-since.js +31 -0
- package/src/assets/styles/content.css +149 -0
- package/src/assets/styles/fonts.css +33 -0
- package/src/assets/styles/footer.css +65 -0
- package/src/assets/styles/layout.css +483 -0
- package/src/assets/styles/pages.css +348 -0
- package/src/assets/styles/recent-decisions.css +104 -0
- package/src/assets/styles/reset.css +107 -0
- package/src/assets/styles/search-dialog.css +196 -0
- package/src/assets/styles/tags.css +16 -0
- package/src/assets/styles/tokens.css +181 -0
- package/src/assets/styles/typography.css +182 -0
- package/src/assets/styles/view-transitions.css +67 -0
- package/src/config.js +38 -0
- package/src/defaultConfig.json +99 -0
- package/src/index.js +227 -0
- package/src/index.md +28 -0
- package/src/lib/collections/adrs.js +10 -0
- package/src/lib/collections/contributors.js +15 -0
- package/src/lib/collections/index.js +14 -0
- package/src/lib/collections/topics.js +18 -0
- package/src/lib/data/index.js +19 -0
- package/src/lib/filters/collections.js +63 -0
- package/src/lib/filters/dates.js +68 -0
- package/src/lib/filters/index.js +26 -0
- package/src/lib/filters/strings.js +23 -0
- package/src/lib/plugins/bundle.js +23 -0
- package/src/lib/plugins/index.js +16 -0
- package/src/lib/plugins/markdown.js +10 -0
- package/src/lib/plugins/rss.js +24 -0
- package/src/lib/plugins/syntax-highlight.js +8 -0
- package/src/lib/plugins/toc.js +11 -0
- package/src/lib/shortcodes/icons.js +91 -0
- package/src/lib/shortcodes/index.js +11 -0
- package/src/lib/shortcodes/search.js +32 -0
- package/src/templates/assets/favicon.njk +7 -0
- package/src/templates/assets/search_index.njk +13 -0
- package/src/templates/layouts/adr.njk +59 -0
- package/src/templates/layouts/page.njk +136 -0
- package/src/templates/pages/contributor.njk +51 -0
- package/src/templates/pages/contributors.njk +46 -0
- package/src/templates/pages/decisions.njk +41 -0
- package/src/templates/pages/practice-areas.njk +48 -0
- package/src/templates/pages/topics.njk +50 -0
- package/src/templates/partials/footer.njk +12 -0
- package/src/templates/partials/recent-decisions.njk +33 -0
- 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,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
|
+
}
|