scavold 0.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/COMPONENTS.md +862 -0
  2. package/FRONTMATTER.md +248 -0
  3. package/LICENSE +21 -0
  4. package/README.md +26 -0
  5. package/components/ScavoldArticle.vue +12 -0
  6. package/components/ScavoldAside.vue +12 -0
  7. package/components/ScavoldBreadcrumb.vue +36 -0
  8. package/components/ScavoldContainer.vue +16 -0
  9. package/components/ScavoldFooter.vue +12 -0
  10. package/components/ScavoldHeader.vue +12 -0
  11. package/components/ScavoldImage.vue +33 -0
  12. package/components/ScavoldLayout.vue +21 -0
  13. package/components/ScavoldLocaleMenu.vue +86 -0
  14. package/components/ScavoldLocaleRedirect.vue +47 -0
  15. package/components/ScavoldMain.vue +12 -0
  16. package/components/ScavoldMenu.vue +82 -0
  17. package/components/ScavoldMenuItems.vue +45 -0
  18. package/components/ScavoldNav.vue +12 -0
  19. package/components/ScavoldSection.vue +12 -0
  20. package/components/ScavoldSimpleRedirect.vue +35 -0
  21. package/components/ScavoldVideo.vue +74 -0
  22. package/composables/hierarchy.ts +391 -0
  23. package/composables/useContainer.js +59 -0
  24. package/composables/useI18n.js +37 -0
  25. package/composables/useRedirect.js +20 -0
  26. package/composables/useVideo.js +89 -0
  27. package/index.d.ts +43 -0
  28. package/l10n/de.json +6 -0
  29. package/l10n/en.json +6 -0
  30. package/lib/config.d.ts +17 -0
  31. package/lib/config.js +396 -0
  32. package/lib/containers.js +128 -0
  33. package/lib/index.d.ts +9 -0
  34. package/lib/index.js +60 -0
  35. package/lib/markdown.js +22 -0
  36. package/lib/media.js +231 -0
  37. package/lib/pages.js +494 -0
  38. package/lib/parser.js +83 -0
  39. package/lib/redirectTarget.js +46 -0
  40. package/lib/sectionManifest.js +200 -0
  41. package/package.json +86 -0
  42. package/scripts/check-csp.js +68 -0
