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.
- package/COMPONENTS.md +862 -0
- package/FRONTMATTER.md +248 -0
- package/LICENSE +21 -0
- package/README.md +26 -0
- package/components/ScavoldArticle.vue +12 -0
- package/components/ScavoldAside.vue +12 -0
- package/components/ScavoldBreadcrumb.vue +36 -0
- package/components/ScavoldContainer.vue +16 -0
- package/components/ScavoldFooter.vue +12 -0
- package/components/ScavoldHeader.vue +12 -0
- package/components/ScavoldImage.vue +33 -0
- package/components/ScavoldLayout.vue +21 -0
- package/components/ScavoldLocaleMenu.vue +86 -0
- package/components/ScavoldLocaleRedirect.vue +47 -0
- package/components/ScavoldMain.vue +12 -0
- package/components/ScavoldMenu.vue +82 -0
- package/components/ScavoldMenuItems.vue +45 -0
- package/components/ScavoldNav.vue +12 -0
- package/components/ScavoldSection.vue +12 -0
- package/components/ScavoldSimpleRedirect.vue +35 -0
- package/components/ScavoldVideo.vue +74 -0
- package/composables/hierarchy.ts +391 -0
- package/composables/useContainer.js +59 -0
- package/composables/useI18n.js +37 -0
- package/composables/useRedirect.js +20 -0
- package/composables/useVideo.js +89 -0
- package/index.d.ts +43 -0
- package/l10n/de.json +6 -0
- package/l10n/en.json +6 -0
- package/lib/config.d.ts +17 -0
- package/lib/config.js +396 -0
- package/lib/containers.js +128 -0
- package/lib/index.d.ts +9 -0
- package/lib/index.js +60 -0
- package/lib/markdown.js +22 -0
- package/lib/media.js +231 -0
- package/lib/pages.js +494 -0
- package/lib/parser.js +83 -0
- package/lib/redirectTarget.js +46 -0
- package/lib/sectionManifest.js +200 -0
- package/package.json +86 -0
- 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
package/l10n/en.json
ADDED
package/lib/config.d.ts
ADDED
|
@@ -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
|
+
}
|