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
package/lib/index.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { App } from "vue";
2
+ import type { Router } from "vitepress";
3
+
4
+ /**
5
+ * Theme entry point: registers Scavold's i18n catalogue, makes every Scavold
6
+ * component globally available, and installs the runtime redirect handling.
7
+ * Call it from the site theme's own `enhanceApp()`.
8
+ */
9
+ export function enhanceApp( context: { app: App; router: Router } ): Promise<void>;
package/lib/index.js ADDED
@@ -0,0 +1,60 @@
1
+ import { registerScavoldI18n } from "../composables/useI18n.js";
2
+ // Populated at build time by the scavold:external-redirect-map Vite plugin.
3
+ // Maps site-relative paths ("/de/links") to their external redirect targets.
4
+ import externalRedirectMap from "virtual:scavold/external-redirect-map";
5
+
6
+ /**
7
+ *
8
+ */
9
+ export async function enhanceApp( { app, router } ) {
10
+ registerScavoldI18n();
11
+
12
+ const items = Object.entries( import.meta.glob( "../components/*.vue" ) );
13
+
14
+ await Promise.all( items.map( ( [ path, importer ] ) => {
15
+ const name = path.replace( /^.*\/|\.vue$/g, "" );
16
+
17
+ return importer().then( module => app.component( name, module.default ) );
18
+ } ) );
19
+
20
+ // Intercept SPA navigation to external redirect pages using VitePress's own
21
+ // router hook. VitePress's click handler runs in window capture phase and
22
+ // calls go() → onBeforeRouteChange synchronously before doing anything else.
23
+ // We are therefore still inside the user-gesture context, so window.open()
24
+ // has full browser permission. Returning false cancels the VitePress
25
+ // navigation so the current page stays in place.
26
+ //
27
+ // A capture-phase DOM listener on document is NOT sufficient here because
28
+ // VitePress's own listener is on window with { capture: true }, which fires
29
+ // earlier in the propagation chain — stopPropagation at document level is
30
+ // already too late.
31
+ if ( typeof window !== "undefined" && router ) {
32
+ // The very first call to onBeforeRouteChange is the initial router.go()
33
+ // from createApp() — not a user navigation. Direct-page-load redirects
34
+ // are already handled by <meta http-equiv="refresh"> in the head, so we
35
+ // must let this call pass through unconditionally.
36
+ let initialNavDone = false;
37
+ const prevHook = router.onBeforeRouteChange;
38
+ router.onBeforeRouteChange = async ( href ) => {
39
+ if ( !initialNavDone ) {
40
+ initialNavDone = true;
41
+ return;
42
+ }
43
+ if ( prevHook && await prevHook( href ) === false ) return false;
44
+ let url;
45
+ try { url = new URL( href, location.href ); }
46
+ catch { return; }
47
+ // Only intercept same-origin navigation; let the browser handle
48
+ // any already-external hrefs natively.
49
+ if ( url.origin !== location.origin ) return;
50
+ // Strip trailing slash and .html suffix — VitePress's normalizeHref
51
+ // appends .html when cleanUrls is disabled, but the redirect map
52
+ // always stores bare paths (e.g. "/de/links", not "/de/links.html").
53
+ const path = url.pathname.replace( /\.html$/, "" ).replace( /\/$/, "" ) || "/";
54
+ const target = externalRedirectMap[path] ?? externalRedirectMap[path + "/"];
55
+ if ( !target ) return;
56
+ window.open( target, "_blank", "noopener,noreferrer" );
57
+ return false; // cancel VitePress SPA navigation
58
+ };
59
+ }
60
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Replaces named renderer rule of provided markdown-it instance with the given
3
+ * callback invoking the replaced rule in case the given one returns undefined.
4
+ *
5
+ * @param {import('markdown-it')} markdown - instance of markdown-it
6
+ * @param {string} ruleName - name of the rule to patch
7
+ * @param {Function} patchFn - callback replacing named rule, may return nothing/undefined to use result of replaced rule
8
+ * @returns {void}
9
+ */
10
+ export function patchRenderer( markdown, ruleName, patchFn ) {
11
+ const defaultHandler = markdown.renderer.rules[ruleName];
12
+
13
+ markdown.renderer.rules[ruleName] = ( tokens, idx, options, env, slf ) => {
14
+ const patch = patchFn( defaultHandler, tokens, idx, options, env, slf );
15
+
16
+ if ( patch === undefined ) {
17
+ return defaultHandler( tokens, idx, options, env, slf );
18
+ }
19
+
20
+ return patch;
21
+ };
22
+ }
package/lib/media.js ADDED
@@ -0,0 +1,231 @@
1
+ import { createHash } from "node:crypto";
2
+ import { join, basename, extname } from "node:path";
3
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
4
+ import { readFile } from "node:fs/promises";
5
+ import sharp from "sharp";
6
+
7
+ const DEFAULT_WIDTHS = [ 320, 640, 960, 1280, 1920 ];
8
+
9
+ /**
10
+ * Returns the public output folder for generated image variants.
11
+ *
12
+ * @param {string} root project root (where .vitepress lives)
13
+ * @param {string} [staticDir] absolute path to the static/public folder (vite.publicDir)
14
+ * @returns {string}
15
+ */
16
+ function variantsPublicDir( root, staticDir ) {
17
+ return join( staticDir ?? join( root, ".vitepress", "public" ), "media" );
18
+ }
19
+
20
+ /**
21
+ * Derives a stable 8-character hex hash from the source file content.
22
+ *
23
+ * @param {string} filePath absolute path to source file
24
+ * @returns {string}
25
+ */
26
+ function contentHash( filePath ) {
27
+ return createHash( "sha1" ).update( readFileSync( filePath ) ).digest( "hex" ).slice( 0, 8 );
28
+ }
29
+
30
+ /**
31
+ * Converts a filename to kebab-case.
32
+ *
33
+ * @param {string} name filename without extension
34
+ * @returns {string}
35
+ */
36
+ export function toKebabCase( name ) {
37
+ return name
38
+ .replace( /([a-z])([A-Z])/g, "$1-$2" )
39
+ .replace( /[\s_]+/g, "-" )
40
+ .toLowerCase();
41
+ }
42
+
43
+ /**
44
+ * Generates scaled image variants using sharp and returns srcset data.
45
+ * Skips generating a variant if the output file already exists (content-hash
46
+ * in the name means existence implies correctness).
47
+ *
48
+ * @param {string} srcPath absolute path to source image
49
+ * @param {string} outDir absolute path to public output folder
50
+ * @param {number[]} widths list of target widths
51
+ * @returns {Promise<{srcset: string, webpSrcset: string, fallbackSrc: string}>}
52
+ */
53
+ async function generateVariants( srcPath, outDir, widths ) {
54
+ mkdirSync( outDir, { recursive: true } );
55
+
56
+ const hash = contentHash( srcPath );
57
+ const ext = extname( srcPath ).toLowerCase().replace( ".", "" ) || "jpg";
58
+ const base = toKebabCase( basename( srcPath, extname( srcPath ) ) );
59
+ const image = sharp( srcPath );
60
+ const meta = await image.metadata();
61
+ const sourceWidth = meta.width ?? Infinity;
62
+
63
+ const targets = widths.filter( w => w <= sourceWidth );
64
+
65
+ if ( targets.length === 0 || targets[targets.length - 1] < sourceWidth ) {
66
+ targets.push( Math.min( sourceWidth, widths[widths.length - 1] ) );
67
+ }
68
+
69
+ const srcsetParts = [];
70
+ const webpSrcsetParts = [];
71
+
72
+ await Promise.all( targets.map( async width => {
73
+ const outName = `${base}--${width}w-${hash}`;
74
+ const outOrig = join( outDir, `${outName}.${ext}` );
75
+ const outWebp = join( outDir, `${outName}.webp` );
76
+
77
+ const resized = image.clone().resize( { width, withoutEnlargement: true } );
78
+
79
+ await Promise.all( [
80
+ existsSync( outOrig ) ? Promise.resolve() : resized.clone().toFile( outOrig ),
81
+ existsSync( outWebp ) ? Promise.resolve() : resized.clone().webp().toFile( outWebp ),
82
+ ] );
83
+
84
+ srcsetParts.push( { width, url: `/media/${outName}.${ext}` } );
85
+ webpSrcsetParts.push( { width, url: `/media/${outName}.webp` } );
86
+ } ) );
87
+
88
+ srcsetParts.sort( ( a, b ) => a.width - b.width );
89
+ webpSrcsetParts.sort( ( a, b ) => a.width - b.width );
90
+
91
+ return {
92
+ srcset: srcsetParts.map( ( { width, url } ) => `${url} ${width}w` ).join( ", " ),
93
+ webpSrcset: webpSrcsetParts.map( ( { width, url } ) => `${url} ${width}w` ).join( ", " ),
94
+ fallbackSrc: srcsetParts[srcsetParts.length - 1].url,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Extracts local image paths from a markdown source string.
100
+ *
101
+ * @param {string} source markdown source
102
+ * @returns {string[]} list of image src values found
103
+ */
104
+ export function extractImageSrcs( source ) {
105
+ const srcs = [];
106
+
107
+ for ( const match of source.matchAll( /!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g ) ) {
108
+ srcs.push( match[1] );
109
+ }
110
+
111
+ return srcs;
112
+ }
113
+
114
+ /**
115
+ * Sets up media handling for a Scavold-based VitePress site.
116
+ *
117
+ * Call `warmUp(pages)` in a buildStart hook before pages are rendered so that
118
+ * all variant data is in the sync cache by the time the markdown renderer runs.
119
+ * `collectImage()` is then synchronous and returns cached data immediately.
120
+ *
121
+ * @param {object} config raw VitePress user config (with optional mediaDir)
122
+ * @param {object} [options]
123
+ * @param {number[]} [options.widths] target widths for image variants
124
+ * @param {string} [options.sizes] default sizes attribute value
125
+ * @returns {{ warmUp: function, collectImage: function }}
126
+ */
127
+ export function useMedia( config, options = {} ) {
128
+ const widths = options.widths ?? DEFAULT_WIDTHS;
129
+ const defaultSizes = options.sizes ?? "100vw";
130
+ const mediaDir = config.mediaDir ?? config.srcDir ?? "media";
131
+ const root = config.root ?? process.cwd?.() ?? ".";
132
+ const outDir = variantsPublicDir( root, config.vite?.publicDir );
133
+
134
+ /** @type {Map<string, {src: string, srcset: string, webpSrcset: string, sizes: string}>} */
135
+ const cache = new Map();
136
+
137
+ /**
138
+ * Resolves a markdown image src to an absolute filesystem path, or null for
139
+ * remote URLs.
140
+ *
141
+ * @param {string} imageUrl
142
+ * @returns {string|null}
143
+ */
144
+ function resolveImagePath( imageUrl ) {
145
+ if ( /^[a-z]+:\/\//i.test( imageUrl ) ) {
146
+ return null;
147
+ }
148
+
149
+ return imageUrl.startsWith( "/" )
150
+ ? join( root, mediaDir, imageUrl.slice( 1 ) )
151
+ : join( root, mediaDir, imageUrl );
152
+ }
153
+
154
+ /**
155
+ * Processes a single image path into the cache.
156
+ *
157
+ * @param {string} imageUrl
158
+ * @returns {Promise<void>}
159
+ */
160
+ async function process( imageUrl ) {
161
+ if ( cache.has( imageUrl ) ) {
162
+ return;
163
+ }
164
+
165
+ const filePath = resolveImagePath( imageUrl );
166
+
167
+ if ( !filePath ) {
168
+ return;
169
+ }
170
+
171
+ if ( !existsSync( filePath ) ) {
172
+ console.error( "image %s is missing (resolved to %s)", imageUrl, filePath );
173
+ return;
174
+ }
175
+
176
+ const variants = await generateVariants( filePath, outDir, widths );
177
+
178
+ cache.set( imageUrl, {
179
+ src: variants.fallbackSrc,
180
+ srcset: variants.srcset,
181
+ webpSrcset: variants.webpSrcset,
182
+ sizes: defaultSizes,
183
+ } );
184
+ }
185
+
186
+ return {
187
+ /**
188
+ * Pre-processes all local images found in the given page files so the
189
+ * sync cache is populated before any markdown rendering begins.
190
+ * Call this from the VitePress buildStart hook.
191
+ *
192
+ * @param {string[]} pageFiles absolute paths to markdown source files
193
+ * @returns {Promise<void>}
194
+ */
195
+ async warmUp( pageFiles ) {
196
+ const srcs = new Set();
197
+
198
+ await Promise.all( pageFiles.map( async file => {
199
+ const source = await readFile( file, "utf-8" );
200
+
201
+ for ( const src of extractImageSrcs( source ) ) {
202
+ srcs.add( src );
203
+ }
204
+ } ) );
205
+
206
+ await Promise.all( [ ...srcs ].map( src => process( src ) ) );
207
+ },
208
+
209
+ /**
210
+ * Returns cached variant data for a local image src, or null for remote
211
+ * or unrecognised sources. Must be called after warmUp() has completed.
212
+ *
213
+ * @param {string} imageUrl value of the src attribute from markdown
214
+ * @param {string} [sizesOverride] per-image sizes attribute override
215
+ * @returns {{src: string, srcset: string, webpSrcset: string, sizes: string}|null}
216
+ */
217
+ collectImage( imageUrl, sizesOverride ) {
218
+ const data = cache.get( imageUrl );
219
+
220
+ if ( !data ) {
221
+ return null;
222
+ }
223
+
224
+ if ( sizesOverride ) {
225
+ return { ...data, sizes: sizesOverride };
226
+ }
227
+
228
+ return data;
229
+ },
230
+ };
231
+ }