@@ -0,0 +1,89 @@
1
+ import { computed } from "vue";
2
+ import { containerProps } from "./useContainer.js";
3
+
4
+ /**
5
+ * Props definition for video container components. Spread into `defineProps`
6
+ * alongside any component-specific props.
7
+ */
8
+ export const videoProps = {
9
+ ...containerProps,
10
+
11
+ /** URL of the video file. */
12
+ dataSrc: { type: String, default: "" },
13
+
14
+ /** URL of the poster image shown before playback. */
15
+ dataPoster: { type: String, default: "" },
16
+
17
+ /** Play automatically when the page loads. Implies muted. */
18
+ dataAutoplay: { type: String, default: null },
19
+
20
+ /** Loop the video when it ends. */
21
+ dataLoop: { type: String, default: null },
22
+
23
+ /** Mute the audio track. */
24
+ dataMuted: { type: String, default: null },
25
+
26
+ /** Render as a background: the video fills the block and the container's
27
+ * body content is layered on top of it. */
28
+ dataOverlay: { type: String, default: null },
29
+
30
+ /** Show the native playback controls. Always present in the default player;
31
+ * opt-in in overlay mode, where a background video is normally chrome-free. */
32
+ dataControls: { type: String, default: null },
33
+
34
+ /** Hint for preloading: "none", "metadata" (default), or "auto". */
35
+ dataPreload: { type: String, default: null },
36
+
37
+ /** Accessible label for the video element, announced by screen readers.
38
+ * Use when the surrounding context does not already describe the video. */
39
+ dataLabel: { type: String, default: null },
40
+ };
41
+
42
+ /**
43
+ * Composable for video container components.
44
+ *
45
+ * Parses the props emitted by the markdown-it container renderer for a
46
+ * `:::video` block and exposes derived values ready for binding:
47
+ *
48
+ * - `src` — video URL
49
+ * - `poster` — poster image URL, or empty string
50
+ * - `autoplay` — boolean, always implies muted
51
+ * - `loop` — boolean
52
+ * - `muted` — boolean (forced true when autoplay is true)
53
+ * - `preload` — "none" | "metadata" | "auto"
54
+ * - `overlay` — boolean, render the video as a background behind the slot
55
+ * - `controls` — boolean, always true in the default player; opt-in in overlay mode
56
+ *
57
+ * Custom video components should call this composable so they share identical
58
+ * attribute resolution without duplicating the logic.
59
+ *
60
+ * @param {object} props reactive props from defineProps
61
+ * @returns {{ src, poster, autoplay, loop, muted, preload, overlay, controls, ariaLabel }}
62
+ */
63
+ export function useVideo( props ) {
64
+ const src = computed( () => props.dataSrc ?? "" );
65
+ const poster = computed( () => props.dataPoster ?? "" );
66
+
67
+ const autoplay = computed( () => props.dataAutoplay != null );
68
+ const loop = computed( () => props.dataLoop != null );
69
+
70
+ // autoplay requires muted to be allowed by browsers without user gesture
71
+ const muted = computed( () => autoplay.value || props.dataMuted != null );
72
+
73
+ const PRELOAD_VALUES = new Set( [ "none", "metadata", "auto" ] );
74
+ const preload = computed( () =>
75
+ PRELOAD_VALUES.has( props.dataPreload ) ? props.dataPreload : "metadata"
76
+ );
77
+
78
+ const overlay = computed( () => props.dataOverlay != null );
79
+
80
+ // The default player always shows controls; a background video is chrome-free
81
+ // unless the author opts back in with the `controls` flag.
82
+ const controls = computed( () =>
83
+ overlay.value ? props.dataControls != null : true
84
+ );
85
+
86
+ const ariaLabel = computed( () => props.dataLabel ?? undefined );
87
+
88
+ return { src, poster, autoplay, loop, muted, preload, overlay, controls, ariaLabel };
89
+ }
package/index.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { PageData } from "vitepress";
2
+
3
+ export module Scavold {
4
+ /** VitePress user config, as accepted by {@link augmentConfig}. */
5
+ type UserConfig = import( "vitepress" ).UserConfig;
6
+
7
+ interface HierarchyNode {
8
+ parent?: HierarchyNode;
9
+ isPage: boolean;
10
+ path: string;
11
+ subs?: { [path: string]: HierarchyNode };
12
+ locale?: string;
13
+ /** Display title for the page — from frontmatter `title` or the first heading. */
14
+ title?: string;
15
+ /** Navigation label for the page — from frontmatter `label`, falls back to `title`. */
16
+ label?: string;
17
+ /** Normalised alias output path (e.g. `de/impressum.md`) when `url` is declared in frontmatter. Used as the page's href instead of its source path. */
18
+ url?: string;
19
+ frontmatter?: { [key: string]: any };
20
+ }
21
+
22
+ interface AugmentedPageData extends PageData {
23
+ hierarchy: HierarchyNode;
24
+ }
25
+
26
+ interface MenuItem {
27
+ node: HierarchyNode;
28
+ active: boolean;
29
+ current: boolean;
30
+ children: MenuItem[];
31
+ }
32
+
33
+ type LocaleDetection = "auto" | "explicit" | "inherited" | "global";
34
+
35
+ interface LocaleLink {
36
+ /** BCP 47 locale code. */
37
+ locale: string;
38
+ /** Resolved href for this locale. */
39
+ href: string;
40
+ /** True when this locale matches the current page's locale. */
41
+ current: boolean;
42
+ }
43
+ }
package/l10n/de.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "nav": {
3
+ "breadcrumb": "Navigationspfad",
4
+ "locale": "Sprache"
5
+ }
6
+ }
package/l10n/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "nav": {
3
+ "breadcrumb": "Breadcrumb",
4
+ "locale": "Language"
5
+ }
6
+ }
@@ -0,0 +1,17 @@
1
+ import type { UserConfig } from "vitepress";
2
+
3
+ export interface AugmentOptions {
4
+ /**
5
+ * Explicit container-name → component-name overrides, merged on top of
6
+ * Scavold's defaults and any names declared in `.cratly.config.yaml`.
7
+ */
8
+ containers?: Record<string, string>;
9
+ }
10
+
11
+ /**
12
+ * Augments a VitePress user config with all Scavold features: derives `srcDir`
13
+ * and `vite.publicDir` from `.cratly.config.yaml`, registers the image
14
+ * processing plugin and the Markdown container extensions, and emits the
15
+ * section-type manifest read by the cratly editor.
16
+ */
17
+ export function augmentConfig( rawConfig: UserConfig, options?: AugmentOptions ): Promise<UserConfig>;
package/lib/config.js ADDED
@@ -0,0 +1,396 @@
1
+ import { join, relative, resolve } from "node:path";
2
+ import { dirname, join as joinPosix } from "node:path/posix";
3
+ import { readFile } from "node:fs/promises";
4
+ import { readdirSync, readFileSync } from "node:fs";
5
+ import { clearCache, compileHierarchy, compileRedirects, sourceFolder } from "./pages.js";
6
+ import { useMedia } from "./media.js";
7
+ import { patchRenderer } from "./markdown.js";
8
+ import { registerContainers, resolveContainerMap } from "./containers.js";
9
+ import { buildSectionManifest, writeSectionManifest } from "./sectionManifest.js";
10
+ import { isExternalUrl, servableRedirectTarget } from "./redirectTarget.js";
11
+
12
+ // Scavold's own version, reported under `adapter.version` in the emitted
13
+ // section-type manifest.
14
+ const SCAVOLD_VERSION = JSON.parse(
15
+ readFileSync( new URL( "../package.json", import.meta.url ), "utf-8" ),
16
+ ).version;
17
+
18
+ // ─── External-redirect map virtual module ────────────────────────────────────
19
+ // During Vite's browser bundle step the plugin exposes a JSON map of
20
+ // { "/site/relative/path": "https://external-url" }
21
+ // for every page whose frontmatter has a plain-string external redirect.
22
+ // lib/index.js imports this map statically so the capture-phase click listener
23
+ // can call window.open() synchronously within the user's click event, giving
24
+ // the browser full user-gesture context and avoiding popup blockers entirely.
25
+
26
+ const REDIRECT_MAP_VIRTUAL_ID = "virtual:scavold/external-redirect-map";
27
+ const REDIRECT_MAP_RESOLVED_ID = "\0" + REDIRECT_MAP_VIRTUAL_ID;
28
+
29
+ /**
30
+ * Returns the scalar string value after "redirect:" on the same YAML line,
31
+ * or null if the frontmatter has no scalar redirect (e.g. block-style locale
32
+ * redirect maps).
33
+ *
34
+ * @param {string} content Raw markdown file content
35
+ * @returns {string|null}
36
+ */
37
+ function extractFrontmatterRedirect( content ) {
38
+ const fmMatch = content.match( /^---\r?\n([\s\S]*?)\r?\n---/ );
39
+ if ( !fmMatch ) return null;
40
+ const rdMatch = fmMatch[1].match( /^redirect:\s*(.+)$/m );
41
+ if ( !rdMatch ) return null;
42
+ let value = rdMatch[1].trim();
43
+ // Strip optional surrounding quotes
44
+ if ( ( value.startsWith( '"' ) && value.endsWith( '"' ) ) ||
45
+ ( value.startsWith( "'" ) && value.endsWith( "'" ) ) ) {
46
+ value = value.slice( 1, -1 );
47
+ }
48
+ return value || null;
49
+ }
50
+
51
+ /**
52
+ * Recursively walks srcDir and fills map with { urlPath → externalUrl } for
53
+ * every .md file whose frontmatter redirect is an external URL.
54
+ *
55
+ * @param {string} dir Absolute path to scan
56
+ * @param {string} base Absolute srcDir root (for computing site-relative path)
57
+ * @param {Record<string,string>} map Result accumulator
58
+ */
59
+ function walkMdFilesForRedirects( dir, base, map ) {
60
+ for ( const entry of readdirSync( dir, { withFileTypes: true } ) ) {
61
+ const full = join( dir, entry.name );
62
+ if ( entry.isDirectory() ) {
63
+ if ( !entry.name.startsWith( "." ) && entry.name !== "node_modules" ) {
64
+ walkMdFilesForRedirects( full, base, map );
65
+ }
66
+ } else if ( entry.name.endsWith( ".md" ) ) {
67
+ let content;
68
+ try { content = readFileSync( full, "utf8" ); }
69
+ catch { continue; }
70
+ const redirect = extractFrontmatterRedirect( content );
71
+ if ( redirect && isExternalUrl( redirect ) ) {
72
+ const rel = relative( base, full ).replace( /\\/g, "/" );
73
+ const urlPath = "/" + rel.replace( /index\.md$/, "" ).replace( /\.md$/, "" );
74
+ map[urlPath] = redirect;
75
+ }
76
+ }
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Creates a Vite plugin that exposes virtual:scavold/external-redirect-map.
82
+ * The module is built by scanning pagesDir for .md files with external
83
+ * redirect frontmatter. Invalidated in dev mode on any .md file change so
84
+ * the next full-reload (triggered by mediaPlugin) picks up the new map.
85
+ *
86
+ * @param {string} pagesDir Absolute path to the VitePress srcDir
87
+ * @returns {import("vite").Plugin}
88
+ */
89
+ function createExternalRedirectMapPlugin( pagesDir ) {
90
+ return {
91
+ name: "scavold:external-redirect-map",
92
+ resolveId( id ) {
93
+ if ( id === REDIRECT_MAP_VIRTUAL_ID ) return REDIRECT_MAP_RESOLVED_ID;
94
+ },
95
+ load( id ) {
96
+ if ( id !== REDIRECT_MAP_RESOLVED_ID ) return;
97
+ const map = {};
98
+ try { walkMdFilesForRedirects( pagesDir, pagesDir, map ); }
99
+ catch { /* srcDir not accessible yet — return empty map */ }
100
+ return `export default ${JSON.stringify( map )}`;
101
+ },
102
+ configureServer( server ) {
103
+ // Invalidate on .md changes so the next full-reload (triggered by
104
+ // mediaPlugin.handleHotUpdate) picks up the updated map.
105
+ server.watcher.on( "change", path => {
106
+ if ( !path.endsWith( ".md" ) ) return;
107
+ const mod = server.moduleGraph.getModuleById( REDIRECT_MAP_RESOLVED_ID );
108
+ if ( mod ) server.moduleGraph.invalidateModule( mod );
109
+ } );
110
+ },
111
+ };
112
+ }
113
+
114
+ // ─────────────────────────────────────────────────────────────────────────────
115
+
116
+ /**
117
+ * Extracts an optional sizes override from a markdown image title string.
118
+ * Recognises the pattern: sizes=VALUE or sizes="VALUE"
119
+ *
120
+ * @param {string|null} title
121
+ * @returns {string|undefined}
122
+ */
123
+ function parseSizesFromTitle( title ) {
124
+ if ( !title ) {
125
+ return undefined;
126
+ }
127
+
128
+ const match = title.match( /(?:^|\s)sizes=(?:"([^"]+)"|(\S+))/ );
129
+
130
+ return match?.[1] ?? match?.[2] ?? undefined;
131
+ }
132
+
133
+ /**
134
+ * Augments provided VitePress configuration to integrate extended theme
135
+ * features.
136
+ *
137
+ * @param {object} rawConfig VitePress user config
138
+ * @param {object} [options]
139
+ * @param {Object<string,string>} [options.containers] explicit container-name →
140
+ * component-name overrides, merged on top of Scavold defaults and any names
141
+ * declared in .crate.config.yaml
142
+ */
143
+ export async function augmentConfig( rawConfig, options = {} ) {
144
+ // Read .crate.config.yaml for structural config and container declarations (Tier 2)
145
+ let crateContainers = {};
146
+ let crateSrcDir = null;
147
+ let crateStaticDir = null;
148
+ let crateImageSizes = null;
149
+ let crateImageWidths = null;
150
+
151
+ try {
152
+ const { readFile } = await import( "node:fs/promises" );
153
+ const YAML = ( await import( "yaml" ) ).default;
154
+ const raw = await readFile( join( process.cwd(), ".cratly.config.yaml" ), "utf-8" );
155
+ const crate = YAML.parse( raw ) ?? {};
156
+ crateContainers = crate.containers ?? {};
157
+ crateSrcDir = crate.pages_folder ?? null;
158
+ crateStaticDir = crate.static_folder ?? null;
159
+ crateImageSizes = crate.image_sizes ?? null;
160
+ crateImageWidths = crate.image_widths ?? null;
161
+ } catch {
162
+ // file absent or unparseable — proceed with defaults
163
+ }
164
+
165
+ const containerMap = resolveContainerMap( crateContainers, options.containers );
166
+
167
+ // Apply srcDir from crate config if not already set in rawConfig
168
+ const srcDir = rawConfig.srcDir ?? crateSrcDir ?? undefined;
169
+
170
+ // Prevent VitePress/Vite from defaulting publicDir to {srcDir}/public when
171
+ // content lives in a subfolder — static assets must not appear inside the
172
+ // pages folder. VitePress passes srcDir as Vite's root, so publicDir must be
173
+ // an absolute path to be unambiguous. Derived from static_folder in
174
+ // .crate.config.yaml; falls back to "public" at the project root only when
175
+ // srcDir is a subfolder and no explicit value is set by the caller.
176
+ const staticFolder = crateStaticDir ?? ( srcDir && srcDir !== "." ? "public" : null );
177
+ const vitePublicDir = rawConfig.vite?.publicDir
178
+ ?? ( staticFolder ? resolve( process.cwd(), staticFolder ) : undefined );
179
+
180
+ // Resolved config carries the derived srcDir so pages.js helpers see it correctly.
181
+ // The vite.plugins array is built separately below after media is initialised.
182
+ const resolvedConfig = {
183
+ ...rawConfig,
184
+ ...(srcDir !== undefined && { srcDir }),
185
+ vite: {
186
+ ...rawConfig.vite,
187
+ ...(vitePublicDir !== undefined && { publicDir: vitePublicDir }),
188
+ },
189
+ };
190
+
191
+ const media = useMedia( resolvedConfig, {
192
+ ...( crateImageSizes != null && { sizes: crateImageSizes } ),
193
+ ...( crateImageWidths != null && { widths: crateImageWidths } ),
194
+ } );
195
+
196
+ async function warmUpMedia() {
197
+ const pagesDir = sourceFolder( resolvedConfig );
198
+ const { glob } = await import( "node:fs/promises" );
199
+ const pageFiles = ( await Array.fromAsync( glob( "**/*.md", { cwd: pagesDir } ) ) )
200
+ .map( f => join( pagesDir, f ) );
201
+
202
+ await media.warmUp( pageFiles );
203
+ }
204
+
205
+ // VitePress's internal markdown LRU cache clear function — not public API
206
+ // but needed to force re-compilation of all pages on .md changes.
207
+ let clearVPCache;
208
+
209
+ /** @type {import("vite").Plugin} */
210
+ const mediaPlugin = {
211
+ name: "scavold:media",
212
+ async buildStart() {
213
+ await warmUpMedia();
214
+ },
215
+ async configureServer( server ) {
216
+ server.httpServer?.once( "listening", () => warmUpMedia() );
217
+
218
+ // VitePress does not expose its internal markdown LRU cache publicly.
219
+ // We locate the chunk by finding whichever dist/node chunk exports a
220
+ // function named `clearCache` (exported as an alias). If VitePress
221
+ // ever makes this public or changes its chunk layout this will degrade
222
+ // gracefully — pages will still reload, hierarchy just won't update
223
+ // until the server is restarted.
224
+ // TODO: remove once https://github.com/vuejs/vitepress/issues/XXXX
225
+ // is resolved and VitePress exposes clearCache publicly.
226
+ try {
227
+ const { readdir } = await import( "node:fs/promises" );
228
+ const { createRequire } = await import( "node:module" );
229
+ const require = createRequire( import.meta.url );
230
+ const vpNodeDir = new URL( "../../dist/node/", import.meta.resolve( "vitepress" ) );
231
+ const chunks = ( await readdir( new URL( ".", vpNodeDir ) ) )
232
+ .filter( f => f.startsWith( "chunk-" ) && f.endsWith( ".js" ) );
233
+
234
+ for ( const chunk of chunks ) {
235
+ const mod = await import( new URL( chunk, vpNodeDir ).href );
236
+ const fn = Object.values( mod ).find( v => typeof v === "function" && v.length <= 1 && v.toString().includes( "cache$1" ) );
237
+
238
+ if ( fn ) {
239
+ clearVPCache = fn;
240
+ break;
241
+ }
242
+ }
243
+
244
+ if ( !clearVPCache ) {
245
+ console.warn( "[scavold] could not locate VitePress internal markdown cache — hierarchy changes may require a server restart to take effect" );
246
+ }
247
+ } catch {
248
+ console.warn( "[scavold] could not locate VitePress internal markdown cache — hierarchy changes may require a server restart to take effect" );
249
+ }
250
+ },
251
+ handleHotUpdate( { file, server } ) {
252
+ if ( !file.endsWith( ".md" ) ) {
253
+ return;
254
+ }
255
+
256
+ warmUpMedia();
257
+ clearVPCache?.();
258
+
259
+ // Invalidate all .md modules and trigger a full reload so the
260
+ // browser re-requests the current page, causing transformPageData
261
+ // to run fresh for it with the updated hierarchy.
262
+ const mdModules = [];
263
+
264
+ for ( const mod of server.moduleGraph.idToModuleMap.values() ) {
265
+ if ( mod.file?.endsWith( ".md" ) ) {
266
+ server.moduleGraph.invalidateModule( mod );
267
+ mdModules.push( mod );
268
+ }
269
+ }
270
+
271
+ server.ws.send( { type: "full-reload" } );
272
+
273
+ return mdModules;
274
+ },
275
+ };
276
+
277
+ const pagesDir = sourceFolder( resolvedConfig );
278
+
279
+ // Emit the section-type manifest (.cratly/sections.json) so the cratly
280
+ // editor can render typed controls per section kind. Built from Scavold's
281
+ // built-in sections merged with the site's container declarations. Written
282
+ // at build start and once when the dev server starts listening.
283
+ async function emitSectionManifest() {
284
+ try {
285
+ const manifest = buildSectionManifest( crateContainers, { adapterVersion: SCAVOLD_VERSION } );
286
+ await writeSectionManifest( process.cwd(), manifest );
287
+ } catch ( error ) {
288
+ console.warn( "[scavold] could not write .cratly/sections.json —", error.message );
289
+ }
290
+ }
291
+
292
+ /** @type {import("vite").Plugin} */
293
+ const sectionManifestPlugin = {
294
+ name: "scavold:section-manifest",
295
+ async buildStart() {
296
+ await emitSectionManifest();
297
+ },
298
+ configureServer( server ) {
299
+ server.httpServer?.once( "listening", () => emitSectionManifest() );
300
+ },
301
+ };
302
+
303
+ return {
304
+ ...resolvedConfig,
305
+ vite: {
306
+ ...resolvedConfig.vite,
307
+ plugins: [
308
+ ...( rawConfig.vite?.plugins ?? [] ),
309
+ mediaPlugin,
310
+ sectionManifestPlugin,
311
+ createExternalRedirectMapPlugin( pagesDir ),
312
+ ],
313
+ },
314
+ rewrites: {
315
+ ...rawConfig?.rewrites,
316
+ ...await compileRedirects( resolvedConfig ),
317
+ },
318
+ buildEnd: rawConfig.buildEnd,
319
+ async transformPageData( pageData, context ) {
320
+ clearCache();
321
+
322
+ // Inject head tags for string-form redirects so the browser acts
323
+ // immediately on a direct page load without any script or user interaction.
324
+ //
325
+ // Internal targets → <script>location.replace(…)</script>
326
+ // Fires synchronously before rendering. VitePress/@unhead/vue
327
+ // re-executes <script> tags on every SPA navigation, but the
328
+ // capture-phase click interceptor in lib/index.js cancels the router
329
+ // navigation before this page is ever reached via SPA, so the
330
+ // re-execution never causes a double-redirect.
331
+ //
332
+ // External targets → <meta http-equiv="refresh" content="0;url=…">
333
+ // The browser processes <meta http-equiv="refresh"> only during initial
334
+ // HTML parsing, not when @unhead/vue re-inserts it on SPA navigation,
335
+ // so it is safe to include unconditionally. On direct page loads the
336
+ // tab redirects automatically. SPA navigation is intercepted by the
337
+ // click listener before the router fires, so this page never mounts
338
+ // during in-app use.
339
+ //
340
+ // The redirect value can be either:
341
+ // - a site-relative URL ("/de/jobs") — written by the Cratly editor
342
+ // - a relative .md path ("jobs.md") — written by hand in frontmatter
343
+ // - an absolute external URL — e.g. "https://example.com"
344
+ const redirect = pageData.frontmatter?.redirect;
345
+ if ( typeof redirect === "string" ) {
346
+ if ( isExternalUrl( redirect ) ) {
347
+ pageData.frontmatter.head = [
348
+ ...( pageData.frontmatter.head ?? [] ),
349
+ [ "meta", { "http-equiv": "refresh", content: `0;url=${redirect}` } ],
350
+ ];
351
+ } else {
352
+ let target;
353
+ if ( redirect.startsWith( "/" ) ) {
354
+ target = servableRedirectTarget( redirect );
355
+ } else {
356
+ const dir = dirname( pageData.relativePath );
357
+ const resolved = joinPosix( dir === "." ? "" : dir, redirect );
358
+ target = servableRedirectTarget( "/" + resolved.replace( /\/index\.md$/, "/" ).replace( /\.md$/, "" ) );
359
+ }
360
+ pageData.frontmatter.head = [
361
+ ...( pageData.frontmatter.head ?? [] ),
362
+ [ "script", {}, `location.replace(${JSON.stringify( target )})` ],
363
+ ];
364
+ }
365
+ }
366
+
367
+ return {
368
+ hierarchy: await compileHierarchy( resolvedConfig, context.siteConfig.pages ),
369
+ };
370
+ },
371
+ markdown: {
372
+ config( md ) {
373
+ // see https://github.com/markdown-it/markdown-it/blob/master/lib/renderer.mjs
374
+
375
+ patchRenderer( md, "image", ( defaultHandler, tokens, idx, options, ...args ) => {
376
+ const token = tokens[idx];
377
+ const sizesOverride = parseSizesFromTitle( token.attrGet( "title" ) );
378
+ const data = media.collectImage( token.attrGet( "src" ), sizesOverride );
379
+
380
+ if ( data ) {
381
+ token.tag = "ScavoldImage";
382
+ token.attrSet( "src", data.src );
383
+ token.attrSet( "srcset", data.srcset );
384
+ token.attrSet( "webp-srcset", data.webpSrcset );
385
+ token.attrSet( "sizes", data.sizes );
386
+ options = { ...options, xhtmlOut: true };
387
+ }
388
+
389
+ return defaultHandler( tokens, idx, options, ...args );
390
+ } );
391
+
392
+ registerContainers( md, containerMap );
393
+ },
394
+ },
395
+ };
396
+ }
@@ -0,0 +1,128 @@
1
+ import ContainerPlugin from "markdown-it-container";
2
+ import { parseKeyValuePairs } from "./parser.js";
3
+
4
+ /**
5
+ * HTML sectioning element names that Scavold maps to thin wrapper components
6
+ * by default. Each maps to `Scavold{Name}` unless overridden.
7
+ */
8
+ export const SECTIONING_ELEMENTS = new Set( [
9
+ "article",
10
+ "aside",
11
+ "footer",
12
+ "header",
13
+ "main",
14
+ "nav",
15
+ "section",
16
+ ] );
17
+
18
+ /**
19
+ * Builds the default container-name → component-name map from the set of
20
+ * built-in sectioning element names plus other built-in containers.
21
+ *
22
+ * @returns {Object<string,string>}
23
+ */
24
+ function defaultContainerMap() {
25
+ const map = {};
26
+
27
+ for ( const name of SECTIONING_ELEMENTS ) {
28
+ map[name] = `Scavold${name[0].toUpperCase()}${name.slice( 1 )}`;
29
+ }
30
+
31
+ map.video = "ScavoldVideo";
32
+
33
+ return map;
34
+ }
35
+
36
+ /**
37
+ * Resolves a container name to the Vue component tag to emit, using the
38
+ * provided map. Falls back to ScavoldContainer for unknown names.
39
+ *
40
+ * @param {string} name container name from markdown
41
+ * @param {Object<string,string>} map resolved name→component map
42
+ * @returns {string} component tag name
43
+ */
44
+ function resolveTag( name, map ) {
45
+ return map[name] ?? "ScavoldContainer";
46
+ }
47
+
48
+ /**
49
+ * Renders the opening or closing tag for a container token.
50
+ *
51
+ * @param {string} tag component tag to emit
52
+ * @param {import('markdown-it/lib/token')} token
53
+ * @param {string} containerName the original container name from markdown
54
+ * @returns {string}
55
+ */
56
+ function renderToken( tag, token, containerName ) {
57
+ if ( token.nesting !== 1 ) {
58
+ return `</${tag}>\n`;
59
+ }
60
+
61
+ const args = parseKeyValuePairs( token.info.trim().replace( /^\S+\s*/, "" ) );
62
+
63
+ const classes = Object.keys( args )
64
+ .filter( key => args[key] === true )
65
+ .join( " " );
66
+
67
+ const attrs = Object.entries( args )
68
+ .filter( ( [ , value ] ) => value && value !== true )
69
+ .map( ( [ key, value ] ) => `data-${key}="${value}"` );
70
+
71
+ attrs.push( `data-container="${containerName}"` );
72
+
73
+ if ( classes ) {
74
+ attrs.push( `class="${classes}"` );
75
+ }
76
+
77
+ return `<${tag} ${attrs.join( " " )}>\n`;
78
+ }
79
+
80
+ /**
81
+ * Registers markdown-it-container plugins for all container names in the
82
+ * provided map, plus ScavoldContainer as the catch-all fallback for any name
83
+ * not in the map.
84
+ *
85
+ * @param {import('markdown-it')} md markdown-it instance
86
+ * @param {Object<string,string>} containerMap resolved name→component map
87
+ */
88
+ export function registerContainers( md, containerMap ) {
89
+ for ( const [ name, tag ] of Object.entries( containerMap ) ) {
90
+ md.use( ContainerPlugin, name, {
91
+ render: ( tokens, index ) => renderToken( tag, tokens[index], name ),
92
+ } );
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Merges the Scavold default container map with site-level declarations from
98
+ * .crate.config.yaml and an explicit developer-supplied override map.
99
+ *
100
+ * Resolution order (last wins):
101
+ * 1. Scavold built-in sectioning element defaults
102
+ * 2. Names declared in crateConfig.containers (mapped to Scavold{Name})
103
+ * 3. Explicit overrides passed directly to augmentConfig
104
+ *
105
+ * @param {object} [crateContainers] containers block from .crate.config.yaml
106
+ * @param {Object<string,string>} [overrides] explicit name→component overrides
107
+ * @returns {Object<string,string>}
108
+ */
109
+ export function resolveContainerMap( crateContainers = {}, overrides = {} ) {
110
+ const map = defaultContainerMap();
111
+
112
+ for ( const [ name, value ] of Object.entries( crateContainers ) ) {
113
+ if ( typeof value === "string" ) {
114
+ // shorthand: "name: ComponentName" — treat string as explicit component override
115
+ map[name] = value;
116
+ } else if ( value?.component ) {
117
+ // object form with explicit component key
118
+ map[name] = value.component;
119
+ } else if ( !map[name] ) {
120
+ // object form without component key — register with default naming convention
121
+ map[name] = `Scavold${name[0].toUpperCase()}${name.slice( 1 )}`;
122
+ }
123
+ }
124
+
125
+ Object.assign( map, overrides );
126
+
127
+ return map;
128
+ }