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
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
|
+
}
|
package/lib/markdown.js
ADDED
|
@@ -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
|
+
}
|