@apleasantview/eleventy-plugin-baseline 0.1.0-next.40 → 0.1.0-next.42

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 (73) hide show
  1. package/README.md +30 -32
  2. package/core/back-compat/options.js +69 -0
  3. package/core/content-graph/backlinks.js +65 -0
  4. package/core/content-graph/extractors.js +185 -0
  5. package/core/content-graph/graph.js +121 -0
  6. package/core/content-graph/index.js +2 -0
  7. package/core/content-graph/prepass.js +121 -0
  8. package/core/dates/git-date.js +71 -0
  9. package/core/dates/index.js +55 -0
  10. package/core/locale/derive-lang.js +19 -0
  11. package/core/locale/index.js +6 -0
  12. package/core/locale/normalize-lang.js +13 -0
  13. package/core/locale/normalize-locale.js +20 -0
  14. package/core/locale/open-graph-locale.js +14 -0
  15. package/core/locale/resolve-default.js +27 -0
  16. package/core/locale/resolve-locale.js +16 -0
  17. package/core/logging/banner.js +49 -0
  18. package/core/{logging.js → logging/index.js} +19 -2
  19. package/core/logging/quips.js +30 -0
  20. package/core/markdown/auto-heading-ids.js +86 -0
  21. package/core/markdown/index.js +5 -0
  22. package/core/markdown/safe-use.js +42 -0
  23. package/core/{wikilinks.js → markdown/wikilinks.js} +4 -4
  24. package/core/page-context/build.js +336 -0
  25. package/core/page-context/index.js +1 -0
  26. package/core/page-context/register.js +73 -0
  27. package/core/page-context/seo-helpers.js +56 -0
  28. package/core/schema.js +22 -2
  29. package/core/seo-graph/adapter.js +246 -0
  30. package/core/seo-graph/build.js +87 -0
  31. package/core/seo-graph/index.js +1 -0
  32. package/core/seo-graph/open-graph.js +130 -0
  33. package/core/seo-graph/register.js +42 -0
  34. package/core/seo-graph/schema.js +18 -0
  35. package/core/slug-index.js +2 -2
  36. package/core/state.js +75 -0
  37. package/core/{shortcodes/image.js → surface/image-shortcode.js} +4 -4
  38. package/core/surface/index.js +22 -0
  39. package/core/types.js +3 -0
  40. package/core/utils/add-trailing-slash.js +11 -0
  41. package/core/utils/ensure-dot-slash-dir.js +13 -0
  42. package/core/utils/normalize-language-map.js +37 -0
  43. package/core/utils/resolve-field.js +9 -0
  44. package/core/utils/resolve-subdir.js +20 -0
  45. package/core/utils/slugify.js +15 -0
  46. package/core/utils/title-case-slug.js +15 -0
  47. package/core/utils/unique-by.js +25 -0
  48. package/core/virtual-dir.js +11 -10
  49. package/index.js +161 -118
  50. package/modules/assets/index.js +4 -2
  51. package/modules/assets/processors/esbuild-process.js +2 -2
  52. package/modules/assets/processors/postcss-process.js +2 -2
  53. package/modules/head/drivers/posthtml-head-elements.js +92 -12
  54. package/modules/head/index.js +23 -19
  55. package/modules/head/schema.js +7 -3
  56. package/modules/multilang/filters/i18n-default-translation.js +2 -4
  57. package/modules/multilang/filters/i18n-translation-in.js +2 -2
  58. package/modules/multilang/filters/i18n-translations-for.js +2 -2
  59. package/modules/multilang/index.js +80 -39
  60. package/modules/navigator/index.js +39 -25
  61. package/modules/navigator/templates/navigator-core.html +1 -1
  62. package/modules/sitemap/index.js +8 -4
  63. package/modules/sitemap/templates/sitemap-core.html +1 -1
  64. package/package.json +5 -2
  65. package/core/filters/index.js +0 -4
  66. package/core/global-functions/index.js +0 -6
  67. package/core/page-context.js +0 -310
  68. package/core/shortcodes/index.js +0 -2
  69. package/core/utils/helpers.js +0 -75
  70. /package/core/{global-functions/date.js → dates/date-global.js} +0 -0
  71. /package/core/{filters/markdown.js → markdown/markdownify.js} +0 -0
  72. /package/core/{filters → surface/filters}/isString.js +0 -0
  73. /package/core/{filters → surface/filters}/related-posts.js +0 -0
