@apleasantview/eleventy-plugin-baseline 0.1.0-next.33 → 0.1.0-next.39
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/README.md +48 -23
- package/core/content-map-store.js +51 -0
- package/core/filters/index.js +4 -0
- package/core/filters/isString.js +1 -1
- package/core/filters/related-posts.js +1 -1
- package/core/global-functions/index.js +6 -0
- package/core/logging.js +25 -25
- package/core/page-context.js +310 -0
- package/core/registry.js +110 -0
- package/core/schema.js +37 -0
- package/core/shortcodes/image.js +8 -3
- package/core/shortcodes/index.js +2 -0
- package/core/slug-index.js +61 -0
- package/core/translation-map-store.js +46 -0
- package/core/types.js +73 -0
- package/core/utils/helpers.js +75 -0
- package/core/utils/pick.js +7 -0
- package/core/virtual-dir.js +111 -0
- package/core/wikilinks.js +152 -0
- package/index.js +364 -0
- package/modules/assets/index.js +162 -0
- package/modules/{assets-esbuild/process.js → assets/processors/esbuild-process.js} +3 -1
- package/modules/{assets-postcss/process.js → assets/processors/postcss-process.js} +5 -2
- package/modules/assets/schema.js +14 -0
- package/modules/head/drivers/capo-adapter.js +72 -0
- package/modules/head/drivers/posthtml-head-elements.js +140 -0
- package/modules/head/index.js +106 -0
- package/modules/head/schema.js +42 -0
- package/modules/head/utils/alternates.js +11 -0
- package/modules/head/utils/dedupe.js +47 -0
- package/modules/multilang/index.js +149 -0
- package/modules/navigator/index.js +140 -0
- package/modules/navigator/schema.js +13 -0
- package/modules/{navigator-core → navigator}/templates/navigator-core.html +10 -4
- package/{core → modules/navigator/utils}/debug.js +7 -1
- package/modules/sitemap/index.js +121 -0
- package/modules/{sitemap-core → sitemap}/templates/sitemap-core.html +2 -2
- package/modules/{sitemap-core → sitemap}/templates/sitemap-index.html +2 -2
- package/modules.js +6 -0
- package/package.json +15 -6
- package/core/filters.js +0 -9
- package/core/globals.js +0 -6
- package/core/helpers.js +0 -36
- package/core/modules.js +0 -18
- package/core/shortcodes.js +0 -3
- package/eleventy.config.js +0 -169
- package/modules/assets-core/plugins/assets-core.js +0 -197
- package/modules/head-core/drivers/posthtml-head-elements.js +0 -127
- package/modules/head-core/plugins/head-core.js +0 -75
- package/modules/head-core/utils/head-utils.js +0 -249
- package/modules/multilang-core/plugins/multilang-core.js +0 -118
- package/modules/navigator-core/plugins/navigator-core.js +0 -57
- package/modules/sitemap-core/plugins/sitemap-core.js +0 -88
- /package/core/{globals → global-functions}/date.js +0 -0
- /package/modules/{assets-postcss/fallback → assets/configs}/postcss.config.js +0 -0
- /package/modules/{multilang-core → multilang}/filters/i18n-default-translation.js +0 -0
- /package/modules/{multilang-core → multilang}/filters/i18n-translation-in.js +0 -0
- /package/modules/{multilang-core → multilang}/filters/i18n-translations-for.js +0 -0
package/core/schema.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as z from 'zod';
|
|
2
|
+
|
|
3
|
+
export const configSchema = z.object({
|
|
4
|
+
dir: z.object({
|
|
5
|
+
input: z.string().min(1),
|
|
6
|
+
output: z.string().min(1),
|
|
7
|
+
data: z.string().min(1),
|
|
8
|
+
includes: z.string().min(1),
|
|
9
|
+
assets: z.string().min(1),
|
|
10
|
+
public: z.string().min(1)
|
|
11
|
+
}),
|
|
12
|
+
htmlTemplateEngine: z.string().min(1),
|
|
13
|
+
markdownTemplateEngine: z.string().min(1),
|
|
14
|
+
templateFormats: z
|
|
15
|
+
.array(z.string().min(1))
|
|
16
|
+
.min(1)
|
|
17
|
+
.refine((templateFormats) => templateFormats.includes('njk'), {
|
|
18
|
+
error: 'Baseline requires njk in templateFormats'
|
|
19
|
+
})
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const settingsSchema = z.object({
|
|
23
|
+
title: z.string().optional(),
|
|
24
|
+
tagline: z.string().optional(),
|
|
25
|
+
url: z.string().optional(),
|
|
26
|
+
noindex: z.boolean().optional(),
|
|
27
|
+
defaultLanguage: z.string().optional(),
|
|
28
|
+
languages: z.record(z.string(), z.looseObject({})).optional(),
|
|
29
|
+
head: z
|
|
30
|
+
.object({
|
|
31
|
+
link: z.array(z.looseObject({})).optional(),
|
|
32
|
+
script: z.array(z.looseObject({})).optional(),
|
|
33
|
+
meta: z.array(z.looseObject({})).optional(),
|
|
34
|
+
style: z.array(z.looseObject({})).optional()
|
|
35
|
+
})
|
|
36
|
+
.optional()
|
|
37
|
+
});
|
package/core/shortcodes/image.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import Image from '@11ty/eleventy-img';
|
|
3
|
+
import { createLogger } from '../logging.js';
|
|
4
|
+
|
|
5
|
+
// Module-level logger. Image shortcode only uses `.warn`, which emits regardless
|
|
6
|
+
// of verbose, so we don't thread verbose through the shortcode signature.
|
|
7
|
+
const log = createLogger('image');
|
|
3
8
|
|
|
4
9
|
const DEFAULT_WIDTHS = [320, 640, 960, 1280, 1920, 'auto'];
|
|
5
10
|
const DEFAULT_FORMATS = ['avif', 'webp'];
|
|
@@ -59,13 +64,13 @@ export async function imageShortcode(options = {}) {
|
|
|
59
64
|
} = options;
|
|
60
65
|
// Read from global data set during plugin init. When true, `eleventy:ignore`
|
|
61
66
|
// is added to the <img> (line 140) to prevent double-processing.
|
|
62
|
-
const hasImageTransformPlugin = this.ctx._baseline.hasImageTransformPlugin;
|
|
67
|
+
const hasImageTransformPlugin = this.ctx._baseline.features.hasImageTransformPlugin;
|
|
63
68
|
|
|
64
69
|
// --- Validation and normalization ---
|
|
65
70
|
|
|
66
71
|
if (!src) throw new Error(`imageShortcode: src is required (received ${JSON.stringify(src)})`);
|
|
67
72
|
if (alt == null) {
|
|
68
|
-
|
|
73
|
+
log.warn('alt is required (use empty string for decorative images)');
|
|
69
74
|
}
|
|
70
75
|
|
|
71
76
|
const normalizedCaption = String(caption);
|
|
@@ -101,7 +106,7 @@ export async function imageShortcode(options = {}) {
|
|
|
101
106
|
});
|
|
102
107
|
} catch (error) {
|
|
103
108
|
if (process.env.ELEVENTY_RUN_MODE === 'serve') {
|
|
104
|
-
|
|
109
|
+
log.warn(`transformOnRequest failed for ${src}, retrying.\n > ${error?.message || error}`);
|
|
105
110
|
metadata = await Image(resolvedSrc, imageOptions);
|
|
106
111
|
} else {
|
|
107
112
|
throw error;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { getScope, setEntry, getEntry } from './registry.js';
|
|
2
|
+
|
|
3
|
+
const SCOPE_NAME = 'core:slug-index';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Slug index (runtime substrate)
|
|
7
|
+
*
|
|
8
|
+
* Maps wikilink-friendly slugs to canonical page URLs. Populated by the
|
|
9
|
+
* page-context builder as each page resolves; read by the wikilinks
|
|
10
|
+
* markdown-it plugin during body render.
|
|
11
|
+
*
|
|
12
|
+
* Architecture layer:
|
|
13
|
+
* runtime substrate
|
|
14
|
+
*
|
|
15
|
+
* System role:
|
|
16
|
+
* Forward-link index for the wikilinks plugin. In multilingual sites
|
|
17
|
+
* only defaultLanguage pages register; the wikilinks plugin uses the
|
|
18
|
+
* translation map to hop to other languages.
|
|
19
|
+
*
|
|
20
|
+
* Lifecycle:
|
|
21
|
+
* cascade-time → page-context registers slugs as templates compute
|
|
22
|
+
* transform-time → wikilinks plugin resolves slugs during body render
|
|
23
|
+
*
|
|
24
|
+
* Why this exists:
|
|
25
|
+
* Eleventy's data cascade resolves all eleventyComputed values before any
|
|
26
|
+
* body renders, so the index is complete by the time wikilinks need it.
|
|
27
|
+
* A dedicated scope keeps slug→url out of the page-context values map
|
|
28
|
+
* (which holds url→pageContext).
|
|
29
|
+
*
|
|
30
|
+
* Scope:
|
|
31
|
+
* Owns slug registration with collision detection and slug-keyed lookup.
|
|
32
|
+
* Does not own slug derivation (page-context) or link rendering (wikilinks).
|
|
33
|
+
*
|
|
34
|
+
* Data flow:
|
|
35
|
+
* page-context.buildPageContext → set() → registry scope → wikilinks getBySlug()
|
|
36
|
+
*
|
|
37
|
+
* @param {import('@11ty/eleventy').UserConfig} eleventyConfig
|
|
38
|
+
* @returns {{set: (slug: string, url: string, inputPath?: string) => void, getBySlug: (slug: string) => string | null, snapshot: () => Record<string, {url: string, inputPath?: string}>}}
|
|
39
|
+
*/
|
|
40
|
+
export function createSlugIndex(eleventyConfig) {
|
|
41
|
+
const scope = getScope(eleventyConfig, SCOPE_NAME);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
set(slug, url, inputPath) {
|
|
45
|
+
if (!slug || !url) return;
|
|
46
|
+
const existing = getEntry(scope, slug);
|
|
47
|
+
if (existing && existing.url !== url) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Wikilink slug collision: "${slug}" used by both ${existing.inputPath ?? existing.url} and ${inputPath ?? url}`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
setEntry(scope, slug, { url, inputPath });
|
|
53
|
+
},
|
|
54
|
+
getBySlug(slug) {
|
|
55
|
+
return getEntry(scope, slug)?.url ?? null;
|
|
56
|
+
},
|
|
57
|
+
snapshot() {
|
|
58
|
+
return Object.fromEntries(scope.values);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { getScope, setEntry, getEntry } from './registry.js';
|
|
2
|
+
|
|
3
|
+
const SCOPE_NAME = 'core:translation-map-store';
|
|
4
|
+
const KEY = 'translationMap';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Translation map store (runtime substrate)
|
|
8
|
+
*
|
|
9
|
+
* Hand-off point for the translations map: written by multilang at
|
|
10
|
+
* cascade-time, read by head at transform-time.
|
|
11
|
+
*
|
|
12
|
+
* Architecture layer:
|
|
13
|
+
* runtime substrate
|
|
14
|
+
*
|
|
15
|
+
* System role:
|
|
16
|
+
* Bridge between the multilang module (writer) and the head module (reader).
|
|
17
|
+
* The set/get pair lives in a registry scope rather than the data cascade
|
|
18
|
+
* because head's PostHTML plugin runs outside the cascade.
|
|
19
|
+
*
|
|
20
|
+
* Lifecycle:
|
|
21
|
+
* cascade-time → multilang's translationsMap collection writes via set()
|
|
22
|
+
* transform-time → head reads via get() to build hreflang alternates
|
|
23
|
+
*
|
|
24
|
+
* Why this exists:
|
|
25
|
+
* The translations map is built inside an Eleventy collection, but head
|
|
26
|
+
* needs it inside an htmlTransformer plugin where collections aren't
|
|
27
|
+
* available.
|
|
28
|
+
*
|
|
29
|
+
* Scope:
|
|
30
|
+
* Owns set/get on a per-config scope.
|
|
31
|
+
* Does not own the map's shape, how it's built, or how head uses it.
|
|
32
|
+
*
|
|
33
|
+
* Data flow:
|
|
34
|
+
* multilang translationsMap collection → set() → registry scope → head get()
|
|
35
|
+
*
|
|
36
|
+
* @param {import('@11ty/eleventy').UserConfig} eleventyConfig
|
|
37
|
+
* @returns {{set: (map: object) => void, get: () => object | null}}
|
|
38
|
+
*/
|
|
39
|
+
export function createTranslationMapStore(eleventyConfig) {
|
|
40
|
+
const scope = getScope(eleventyConfig, SCOPE_NAME);
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
set: (map) => setEntry(scope, KEY, map),
|
|
44
|
+
get: () => getEntry(scope, KEY) ?? null
|
|
45
|
+
};
|
|
46
|
+
}
|
package/core/types.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Baseline shared typedefs.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from the composition root so file-level headers stay scannable.
|
|
5
|
+
* No runtime exports; this file exists for IDE and JSDoc tooling only.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {Object} BaselineSettings
|
|
10
|
+
* Site identity and SEO configuration.
|
|
11
|
+
*
|
|
12
|
+
* @property {string} [title]
|
|
13
|
+
* @property {string} [tagline]
|
|
14
|
+
* @property {string} [url]
|
|
15
|
+
* @property {boolean} [noindex]
|
|
16
|
+
* @property {string} [defaultLanguage]
|
|
17
|
+
* @property {Record<string, unknown>} [languages]
|
|
18
|
+
* @property {Object} [head]
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} BaselineOptions
|
|
23
|
+
* Runtime feature flags and behaviour configuration.
|
|
24
|
+
*
|
|
25
|
+
* User-facing input. Each module reads its own slice from `state.options.<module>`.
|
|
26
|
+
*
|
|
27
|
+
* @property {boolean} [verbose]
|
|
28
|
+
* Enables structured debug logging across modules.
|
|
29
|
+
*
|
|
30
|
+
* @property {boolean} [multilingual]
|
|
31
|
+
* Enables multilingual mode. Requires settings.defaultLanguage and
|
|
32
|
+
* settings.languages; the multilang module bails with a log otherwise.
|
|
33
|
+
*
|
|
34
|
+
* @property {boolean} [sitemap]
|
|
35
|
+
* Enables sitemap generation module (default: true).
|
|
36
|
+
*
|
|
37
|
+
* @property {boolean | { template?: boolean, inspectorDepth?: number }} [navigator]
|
|
38
|
+
* Controls navigator tooling. Boolean shorthand activates the module and
|
|
39
|
+
* the virtual debug page. Object form lets the page render flag and the
|
|
40
|
+
* inspector depth be tuned independently. Defaults to true in dev mode.
|
|
41
|
+
*
|
|
42
|
+
* @property {{ titleSeparator?: string, showGenerator?: boolean }} [head]
|
|
43
|
+
* Head module options.
|
|
44
|
+
*
|
|
45
|
+
* @property {{ esbuild?: { minify?: boolean, target?: string } }} [assets]
|
|
46
|
+
* Assets module options. The esbuild slice is permissive — any esbuild
|
|
47
|
+
* option is accepted; only `minify` and `target` are typed.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @typedef {Object} BaselineState
|
|
52
|
+
* Fully resolved internal plugin state.
|
|
53
|
+
*
|
|
54
|
+
* @property {Object} settings
|
|
55
|
+
* @property {Object} options
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {Object} BaselineContext
|
|
60
|
+
* Shared module boundary contract.
|
|
61
|
+
*
|
|
62
|
+
* This context is the only supported interface between:
|
|
63
|
+
* - Eleventy configuration runtime
|
|
64
|
+
* - baseline core
|
|
65
|
+
* - feature modules
|
|
66
|
+
*
|
|
67
|
+
* @property {BaselineState} state
|
|
68
|
+
* @property {Object} runtime
|
|
69
|
+
* @property {Object} runtime.contentMap
|
|
70
|
+
* @property {Object} runtime.site
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { TemplatePath } from '@11ty/eleventy-utils';
|
|
2
|
+
import slugifyLib from 'slugify';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Helper function to add trailing slash to a path
|
|
6
|
+
* @param {string} path
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
export function addTrailingSlash(path) {
|
|
10
|
+
if (path.slice(-1) === '/') {
|
|
11
|
+
return path;
|
|
12
|
+
}
|
|
13
|
+
return path + '/';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve a subdirectory under input and output.
|
|
18
|
+
* Joins inputDir/outputDir with rawDir, normalises, and adds trailing slashes.
|
|
19
|
+
* @param {string} inputDir - The input directory (e.g., "./src/").
|
|
20
|
+
* @param {string} outputDir - The output directory (e.g., "./dist/").
|
|
21
|
+
* @param {string} rawDir - Raw subdirectory value (e.g., "assets", "static").
|
|
22
|
+
* @returns {{input: string, output: string}}
|
|
23
|
+
*/
|
|
24
|
+
export function resolveSubdir(inputDir, outputDir, rawDir) {
|
|
25
|
+
const joinedInput = TemplatePath.join(inputDir, rawDir || '');
|
|
26
|
+
const joinedOutput = TemplatePath.join(outputDir, rawDir || '');
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
input: addTrailingSlash(TemplatePath.standardizeFilePath(joinedInput)),
|
|
30
|
+
output: addTrailingSlash(TemplatePath.standardizeFilePath(joinedOutput))
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Slugify a string into a wikilink-friendly key.
|
|
36
|
+
* Lowercases, strips diacritics, replaces non-alphanumerics with hyphens,
|
|
37
|
+
* trims leading/trailing hyphens. Returns null for empty input.
|
|
38
|
+
*
|
|
39
|
+
* @param {string|null|undefined} input
|
|
40
|
+
* @returns {string|null}
|
|
41
|
+
*/
|
|
42
|
+
export function slugify(input) {
|
|
43
|
+
if (input == null) return null;
|
|
44
|
+
const slug = slugifyLib(String(input), { lower: true, strict: true, trim: true });
|
|
45
|
+
return slug || null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Normalize language input to an object map.
|
|
50
|
+
* Accepts an array of language codes or an object keyed by language code.
|
|
51
|
+
* Returns null if input is invalid or empty.
|
|
52
|
+
*
|
|
53
|
+
* @param {Object} settings - Options object containing languages.
|
|
54
|
+
* @param {import('../logging.js').BaselineLogger} [logger] - Logger for dropped-entry notice.
|
|
55
|
+
* @returns {Record<string, Object>|null} Normalized language map, or null.
|
|
56
|
+
*/
|
|
57
|
+
export function normalizeLanguages(settings, logger) {
|
|
58
|
+
const normalizedLanguages = Array.isArray(settings.languages)
|
|
59
|
+
? Object.fromEntries(
|
|
60
|
+
settings.languages
|
|
61
|
+
.filter((lang) => typeof lang === 'string' && lang.trim())
|
|
62
|
+
.map((lang) => [lang.toLowerCase().trim(), {}])
|
|
63
|
+
)
|
|
64
|
+
: settings.languages && typeof settings.languages === 'object'
|
|
65
|
+
? settings.languages
|
|
66
|
+
: null;
|
|
67
|
+
|
|
68
|
+
if (logger && Array.isArray(settings.languages)) {
|
|
69
|
+
const normalizedCount = normalizedLanguages ? Object.keys(normalizedLanguages).length : 0;
|
|
70
|
+
if (normalizedCount !== settings.languages.length) {
|
|
71
|
+
logger.info('Some languages entries were invalid and were dropped.');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return normalizedLanguages;
|
|
75
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { TemplatePath } from '@11ty/eleventy-utils';
|
|
2
|
+
import { resolveSubdir } from './utils/helpers.js';
|
|
3
|
+
import { createLogger } from './logging.js';
|
|
4
|
+
import { getScope, addScopeListener, setEntry } from './registry.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Virtual directories (runtime substrate)
|
|
8
|
+
*
|
|
9
|
+
* Synthesises extra keys on eleventyConfig.directories (e.g. `assets`,
|
|
10
|
+
* `public`) that Eleventy itself won't accept, and keeps them in sync once
|
|
11
|
+
* Eleventy finalises its real directory map.
|
|
12
|
+
*
|
|
13
|
+
* Architecture layer:
|
|
14
|
+
* runtime substrate
|
|
15
|
+
*
|
|
16
|
+
* System role:
|
|
17
|
+
* Adds virtual dir keys consumed by modules (assets reads
|
|
18
|
+
* `directories.assets`) and by the composition root (passthrough copy from
|
|
19
|
+
* `directories.public`).
|
|
20
|
+
*
|
|
21
|
+
* Lifecycle:
|
|
22
|
+
* build-time → synthesise key, pre-populate cache from eleventyConfig.dir
|
|
23
|
+
* build-time → on `eleventy.directories`, refresh the cache to final paths
|
|
24
|
+
*
|
|
25
|
+
* Why this exists:
|
|
26
|
+
* Eleventy's ProjectDirectories.setViaConfigObject() only honours input,
|
|
27
|
+
* output, data, includes, and layouts. Extra `dir.*` keys are silently
|
|
28
|
+
* ignored, and the `eleventy.directories` event exposes only the same set.
|
|
29
|
+
* Synthesis fills the gap so consumers can read additional dirs the same
|
|
30
|
+
* way they read the real ones.
|
|
31
|
+
*
|
|
32
|
+
* Scope:
|
|
33
|
+
* Owns synthesis of extra `eleventyConfig.directories` keys, the live
|
|
34
|
+
* cache, and a single shared listener for sync.
|
|
35
|
+
* Does not own passthrough copy or watch wiring (composition root and
|
|
36
|
+
* modules own those).
|
|
37
|
+
*
|
|
38
|
+
* Data flow:
|
|
39
|
+
* { name, outputDir } → eleventyConfig.directories[name] getter →
|
|
40
|
+
* live { input, output } cache → consumers
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
const SCOPE_NAME = 'core:virtual-dir';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Register a virtual directory on eleventyConfig.directories.
|
|
47
|
+
*
|
|
48
|
+
* @param {import('@11ty/eleventy').UserConfig} eleventyConfig
|
|
49
|
+
* @param {Object} options
|
|
50
|
+
* @param {string} options.key - Key to synthesise (e.g. 'assets', 'public').
|
|
51
|
+
* @param {string} [options.outputDir] - Override the output subdirectory. Defaults
|
|
52
|
+
* to the raw dir value (symmetric with input). Pass `''` to resolve to the
|
|
53
|
+
* output root (used by `public`, which copies to `/`).
|
|
54
|
+
* @returns {{input: string, output: string}} Live cache; properties refresh when
|
|
55
|
+
* eleventy.directories fires. Safe to read at plugin-init time.
|
|
56
|
+
*/
|
|
57
|
+
export function registerVirtualDir(eleventyConfig, { key, outputDir } = {}) {
|
|
58
|
+
if (!key) {
|
|
59
|
+
throw new Error('registerVirtualDir: `name` is required');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const log = createLogger(SCOPE_NAME);
|
|
63
|
+
const scope = getScope(eleventyConfig, SCOPE_NAME);
|
|
64
|
+
const rawDir = eleventyConfig.dir?.[key] || key;
|
|
65
|
+
const rawOutputDir = outputDir ?? rawDir;
|
|
66
|
+
const cache = { input: null, output: null };
|
|
67
|
+
|
|
68
|
+
// Pre-populate from eleventyConfig.dir so synchronous readers at plugin-init
|
|
69
|
+
// time (watch globs, ignores, compile-guard prefixes) see a valid path.
|
|
70
|
+
syncCache(cache, eleventyConfig.dir || {}, rawDir, rawOutputDir);
|
|
71
|
+
|
|
72
|
+
setEntry(scope, key, { rawDir, rawOutputDir, cache });
|
|
73
|
+
|
|
74
|
+
// Define the virtual key once. The getter reads the live cache, which the
|
|
75
|
+
// shared listener below refreshes when Eleventy emits its final directories.
|
|
76
|
+
const existing = Object.getOwnPropertyDescriptor(eleventyConfig.directories, key);
|
|
77
|
+
if (existing && existing.configurable === false) {
|
|
78
|
+
log.info(`directories[${key}] already defined; skipping`);
|
|
79
|
+
} else {
|
|
80
|
+
Object.defineProperty(eleventyConfig.directories, key, {
|
|
81
|
+
get() {
|
|
82
|
+
return cache.input;
|
|
83
|
+
},
|
|
84
|
+
enumerable: true,
|
|
85
|
+
configurable: false
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// One listener services all virtual dirs registered on this config.
|
|
90
|
+
// addScopeListener dedupes on ('eleventy.directories', 'sync'), so
|
|
91
|
+
// subsequent registerVirtualDir calls don't stack handlers.
|
|
92
|
+
addScopeListener(eleventyConfig, SCOPE_NAME, 'eleventy.directories', 'sync', (scope, dirs) => {
|
|
93
|
+
for (const entry of scope.values.values()) {
|
|
94
|
+
syncCache(entry.cache, dirs, entry.rawDir, entry.rawOutputDir);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
log.info('Virtual directories mounted');
|
|
99
|
+
|
|
100
|
+
return cache;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function syncCache(cache, dirs, rawDir, rawOutputDir) {
|
|
104
|
+
const inputDir = TemplatePath.addLeadingDotSlash(dirs.input || './');
|
|
105
|
+
const outputDir = TemplatePath.addLeadingDotSlash(dirs.output || './');
|
|
106
|
+
|
|
107
|
+
// resolveSubdir symmetrically resolves against input and output; call twice
|
|
108
|
+
// so input and output subdirs can differ (e.g. `public` copies to root).
|
|
109
|
+
cache.input = resolveSubdir(inputDir, outputDir, rawDir).input;
|
|
110
|
+
cache.output = resolveSubdir(inputDir, outputDir, rawOutputDir).output;
|
|
111
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { slugify } from './utils/helpers.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WIKILINKS (filter)
|
|
5
|
+
*
|
|
6
|
+
* MediaWiki-style inline link syntax for body markdown. Recognises
|
|
7
|
+
* [[slug]], [[slug#anchor]], [[slug:lang]], [[slug|alias]], and any
|
|
8
|
+
* combination. Resolves slugs against the slug index and, when a lang
|
|
9
|
+
* suffix is given, hops to the requested translation. Misses render as
|
|
10
|
+
* the original literal text.
|
|
11
|
+
*
|
|
12
|
+
* Architecture layer:
|
|
13
|
+
* runtime substrate
|
|
14
|
+
*
|
|
15
|
+
* System role:
|
|
16
|
+
* Markdown-it plugin registered by the composition root via
|
|
17
|
+
* amendLibrary('md', ...). Reads the slug index, page-context registry,
|
|
18
|
+
* and translation-map store; writes nothing back.
|
|
19
|
+
*
|
|
20
|
+
* Lifecycle:
|
|
21
|
+
* transform-time → parses [[...]] in body markdown and emits link or
|
|
22
|
+
* text tokens
|
|
23
|
+
*
|
|
24
|
+
* Why this exists:
|
|
25
|
+
* Markdown-it has no wiki-link syntax. Eleventy resolves all
|
|
26
|
+
* eleventyComputed values before any body renders, so the slug index
|
|
27
|
+
* and translation map are complete by the time this rule fires —
|
|
28
|
+
* resolution is deterministic without a two-pass build.
|
|
29
|
+
*
|
|
30
|
+
* Scope:
|
|
31
|
+
* Owns syntax parsing, slug-to-href resolution, the lang hop, and link
|
|
32
|
+
* rendering with class/lang/hreflang attributes.
|
|
33
|
+
* Does not own slug derivation (page-context), index population, or the
|
|
34
|
+
* translation-map shape (multilang module).
|
|
35
|
+
*
|
|
36
|
+
* Data flow:
|
|
37
|
+
* markdown-it inline state → slug index + page context + translation map → link tokens
|
|
38
|
+
*
|
|
39
|
+
* @param {import('markdown-it').default} md
|
|
40
|
+
* @param {Object} deps
|
|
41
|
+
* @param {{getBySlug: (slug: string) => string | null}} deps.slugIndex
|
|
42
|
+
* @param {{getByKey: (url: string) => any}} deps.pageContextRegistry
|
|
43
|
+
* @param {{get: () => Record<string, Record<string, {url: string, title?: string}>> | null}} [deps.translationMapStore]
|
|
44
|
+
*/
|
|
45
|
+
export function wikilinks(md, { slugIndex, pageContextRegistry, translationMapStore } = {}) {
|
|
46
|
+
if (!slugIndex || !pageContextRegistry) {
|
|
47
|
+
throw new Error('wikilinks plugin requires { slugIndex, pageContextRegistry }');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parse(inner) {
|
|
51
|
+
// [[target|alias]]
|
|
52
|
+
const pipeIdx = inner.indexOf('|');
|
|
53
|
+
const target = pipeIdx === -1 ? inner : inner.slice(0, pipeIdx);
|
|
54
|
+
const alias = pipeIdx === -1 ? null : inner.slice(pipeIdx + 1).trim() || null;
|
|
55
|
+
|
|
56
|
+
// [[slug-or-langed#anchor]]
|
|
57
|
+
const hashIdx = target.indexOf('#');
|
|
58
|
+
const slugAndLang = hashIdx === -1 ? target : target.slice(0, hashIdx);
|
|
59
|
+
const rawAnchor = hashIdx === -1 ? null : target.slice(hashIdx + 1).trim() || null;
|
|
60
|
+
const anchor = rawAnchor ? slugify(rawAnchor) : null;
|
|
61
|
+
|
|
62
|
+
// [[slug:lang]]
|
|
63
|
+
const colonIdx = slugAndLang.indexOf(':');
|
|
64
|
+
const rawSlug = colonIdx === -1 ? slugAndLang : slugAndLang.slice(0, colonIdx);
|
|
65
|
+
const rawLang = colonIdx === -1 ? null : slugAndLang.slice(colonIdx + 1).trim() || null;
|
|
66
|
+
|
|
67
|
+
return { rawSlug: rawSlug.trim(), rawLang, anchor, alias };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resolve({ rawSlug, rawLang, anchor, alias }) {
|
|
71
|
+
const slug = slugify(rawSlug);
|
|
72
|
+
if (!slug) return null;
|
|
73
|
+
|
|
74
|
+
const url = slugIndex.getBySlug(slug);
|
|
75
|
+
if (!url) return null;
|
|
76
|
+
|
|
77
|
+
const ctx = pageContextRegistry.getByKey(url);
|
|
78
|
+
const defaultTitle = ctx?.entry?.title ?? rawSlug;
|
|
79
|
+
|
|
80
|
+
const withAnchor = (u) => (anchor ? `${u}#${anchor}` : u);
|
|
81
|
+
|
|
82
|
+
if (!rawLang) {
|
|
83
|
+
return {
|
|
84
|
+
href: withAnchor(url),
|
|
85
|
+
label: alias ?? defaultTitle,
|
|
86
|
+
lang: null
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const lang = rawLang.toLowerCase();
|
|
91
|
+
const translationKey = ctx?.page?.locale?.translationKey;
|
|
92
|
+
if (!translationKey) return null;
|
|
93
|
+
|
|
94
|
+
const map = translationMapStore?.get?.();
|
|
95
|
+
const entry = map?.[translationKey]?.[lang];
|
|
96
|
+
if (!entry) return null;
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
href: withAnchor(entry.url),
|
|
100
|
+
label: alias ?? entry.title ?? defaultTitle,
|
|
101
|
+
lang
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function tokenize(state, silent) {
|
|
106
|
+
const start = state.pos;
|
|
107
|
+
const src = state.src;
|
|
108
|
+
|
|
109
|
+
if (src.charCodeAt(start) !== 0x5b /* [ */) return false;
|
|
110
|
+
if (src.charCodeAt(start + 1) !== 0x5b) return false;
|
|
111
|
+
|
|
112
|
+
const end = src.indexOf(']]', start + 2);
|
|
113
|
+
if (end === -1) return false;
|
|
114
|
+
|
|
115
|
+
const inner = src.slice(start + 2, end);
|
|
116
|
+
// Reject nested brackets to keep the rule unambiguous.
|
|
117
|
+
if (inner.includes('[') || inner.includes(']')) return false;
|
|
118
|
+
|
|
119
|
+
if (silent) {
|
|
120
|
+
state.pos = end + 2;
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const literal = src.slice(start, end + 2);
|
|
125
|
+
const parsed = parse(inner);
|
|
126
|
+
const result = resolve(parsed);
|
|
127
|
+
|
|
128
|
+
if (!result) {
|
|
129
|
+
const t = state.push('text', '', 0);
|
|
130
|
+
t.content = literal;
|
|
131
|
+
} else {
|
|
132
|
+
const open = state.push('link_open', 'a', 1);
|
|
133
|
+
const attrs = [
|
|
134
|
+
['href', result.href],
|
|
135
|
+
['class', 'wikilink']
|
|
136
|
+
];
|
|
137
|
+
if (result.lang) {
|
|
138
|
+
attrs.push(['lang', result.lang]);
|
|
139
|
+
attrs.push(['hreflang', result.lang]);
|
|
140
|
+
}
|
|
141
|
+
open.attrs = attrs;
|
|
142
|
+
const text = state.push('text', '', 0);
|
|
143
|
+
text.content = result.label;
|
|
144
|
+
state.push('link_close', 'a', -1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
state.pos = end + 2;
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
md.inline.ruler.before('link', 'wikilink', tokenize);
|
|
152
|
+
}
|