@@ -0,0 +1,71 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import path from 'node:path';
3
+
4
+ let cache = null;
5
+
6
+ // Build a { repoRelativePath: ISO date } map from one `git log` walk.
7
+ // Each commit emits its date, then the files it touched; first date wins
8
+ // because git log is newest-first.
9
+ function buildCache() {
10
+ const map = new Map();
11
+ const marker = '__BASELINE_COMMIT__';
12
+ let raw;
13
+ try {
14
+ raw = execFileSync(
15
+ 'git',
16
+ ['log', '--name-only', '--no-renames', `--pretty=format:${marker}%cI`],
17
+ { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }
18
+ );
19
+ } catch {
20
+ return map;
21
+ }
22
+
23
+ let currentDate = null;
24
+ for (const line of raw.split('\n')) {
25
+ if (line.startsWith(marker)) {
26
+ // Normalise to UTC so all outputs (sitemap, JSON-LD, schemamap) match.
27
+ currentDate = new Date(line.slice(marker.length)).toISOString();
28
+ } else if (line && currentDate && !map.has(line)) {
29
+ map.set(line, currentDate);
30
+ }
31
+ }
32
+ return map;
33
+ }
34
+
35
+ function normalize(inputPath) {
36
+ const abs = path.resolve(inputPath);
37
+ const rel = path.relative(process.cwd(), abs);
38
+ return rel.split(path.sep).join('/');
39
+ }
40
+
41
+ /**
42
+ * Last-commit date (UTC ISO) for a file, or `null` when git has no record of
43
+ * it (untracked, or no git history available — e.g. a shallow CI clone).
44
+ *
45
+ * Unlike the docs-site copy this carries no mtime/now fallback: the date floor
46
+ * is `page.date`, applied by `resolveDates`, so this stays a pure "what does
47
+ * git say, or nothing" lookup.
48
+ *
49
+ * @param {string} inputPath
50
+ * @returns {string | null}
51
+ */
52
+ export function gitModified(inputPath) {
53
+ if (!cache) cache = buildCache();
54
+ return cache.get(normalize(inputPath)) ?? null;
55
+ }
56
+
57
+ /**
58
+ * The most recent `gitModified` across several paths, or `null` if none resolve.
59
+ *
60
+ * @param {Array<string | undefined | null>} inputPaths
61
+ * @returns {string | null}
62
+ */
63
+ export function maxGitModified(inputPaths) {
64
+ let max = null;
65
+ for (const p of inputPaths) {
66
+ if (!p) continue;
67
+ const iso = gitModified(p);
68
+ if (iso && (!max || iso > max)) max = iso;
69
+ }
70
+ return max;
71
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Dates substrate
3
+ *
4
+ * One home for date concerns: the Nunjucks `date` global (formatting), the
5
+ * git-backed last-commit lookup, and `resolveDates` — the single source for a
6
+ * page's publish/modified dates that the seo-graph substrate and any other
7
+ * consumer read from instead of re-deriving the chain.
8
+ */
9
+
10
+ import { gitModified, maxGitModified } from './git-date.js';
11
+
12
+ export { registerDateGlobal } from './date-global.js';
13
+ export { gitModified, maxGitModified };
14
+
15
+ /** Coerce a value to a valid `Date`, or `undefined` if it can't be parsed. */
16
+ function toDate(value) {
17
+ if (!value) return undefined;
18
+ const d = value instanceof Date ? value : new Date(value);
19
+ return Number.isNaN(d.getTime()) ? undefined : d;
20
+ }
21
+
22
+ /**
23
+ * Resolve a page's publish and modified dates from one place. All three author
24
+ * keys are optional; the chain degrades to `page.date`, which Eleventy always
25
+ * backfills (front matter, else file birthtime), so output is never empty.
26
+ *
27
+ * - `datePublished` → front-matter `datePublished` → `page.date` (the floor).
28
+ * - `dateModified` → front-matter `dateModified` → git last-commit → resolved
29
+ * `datePublished`.
30
+ *
31
+ * `git-date` is the middle rung of the modified chain and is allowed to yield
32
+ * nothing; flooring `dateModified` to the *resolved* `datePublished` (not raw
33
+ * `page.date`) keeps the pair coherent when an author overrides `datePublished`.
34
+ * No clamp to `modified >= published`: a scheduled post can legitimately be
35
+ * modified before its publish date.
36
+ *
37
+ * Takes the full cascade `data` bag (matches the substrate convention) and
38
+ * returns normalised `Date` objects, ready for the seo-graph piece builders.
39
+ *
40
+ * `gitLookup` is an injection seam for tests; production calls pass `data` only
41
+ * and get the real git-backed lookup.
42
+ *
43
+ * @param {{ page?: any, datePublished?: unknown, dateModified?: unknown }} data
44
+ * @param {(inputPath: string) => string | null} [gitLookup]
45
+ * @returns {{ datePublished: Date | undefined, dateModified: Date | undefined }}
46
+ */
47
+ export function resolveDates(data, gitLookup = gitModified) {
48
+ const page = data?.page ?? {};
49
+ const datePublished = toDate(data?.datePublished) ?? toDate(page.date);
50
+ const dateModified =
51
+ toDate(data?.dateModified) ??
52
+ toDate(page.inputPath ? gitLookup(page.inputPath) : null) ??
53
+ datePublished;
54
+ return { datePublished, dateModified };
55
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Extract the short language subtag from a BCP 47 tag.
3
+ *
4
+ * `'en-US'` → `'en'`, `'zh-Hant-HK'` → `'zh'`. Normalises via `Intl.Locale`;
5
+ * null for empty input or tags it rejects.
6
+ *
7
+ * @param {unknown} locale
8
+ * @returns {string | null}
9
+ */
10
+ export function deriveLang(locale) {
11
+ if (locale == null) return null;
12
+ const str = String(locale).trim();
13
+ if (str === '') return null;
14
+ try {
15
+ return new Intl.Locale(str).language;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
@@ -0,0 +1,6 @@
1
+ export { normalizeLang } from './normalize-lang.js';
2
+ export { normalizeLocale } from './normalize-locale.js';
3
+ export { deriveLang } from './derive-lang.js';
4
+ export { resolveDefault } from './resolve-default.js';
5
+ export { resolveLocale } from './resolve-locale.js';
6
+ export { toOpenGraphLocale } from './open-graph-locale.js';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Normalize a short language code: lowercase and trim.
3
+ *
4
+ * The substrate's lightest helper. Empty string for null/undefined; coerces
5
+ * non-string input via `String()`.
6
+ *
7
+ * @param {unknown} raw
8
+ * @returns {string}
9
+ */
10
+ export function normalizeLang(raw) {
11
+ if (raw == null) return '';
12
+ return String(raw).toLowerCase().trim();
13
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Normalize a BCP 47 locale tag to conventional casing using `Intl.Locale`.
3
+ *
4
+ * `Intl.Locale` handles language-script-region casing and subtag rules without
5
+ * reimplementing the spec. Returns null for empty/whitespace input or tags it
6
+ * rejects.
7
+ *
8
+ * @param {unknown} raw
9
+ * @returns {string | null}
10
+ */
11
+ export function normalizeLocale(raw) {
12
+ if (raw == null) return null;
13
+ const str = String(raw).trim();
14
+ if (str === '') return null;
15
+ try {
16
+ return new Intl.Locale(str).toString();
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
@@ -0,0 +1,14 @@
1
+ import { normalizeLocale } from './normalize-locale.js';
2
+
3
+ /**
4
+ * Format a BCP 47 tag in Open Graph's `language_TERRITORY` form (`en-US` →
5
+ * `en_US`). Normalises casing first; null for tags `Intl.Locale` rejects.
6
+ * `replaceAll` so script+region tags convert fully (`zh-Hant-HK` → `zh_Hant_HK`).
7
+ *
8
+ * @param {unknown} raw A BCP 47 locale tag.
9
+ * @returns {string | null}
10
+ */
11
+ export function toOpenGraphLocale(raw) {
12
+ const normalized = normalizeLocale(raw);
13
+ return normalized ? normalized.replaceAll('-', '_') : null;
14
+ }
@@ -0,0 +1,27 @@
1
+ import { normalizeLang } from './normalize-lang.js';
2
+ import { normalizeLocale } from './normalize-locale.js';
3
+ import { deriveLang } from './derive-lang.js';
4
+
5
+ /**
6
+ * Resolve the effective `{ lang, locale }` default from settings.
7
+ *
8
+ * defaultLocale wins; defaultLanguage is a writer-side alias. Given only
9
+ * defaultLanguage, locale is derived via `Intl.Locale`.
10
+ *
11
+ * @param {{ defaultLanguage?: string, defaultLocale?: string }} settings
12
+ * @returns {{ lang: string, locale: string | null }}
13
+ */
14
+ export function resolveDefault(settings) {
15
+ const explicitLang = normalizeLang(settings?.defaultLanguage);
16
+ const explicitLocale = normalizeLocale(settings?.defaultLocale);
17
+
18
+ if (explicitLocale) {
19
+ return { lang: deriveLang(explicitLocale) ?? explicitLang, locale: explicitLocale };
20
+ }
21
+
22
+ if (explicitLang) {
23
+ return { lang: explicitLang, locale: normalizeLocale(explicitLang) ?? explicitLang };
24
+ }
25
+
26
+ return { lang: '', locale: null };
27
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Pick the page's BCP 47 locale out of the cascade.
3
+ *
4
+ * Reads, never normalises — trusts multilang's already-resolved `page.locale`
5
+ * first, then bag-level locale, the language's configured locale, and the bare
6
+ * `lang` tag last.
7
+ *
8
+ * @param {{ locale?: string } | undefined} node The navigator node, if any.
9
+ * @param {Record<string, any>} data The Eleventy cascade data bag.
10
+ * @param {{ languages?: Record<string, { locale?: string }> } | undefined} settings
11
+ * @param {string} lang Resolved language subtag; the final fallback.
12
+ * @returns {string}
13
+ */
14
+ export function resolveLocale(node, data, settings, lang) {
15
+ return node?.locale || data?.page?.locale || data?.locale || settings?.languages?.[lang]?.locale || lang;
16
+ }
@@ -0,0 +1,49 @@
1
+ import chalk from 'kleur';
2
+
3
+ const BANNER_GLOBAL_KEY = Symbol.for('eleventy:baseline:banner');
4
+ const FALLBACK_NAME = 'Eleventy Baseline';
5
+ const MARGIN = 4;
6
+
7
+ /**
8
+ * Resolve the label shown inside the banner. Reads `npm_package_name`
9
+ * (set by npm when Baseline runs under `npm run …`) and falls back to
10
+ * the plugin's own name when the env var is absent (raw `npx`, direct
11
+ * node, programmatic use).
12
+ *
13
+ * @returns {string}
14
+ */
15
+ function resolveBannerLabel() {
16
+ return process.env.npm_package_name || FALLBACK_NAME;
17
+ }
18
+
19
+ /**
20
+ * Render the boxed startup banner string. Pure, label-only.
21
+ * Width scales with the label so any project name fits.
22
+ *
23
+ * @param {string} [label]
24
+ * @returns {string}
25
+ */
26
+ export function baselineBanner(label = resolveBannerLabel()) {
27
+ const inner = label.length + MARGIN * 2;
28
+ const top = '╔' + '═'.repeat(inner) + '╗';
29
+ const middle = '║' + ' '.repeat(MARGIN) + chalk.bold().white(label) + ' '.repeat(MARGIN) + '║';
30
+ const bottom = '╚' + '═'.repeat(inner) + '╝';
31
+
32
+ return ['', top, middle, bottom, ''].join('\n');
33
+ }
34
+
35
+ /**
36
+ * Print the banner and an intro line once per process. Guarded by a global
37
+ * symbol so repeated plugin invocations (inner pre-pass Eleventy,
38
+ * multi-instance setups) don't re-print.
39
+ *
40
+ * @param {import('./index.js').BaselineLogger} log
41
+ * @param {{ version: string, eleventyVersion?: string }} versions
42
+ */
43
+ export function printBannerOnce(log, { version, eleventyVersion } = {}) {
44
+ if (globalThis[BANNER_GLOBAL_KEY]) return;
45
+ globalThis[BANNER_GLOBAL_KEY] = true;
46
+ log.print(baselineBanner());
47
+ const tail = eleventyVersion ? `, running Eleventy v${eleventyVersion}` : '';
48
+ log.info(`Baseline v${version}${tail}`);
49
+ }
@@ -1,5 +1,8 @@
1
1
  import chalk from 'kleur';
2
2
 
3
+ export * from './banner.js';
4
+ export * from './quips.js';
5
+
3
6
  /**
4
7
  * Logger (runtime substrate)
5
8
  *
@@ -36,11 +39,12 @@ import chalk from 'kleur';
36
39
  * @property {(...args: unknown[]) => void} info Verbose-only.
37
40
  * @property {(...args: unknown[]) => void} warn Always visible.
38
41
  * @property {(...args: unknown[]) => void} error Always visible.
42
+ * @property {(content: string) => void} print Unprefixed pass-through (used by the banner).
39
43
  */
40
44
 
41
45
  /**
42
46
  * Create a namespaced logger. Prefix is `[baseline]` at plugin root and
43
- * `[baseline:<namespace>]` inside modules. `info` is gated behind `verbose`;
47
+ * `[baseline/<namespace>]` inside modules. `info` is gated behind `verbose`;
44
48
  * `warn` and `error` always emit.
45
49
  *
46
50
  * @param {string | null | undefined} namespace
@@ -49,15 +53,28 @@ import chalk from 'kleur';
49
53
  */
50
54
  export function createLogger(namespace, { verbose = false } = {}) {
51
55
  const label = namespace ? `[baseline/${namespace}]` : '[baseline]';
56
+
57
+ // Pre-pass gate: silence baseline's own info during the inner Eleventy
58
+ // run so modules don't double-log every line they emit again during the
59
+ // real build. Env-var contract scoped to runPrepass's execution (set in
60
+ // try, cleared in finally). Eleventy's own `[11ty]` output is governed
61
+ // by its `quietMode` and stays untouched here.
62
+ const isPrepass = () => process.env.BASELINE_PREPASS_ACTIVE === '1';
63
+
52
64
  return {
53
65
  info: (...args) => {
54
- if (verbose) console.log(chalk.gray(label), ...args);
66
+ if (!verbose) return;
67
+ if (isPrepass()) return;
68
+ console.log(chalk.gray(label), ...args);
55
69
  },
56
70
  warn: (...args) => {
57
71
  console.warn(chalk.yellow().bold(label), ...args);
58
72
  },
59
73
  error: (...args) => {
60
74
  console.error(chalk.red().bold(label), ...args);
75
+ },
76
+ print: (content) => {
77
+ console.log(chalk.gray(content));
61
78
  }
62
79
  };
63
80
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Logging quips (personality)
3
+ *
4
+ * A small set of one-liners used at the pre-pass boundary. The pre-pass
5
+ * runs Eleventy inside Eleventy, so each line is a nod to that idea of
6
+ * repetition or recursion. Picked at random per build.
7
+ *
8
+ * Architecture layer:
9
+ * runtime substrate (logging)
10
+ *
11
+ * Why this exists:
12
+ * Build logs are dry by default. A single playful line at a recurring
13
+ * moment makes the narrative feel like the project, not a config dump.
14
+ */
15
+
16
+ const REPETITION_QUIPS = [
17
+ 'Somewhere, a bowl of petunias is thinking: oh no, not again.',
18
+ 'Déjà vu is usually a glitch in the Matrix.',
19
+ 'All of this has happened before, and all of it will happen again.',
20
+ 'Yo dawg, I heard you like Eleventy, so I put Eleventy in your Eleventy…'
21
+ ];
22
+
23
+ /**
24
+ * Return a random repetition quip.
25
+ *
26
+ * @returns {string}
27
+ */
28
+ export function pickRepetitionQuip() {
29
+ return REPETITION_QUIPS[Math.floor(Math.random() * REPETITION_QUIPS.length)];
30
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Auto heading IDs (runtime substrate)
3
+ *
4
+ * Assigns stable id attributes to every heading that doesn't already have
5
+ * one. Manual ids (from markdown-it-attrs or any upstream plugin) win and
6
+ * seed the dedup map, so auto ids skip over names a manual id has claimed.
7
+ * Duplicate slugs get WordPress-style suffixes: foo, foo-2, foo-3, …
8
+ *
9
+ * Architecture layer:
10
+ * runtime substrate
11
+ *
12
+ * System role:
13
+ * Markdown-it plugin registered by the composition root via
14
+ * amendLibrary('md', ...). Mutates heading_open tokens; reads nothing
15
+ * from the runtime.
16
+ *
17
+ * Lifecycle:
18
+ * transform-time → assigns id attrs on heading_open tokens during the
19
+ * core parsing phase
20
+ *
21
+ * Why this exists:
22
+ * The content graph and deep-link consumers all need predictable heading
23
+ * ids. Doing it inside the markdown engine means the rendered HTML and
24
+ * the graph's heading record are guaranteed identical, instead of being
25
+ * "usually equal because the same slugify helper runs on both sides".
26
+ *
27
+ * Scope:
28
+ * Owns id assignment and dedup. Does not render permalink affordances;
29
+ * that's a theme concern.
30
+ *
31
+ * @param {import('markdown-it').default} md
32
+ * @param {Object} deps
33
+ * @param {(text: string) => string | undefined} deps.slugify
34
+ */
35
+ export function autoHeadingIds(md, { slugify } = {}) {
36
+ if (typeof slugify !== 'function') {
37
+ throw new Error('auto-heading-ids plugin requires { slugify }');
38
+ }
39
+
40
+ md.core.ruler.push('baseline_auto_heading_ids', (state) => {
41
+ const tokens = state.tokens;
42
+ const seen = new Set();
43
+ const counts = new Map();
44
+
45
+ // Pass 1: seed dedup with manual ids so autos never collide with them.
46
+ for (let i = 0; i < tokens.length; i++) {
47
+ const t = tokens[i];
48
+ if (t.type !== 'heading_open') continue;
49
+ const manual = t.attrGet('id');
50
+ if (manual) seen.add(manual);
51
+ }
52
+
53
+ // Pass 2: assign auto ids to headings that lack one.
54
+ for (let i = 0; i < tokens.length; i++) {
55
+ const t = tokens[i];
56
+ if (t.type !== 'heading_open') continue;
57
+ if (t.attrGet('id')) continue;
58
+
59
+ // Read from children rather than .content: markdown-it-attrs
60
+ // strips the {.class} from children tokens but leaves the parent
61
+ // inline .content untouched, so slugifying .content would fold
62
+ // the attribute text into the id.
63
+ const inline = tokens[i + 1];
64
+ const text = (inline?.children || [])
65
+ .filter((c) => c.type === 'text' || c.type === 'code_inline' || c.type === 'image')
66
+ .map((c) => (c.type === 'image' ? c.attrGet('alt') || c.content || '' : c.content))
67
+ .join('')
68
+ .trim();
69
+ if (!text) continue;
70
+
71
+ const base = slugify(text);
72
+ if (!base) continue;
73
+
74
+ let n = counts.get(base) || 0;
75
+ let id;
76
+ do {
77
+ id = n === 0 ? base : `${base}-${n + 1}`;
78
+ n += 1;
79
+ } while (seen.has(id));
80
+
81
+ counts.set(base, n);
82
+ seen.add(id);
83
+ t.attrSet('id', id);
84
+ }
85
+ });
86
+ }
@@ -0,0 +1,5 @@
1
+ // Markdown substrate barrel.
2
+ export { autoHeadingIds } from './auto-heading-ids.js';
3
+ export { wikilinks } from './wikilinks.js';
4
+ export { safeUse } from './safe-use.js';
5
+ export { markdownFilter } from './markdownify.js';
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Safe markdown-it plugin registration
3
+ *
4
+ * Calls md.use(plugin, options) only when no rule with the given name is
5
+ * already registered on the core, block, or inline ruler. Lets Baseline
6
+ * coexist with user configs that loaded the same plugin themselves.
7
+ *
8
+ * Architecture layer:
9
+ * utility
10
+ *
11
+ * System role:
12
+ * Helper used by the composition root when amending the markdown-it
13
+ * instance.
14
+ *
15
+ * Lifecycle:
16
+ * transform-time (config load)
17
+ *
18
+ * Why this exists:
19
+ * markdown-it has no built-in dedup. Calling .use() twice on a plugin
20
+ * that pushes a parser rule runs it twice per render. Eleventy's
21
+ * amendLibrary callbacks compose with whatever the user already wired,
22
+ * so a guard at the seam keeps both sides intact.
23
+ *
24
+ * @param {import('markdown-it').default} md
25
+ * @param {string} ruleName - canonical rule name the plugin registers
26
+ * @param {(md: any, options?: any) => void} plugin
27
+ * @param {any} [options]
28
+ * @param {{ info?: (msg: string) => void }} [log]
29
+ */
30
+ export function safeUse(md, ruleName, plugin, options, log) {
31
+ const rulers = [md.core?.ruler, md.block?.ruler, md.inline?.ruler];
32
+ const installed = rulers.some((r) =>
33
+ r?.__rules__?.some((rule) => rule.name === ruleName)
34
+ );
35
+
36
+ if (installed) {
37
+ log?.info?.(`markdown-it rule "${ruleName}" already registered, skipping`);
38
+ return;
39
+ }
40
+
41
+ md.use(plugin, options);
42
+ }
@@ -1,4 +1,4 @@
1
- import { slugify } from './utils/helpers.js';
1
+ import { slugify } from '../utils/slugify.js';
2
2
 
3
3
  /**
4
4
  * Wikilinks (runtime substrate)
@@ -38,7 +38,7 @@ import { slugify } from './utils/helpers.js';
38
38
  *
39
39
  * @param {import('markdown-it').default} md
40
40
  * @param {Object} deps
41
- * @param {{getBySlug: (slug: string) => string | null}} deps.slugIndex
41
+ * @param {{getBySlug: (slug: string) => string | undefined}} deps.slugIndex
42
42
  * @param {{getByKey: (url: string) => any}} deps.pageContextRegistry
43
43
  * @param {{get: () => Record<string, Record<string, {url: string, title?: string}>> | null}} [deps.translationMapStore]
44
44
  */
@@ -88,7 +88,7 @@ export function wikilinks(md, { slugIndex, pageContextRegistry, translationMapSt
88
88
  }
89
89
 
90
90
  const lang = rawLang.toLowerCase();
91
- const translationKey = ctx?.page?.locale?.translationKey;
91
+ const translationKey = ctx?.page?.translationKey;
92
92
  if (!translationKey) return null;
93
93
 
94
94
  const map = translationMapStore?.get?.();
@@ -148,5 +148,5 @@ export function wikilinks(md, { slugIndex, pageContextRegistry, translationMapSt
148
148
  return true;
149
149
  }
150
150
 
151
- md.inline.ruler.before('link', 'wikilink', tokenize);
151
+ md.inline.ruler.before('link', 'baseline_wikilinks', tokenize);
152
152
  }