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/pages.js
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
import { relative, resolve } from "node:path/posix";
|
|
3
|
+
import { glob, readFile } from "node:fs/promises";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
|
|
6
|
+
const ptnIsIndexPage = /(^|\/)index\.md$/;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Delivers absolute path name of folder containing Markdown documents per page.
|
|
10
|
+
*
|
|
11
|
+
* @param {UserConfig} config Vitepress site configuration
|
|
12
|
+
* @returns {string} absolute path name of folder containing all Markdown files each representing a page
|
|
13
|
+
*/
|
|
14
|
+
export function sourceFolder( config ) {
|
|
15
|
+
return resolve( config.srcDir ?? "." );
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Qualifies a path name of some target page optionally relative to some given
|
|
20
|
+
* reference page into a path name relative to the same base as provided
|
|
21
|
+
* reference.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} from path name of reference page
|
|
24
|
+
* @param {string} to path name of target page, might be absolute URL
|
|
25
|
+
* @param {boolean} localOnly if true, absolute URLs as target are rejected
|
|
26
|
+
* @returns {string} path name of target page relative to same base as reference page
|
|
27
|
+
* @throws TypeError if target page URL is an absolute URL and localOnly has been set
|
|
28
|
+
*/
|
|
29
|
+
export function qualifyRedirect( from, to, localOnly = false ) {
|
|
30
|
+
const cwd = resolve( "" );
|
|
31
|
+
const target = new URL( to, pathToFileURL( resolve( cwd, from ) ) );
|
|
32
|
+
|
|
33
|
+
if ( target.protocol === "file:" ) {
|
|
34
|
+
return relative( pathToFileURL( cwd ).toString(), target.toString() );
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if ( localOnly ) {
|
|
38
|
+
throw new TypeError( `non-local redirect to ${to} rejected` );
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return target.toString();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Caches list of page names meta has been collected for in cached promise.
|
|
46
|
+
*/
|
|
47
|
+
let cachedPages;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Caches promise for collected meta information on a named set of pages.
|
|
51
|
+
*/
|
|
52
|
+
let cachedMeta;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Caches the last compiled hierarchy promise, keyed by the same page list
|
|
56
|
+
* string as cachedPages. Invalidated together with cachedMeta.
|
|
57
|
+
*/
|
|
58
|
+
let cachedHierarchy;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Clears the front matter and hierarchy caches. Intended for use in tests
|
|
62
|
+
* and dev-server invalidation.
|
|
63
|
+
*/
|
|
64
|
+
export function clearCache() {
|
|
65
|
+
cachedPages = undefined;
|
|
66
|
+
cachedMeta = undefined;
|
|
67
|
+
cachedHierarchy = undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reads front matter from every named or all discovered page file(s). Repeated
|
|
72
|
+
* invocations with the same set of pages is served from a cache.
|
|
73
|
+
*
|
|
74
|
+
* @param {UserConfig} config Vitepress site configuration
|
|
75
|
+
* @param {string[]} [pages] names of pages to read front matter from, omit to re-discover all page files in configured source
|
|
76
|
+
* @returns {Promise<Object<string, Object>>} promise for collection of front matter data per named page
|
|
77
|
+
*/
|
|
78
|
+
export async function collectFrontmatterOfAllPages( config, pages = undefined ) {
|
|
79
|
+
const cwd = sourceFolder( config );
|
|
80
|
+
const cacheKey = Array.isArray( pages ) ? pages.join( "," ) : null;
|
|
81
|
+
|
|
82
|
+
if ( cacheKey ) {
|
|
83
|
+
if ( cacheKey !== cachedPages ) {
|
|
84
|
+
cachedPages = cacheKey;
|
|
85
|
+
cachedMeta = collectFrontmatterOfPages( config, pages );
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return cachedMeta;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
pages = ( await Array.fromAsync( glob( "**/*.md", { cwd } ) ) ).map( name => name.replace( /\\/g, "/" ) );
|
|
92
|
+
|
|
93
|
+
return collectFrontmatterOfPages( config, pages );
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Reads front matter from named page files.
|
|
98
|
+
*
|
|
99
|
+
* @param {UserConfig} config Vitepress site configuration
|
|
100
|
+
* @param {string[]} pages names of pages to read front matter from
|
|
101
|
+
* @returns {Promise<Object<string, Object>>} promise for collection of front matter data per named page
|
|
102
|
+
*/
|
|
103
|
+
async function collectFrontmatterOfPages( config, pages ) {
|
|
104
|
+
const cwd = sourceFolder( config );
|
|
105
|
+
const metas = {};
|
|
106
|
+
|
|
107
|
+
pages = [ ...pages ].sort( ( l, r ) => l.localeCompare( r ) );
|
|
108
|
+
|
|
109
|
+
const files = await Promise.all( pages.map( name => readFile( resolve( cwd, name ), "utf-8" ) ) );
|
|
110
|
+
|
|
111
|
+
for ( let i = 0; i < pages.length; i++ ) {
|
|
112
|
+
const page = pages[i];
|
|
113
|
+
const file = files[i];
|
|
114
|
+
const docs = YAML.parseAllDocuments( file ).filter( doc => !doc.empty && !doc.errors?.length );
|
|
115
|
+
|
|
116
|
+
const meta = { ...( docs[0]?.toJS?.() ?? {} ) };
|
|
117
|
+
|
|
118
|
+
// Extract the first ATX heading (# …) as a fallback title when none is
|
|
119
|
+
// declared in frontmatter. Strip the frontmatter block first so a heading
|
|
120
|
+
// inside YAML comments does not match.
|
|
121
|
+
if ( meta.title == null && meta.label == null ) {
|
|
122
|
+
const body = file.replace( /^---[\s\S]*?^---\s*/m, "" );
|
|
123
|
+
const headingMatch = body.match( /^#{1,6}\s+(.+)/m );
|
|
124
|
+
|
|
125
|
+
if ( headingMatch ) {
|
|
126
|
+
meta._firstHeading = headingMatch[1].trim();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
metas[page] = meta;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return metas;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Compiles static unconditional redirects defined in front matter of pages for
|
|
138
|
+
* integration with site's configuration in property `rewrites`.
|
|
139
|
+
*
|
|
140
|
+
* @param {UserConfig} config Vitepress configuration
|
|
141
|
+
* @param {string[]} pages relative path names of markdown files discovered by vitepress for describing pages, omit for checking all markdown files
|
|
142
|
+
* @returns {Promise<Object<string,string>>} promise for set of unconditional static redirects defined in front matter of pages
|
|
143
|
+
*/
|
|
144
|
+
export async function compileRedirects( config, pages = undefined ) {
|
|
145
|
+
const metas = await collectFrontmatterOfAllPages( config, pages );
|
|
146
|
+
const redirects = {};
|
|
147
|
+
|
|
148
|
+
for ( const [ page, meta ] of Object.entries( metas ) ) {
|
|
149
|
+
const redirect = meta?.redirect;
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
if ( redirect?.["*"] && typeof redirect === "object" && Object.keys( redirect ).length === 1 ) {
|
|
153
|
+
redirects[page] = qualifyRedirect( page, redirect["*"], true );
|
|
154
|
+
}
|
|
155
|
+
// String-form redirects ("redirect: other.md") are handled client-side by
|
|
156
|
+
// ScavoldSimpleRedirect and must NOT go into VitePress rewrites — a rewrite
|
|
157
|
+
// would remove the source URL from the build, making it a 404 instead of a
|
|
158
|
+
// redirect page.
|
|
159
|
+
} catch {}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const hierarchy = await compileHierarchy( config, pages );
|
|
163
|
+
|
|
164
|
+
forEachPage( hierarchy, ( node, index, siblings ) => {
|
|
165
|
+
if ( !ptnIsIndexPage.test( node.path ) ) {
|
|
166
|
+
if ( index !== 0 || siblings.some( ( { path } ) => ptnIsIndexPage.test( path ) ) ) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const target = node.path.replace( /\/index\.md$/, "/" );
|
|
172
|
+
|
|
173
|
+
for ( let iter = node.parent; iter; iter = iter.parent ) {
|
|
174
|
+
if ( !iter.isPage && iter.path && iter.path !== target && iter.path + "/" !== target ) {
|
|
175
|
+
redirects[iter.path] = target;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
} );
|
|
179
|
+
|
|
180
|
+
// Merge url alias rewrites: source path → alias path (both .md-relative).
|
|
181
|
+
forEachPage( hierarchy, node => {
|
|
182
|
+
if ( node.url != null ) {
|
|
183
|
+
redirects[node.path] = node.url;
|
|
184
|
+
}
|
|
185
|
+
} );
|
|
186
|
+
|
|
187
|
+
return redirects;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Compiles hierarchy of site's pages based on named pages and their front
|
|
192
|
+
* matter data.
|
|
193
|
+
*
|
|
194
|
+
* @param {UserConfig} config Vitepress site configuration
|
|
195
|
+
* @param {string[]} pages names pages of site to consider
|
|
196
|
+
* @returns {Promise<{}>}
|
|
197
|
+
*/
|
|
198
|
+
export async function compileHierarchy( config, pages ) {
|
|
199
|
+
const cacheKey = Array.isArray( pages ) ? pages.join( "," ) : null;
|
|
200
|
+
|
|
201
|
+
if ( cacheKey && cacheKey === cachedPages && cachedHierarchy ) {
|
|
202
|
+
return cachedHierarchy;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const hierarchyPromise = _buildHierarchy( config, pages );
|
|
206
|
+
|
|
207
|
+
if ( cacheKey ) {
|
|
208
|
+
cachedHierarchy = hierarchyPromise;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return hierarchyPromise;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function _buildHierarchy( config, pages ) {
|
|
215
|
+
const metas = await collectFrontmatterOfAllPages( config, pages );
|
|
216
|
+
const hierarchy = { isPage: false, path: "", subs: {} };
|
|
217
|
+
|
|
218
|
+
// create hierarchy of pages
|
|
219
|
+
for ( const [ page, meta ] of Object.entries( metas ) ) {
|
|
220
|
+
const segments = page.replace( /^\//, "" ).replace( /\/$/, "/index.md" ).split( "/" );
|
|
221
|
+
let parent = hierarchy;
|
|
222
|
+
|
|
223
|
+
const path = [];
|
|
224
|
+
|
|
225
|
+
while ( segments.length > 1 ) {
|
|
226
|
+
const lead = segments.shift();
|
|
227
|
+
path.push( lead );
|
|
228
|
+
|
|
229
|
+
let sub = parent.subs?.[lead];
|
|
230
|
+
|
|
231
|
+
if ( !sub ) {
|
|
232
|
+
sub = ( parent.subs ??= {} )[lead] = {
|
|
233
|
+
isPage: false,
|
|
234
|
+
path: path.join( "/" ),
|
|
235
|
+
frontmatter: {},
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
Object.defineProperty( sub, "parent", { value: parent, writable: true } );
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
parent = sub;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const name = segments[0];
|
|
245
|
+
let leaf = parent.subs?.[name];
|
|
246
|
+
|
|
247
|
+
if ( leaf?.isPage ) {
|
|
248
|
+
throw new TypeError( `ambiguous page ${leaf.path} exists as ${page}, too` );
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if ( !leaf ) {
|
|
252
|
+
leaf = ( parent.subs ??= {} )[name] = {};
|
|
253
|
+
|
|
254
|
+
Object.defineProperty( leaf, "parent", { value: parent, writable: true } );
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
leaf.isPage = true;
|
|
258
|
+
leaf.path = page;
|
|
259
|
+
leaf.frontmatter = meta;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Promote each index.md into its parent folder node: the folder node absorbs
|
|
263
|
+
// the index.md's path and frontmatter, becomes a page itself, and the index.md
|
|
264
|
+
// leaf is removed from subs so it no longer appears as a sibling of other pages
|
|
265
|
+
// in the same folder.
|
|
266
|
+
forEachPage( hierarchy, node => {
|
|
267
|
+
if ( !ptnIsIndexPage.test( node.path ) ) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const folder = node.parent;
|
|
272
|
+
|
|
273
|
+
// Root index.md has no meaningful folder node to promote into — leave it as-is.
|
|
274
|
+
if ( !folder || !folder.path ) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Absorb: folder takes on the index.md's identity.
|
|
279
|
+
folder.isPage = true;
|
|
280
|
+
folder.path = node.path;
|
|
281
|
+
folder.frontmatter = { ...node.frontmatter, ...folder.frontmatter };
|
|
282
|
+
|
|
283
|
+
// Remove the index.md leaf from subs so it is not listed as a sibling.
|
|
284
|
+
const indexKey = Object.keys( folder.subs ?? {} ).find( k => folder.subs[k] === node );
|
|
285
|
+
|
|
286
|
+
if ( indexKey != null ) {
|
|
287
|
+
delete folder.subs[indexKey];
|
|
288
|
+
}
|
|
289
|
+
} );
|
|
290
|
+
|
|
291
|
+
// Derive display labels from frontmatter title, label, first heading, or path segment.
|
|
292
|
+
forEachPage( hierarchy, node => {
|
|
293
|
+
const fm = node.frontmatter ?? {};
|
|
294
|
+
|
|
295
|
+
if ( fm.title != null ) {
|
|
296
|
+
node.title = String( fm.title );
|
|
297
|
+
} else if ( fm._firstHeading != null ) {
|
|
298
|
+
node.title = String( fm._firstHeading );
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if ( fm.label != null ) {
|
|
302
|
+
node.label = String( fm.label );
|
|
303
|
+
}
|
|
304
|
+
} );
|
|
305
|
+
|
|
306
|
+
// Collect url aliases and validate uniqueness. A `url` front matter value
|
|
307
|
+
// declares the desired output path for a page, hiding its source location.
|
|
308
|
+
// Conflicts (two pages targeting the same URL) are a build error.
|
|
309
|
+
const urlTargets = new Map(); // normalised target path → source path
|
|
310
|
+
|
|
311
|
+
forEachPage( hierarchy, node => {
|
|
312
|
+
const raw = node.frontmatter?.url;
|
|
313
|
+
|
|
314
|
+
if ( raw == null ) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Normalise: strip leading slash, ensure .md extension.
|
|
319
|
+
const normalised = String( raw )
|
|
320
|
+
.replace( /^\//, "" )
|
|
321
|
+
.replace( /\/?$/, "" )
|
|
322
|
+
.replace( /\.md$/, "" ) + ".md";
|
|
323
|
+
|
|
324
|
+
const existing = urlTargets.get( normalised );
|
|
325
|
+
|
|
326
|
+
if ( existing != null ) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`[scavold] url alias conflict: both "${existing}" and "${node.path}" ` +
|
|
329
|
+
`declare url: ${raw} — each page must have a unique URL alias`
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
urlTargets.set( normalised, node.path );
|
|
334
|
+
node.url = normalised;
|
|
335
|
+
} );
|
|
336
|
+
|
|
337
|
+
// Recursively pulls down defined locale to given node from its ancestors.
|
|
338
|
+
// Own frontmatter takes precedence over inherited value.
|
|
339
|
+
// Prefer `locale` key; accept `lang` for backwards compatibility.
|
|
340
|
+
const pullLocale = node => {
|
|
341
|
+
const ownLocale = node.frontmatter?.locale ?? node.frontmatter?.lang ?? undefined;
|
|
342
|
+
|
|
343
|
+
if ( ownLocale != null ) {
|
|
344
|
+
node.locale = ownLocale;
|
|
345
|
+
return ownLocale;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if ( node.parent ) {
|
|
349
|
+
const inherited = pullLocale( node.parent );
|
|
350
|
+
|
|
351
|
+
if ( inherited != null ) {
|
|
352
|
+
node.locale = inherited;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return inherited;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return undefined;
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
forEachPage( hierarchy, pullLocale );
|
|
362
|
+
|
|
363
|
+
// Back-link pass: for every page that declares `translations`, check each
|
|
364
|
+
// target page and add the reverse link if the target has none yet for this
|
|
365
|
+
// locale. Warn when a conflicting explicit back-link points elsewhere.
|
|
366
|
+
forEachPage( hierarchy, node => {
|
|
367
|
+
const translations = node.frontmatter?.translations;
|
|
368
|
+
|
|
369
|
+
if ( !translations || typeof translations !== "object" ) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const sourceLocale = node.locale;
|
|
374
|
+
|
|
375
|
+
if ( !sourceLocale ) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
for ( const [ targetLocale, targetPath ] of Object.entries( translations ) ) {
|
|
380
|
+
const target = findNodeByPath( hierarchy, String( targetPath ) );
|
|
381
|
+
|
|
382
|
+
if ( !target ) {
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const existing = target.frontmatter?.translations?.[ sourceLocale ];
|
|
387
|
+
|
|
388
|
+
if ( existing != null ) {
|
|
389
|
+
// Target already has an explicit back-link. Warn if it points elsewhere.
|
|
390
|
+
if ( existing !== node.path ) {
|
|
391
|
+
console.warn(
|
|
392
|
+
`[scavold] translations conflict: ${target.path} declares ` +
|
|
393
|
+
`translations.${sourceLocale}=${existing} but ${node.path} ` +
|
|
394
|
+
`points back to itself. The explicit declaration on ${target.path} takes precedence.`
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
} else {
|
|
398
|
+
// Add the back-link.
|
|
399
|
+
( target.frontmatter ??= {} );
|
|
400
|
+
( target.frontmatter.translations ??= {} );
|
|
401
|
+
target.frontmatter.translations[ sourceLocale ] = node.path;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
} );
|
|
405
|
+
|
|
406
|
+
// Sort children of every node: pages with an explicit `order` frontmatter
|
|
407
|
+
// value come first (ascending); the rest follow in filename order.
|
|
408
|
+
const sortSubs = node => {
|
|
409
|
+
if ( !node.subs ) {
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const entries = Object.entries( node.subs );
|
|
414
|
+
|
|
415
|
+
entries.sort( ( [ , a ], [ , b ] ) => {
|
|
416
|
+
const ao = a.frontmatter?.order;
|
|
417
|
+
const bo = b.frontmatter?.order;
|
|
418
|
+
const aHas = ao != null;
|
|
419
|
+
const bHas = bo != null;
|
|
420
|
+
|
|
421
|
+
if ( aHas && bHas ) {
|
|
422
|
+
return ao - bo;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if ( aHas ) {
|
|
426
|
+
return -1;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if ( bHas ) {
|
|
430
|
+
return 1;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return a.path.localeCompare( b.path );
|
|
434
|
+
} );
|
|
435
|
+
|
|
436
|
+
node.subs = Object.fromEntries( entries );
|
|
437
|
+
|
|
438
|
+
for ( const sub of Object.values( node.subs ) ) {
|
|
439
|
+
sortSubs( sub );
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
sortSubs( hierarchy );
|
|
444
|
+
|
|
445
|
+
return hierarchy;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Finds a node in the hierarchy by its exact path.
|
|
450
|
+
*
|
|
451
|
+
* @param {object} root
|
|
452
|
+
* @param {string} searchedPath
|
|
453
|
+
* @returns {object|undefined}
|
|
454
|
+
*/
|
|
455
|
+
function findNodeByPath( root, searchedPath ) {
|
|
456
|
+
if ( root.path === searchedPath ) {
|
|
457
|
+
return root;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
for ( const sub of Object.values( root.subs ?? {} ) ) {
|
|
461
|
+
const match = findNodeByPath( sub, searchedPath );
|
|
462
|
+
|
|
463
|
+
if ( match != null ) {
|
|
464
|
+
return match;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Recursive iterates over every node of hierarchy which is representing a page.
|
|
473
|
+
*
|
|
474
|
+
* @param hierarchy full hierarchy or one of its subordinated nodes
|
|
475
|
+
* @param {function} fn callback invoked on nodes representing a particular page
|
|
476
|
+
* @param {boolean} depthFirst if true, subordinated nodes are invoked prior to their parent
|
|
477
|
+
*/
|
|
478
|
+
export function forEachPage( hierarchy, fn, depthFirst = false ) {
|
|
479
|
+
if ( hierarchy.isPage && !depthFirst ) {
|
|
480
|
+
const siblings = Object.values( hierarchy.parent?.subs ?? { hierarchy } );
|
|
481
|
+
|
|
482
|
+
fn( hierarchy, siblings.indexOf( hierarchy ), siblings );
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
for ( const sub of Object.values( hierarchy?.subs ?? {} ) ) {
|
|
486
|
+
forEachPage( sub, fn, depthFirst );
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if ( hierarchy.isPage && depthFirst ) {
|
|
490
|
+
const siblings = Object.values( hierarchy.parent?.subs ?? { hierarchy } );
|
|
491
|
+
|
|
492
|
+
fn( hierarchy, siblings.indexOf( hierarchy ), siblings );
|
|
493
|
+
}
|
|
494
|
+
}
|
package/lib/parser.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accepts a string of key-value pairs and provides an object with found keys
|
|
3
|
+
* and their values featuring keys mentioned without an assigned value resulting
|
|
4
|
+
* in boolean value true.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} string - Input string containing key-value pairs
|
|
7
|
+
* @returns {Object} - Object with found keys and their values
|
|
8
|
+
*/
|
|
9
|
+
export function parseKeyValuePairs( string ) {
|
|
10
|
+
const trimmed = String( string || "" ).trim();
|
|
11
|
+
|
|
12
|
+
if ( trimmed === "" ) {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const getQuotedPart = ( tokens, start ) => {
|
|
17
|
+
let end, quote;
|
|
18
|
+
|
|
19
|
+
if ( tokens[start] === "" && ( ( quote = tokens[start + 1] ) === '"' || quote === "'" ) ) {
|
|
20
|
+
start += 2;
|
|
21
|
+
end = tokens.indexOf( quote, start );
|
|
22
|
+
} else {
|
|
23
|
+
end = tokens.findIndex( ( token, i ) => i >= start && /\s/.test( token ) );
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if ( end < 0 ) {
|
|
27
|
+
end = numTokens;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return [ tokens.slice( start, end ).join( "" ), end + 1 ];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const tokens = trimmed.split( /(\s+|[="'])/ );
|
|
34
|
+
let numTokens = tokens.length;
|
|
35
|
+
const found = {};
|
|
36
|
+
|
|
37
|
+
for ( let i = 0; i < numTokens; ) {
|
|
38
|
+
const lead = tokens[i++];
|
|
39
|
+
const sep = tokens[i++];
|
|
40
|
+
|
|
41
|
+
switch ( sep ) {
|
|
42
|
+
default :
|
|
43
|
+
if ( lead !== "" ) {
|
|
44
|
+
found[lead] = true;
|
|
45
|
+
}
|
|
46
|
+
break;
|
|
47
|
+
|
|
48
|
+
case "=" : {
|
|
49
|
+
[ found[lead], i ] = getQuotedPart( tokens, i );
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
case "'" :
|
|
54
|
+
case '"' : {
|
|
55
|
+
if ( lead !== "" ) {
|
|
56
|
+
i -= 2;
|
|
57
|
+
numTokens -= 2;
|
|
58
|
+
tokens.splice( i, 3, tokens[i] + tokens[i + 1] + tokens[i + 2] );
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let end = tokens.indexOf( sep, i );
|
|
63
|
+
|
|
64
|
+
if ( end < 0 ) {
|
|
65
|
+
end = numTokens;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const key = tokens.slice( i, end ).join( "" );
|
|
69
|
+
|
|
70
|
+
if ( tokens[end + 1] === "" && tokens[end + 2] === "=" ) {
|
|
71
|
+
[ found[key], i ] = getQuotedPart( tokens, end + 3 );
|
|
72
|
+
} else {
|
|
73
|
+
found[key] = true;
|
|
74
|
+
i = end + 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return found;
|
|
83
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true for absolute URLs (any scheme, e.g. "https://", "mailto:") and
|
|
3
|
+
* protocol-relative URLs ("//cdn.example.com"). These must be redirected
|
|
4
|
+
* as-is and must never be treated as repository-relative paths.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} value
|
|
7
|
+
* @returns {boolean}
|
|
8
|
+
*/
|
|
9
|
+
export function isExternalUrl( value ) {
|
|
10
|
+
return typeof value === "string" && ( /^[a-z][a-z0-9+.-]*:/i.test( value ) || value.startsWith( "//" ) );
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Normalises a redirect target so it resolves on a plain static file server.
|
|
15
|
+
*
|
|
16
|
+
* VitePress builds leaf pages as `<name>.html` and folder pages as
|
|
17
|
+
* `<dir>/index.html`. Clean URLs like `/de/leistungen` are only resolved by
|
|
18
|
+
* VitePress' in-app router during SPA navigation. A redirect is a *hard*
|
|
19
|
+
* navigation (`location.replace`, an injected `location.replace(...)` head
|
|
20
|
+
* script, or a `<meta http-equiv="refresh">` fallback): it bypasses the router
|
|
21
|
+
* and asks the static server — the editor preview service worker or a bare
|
|
22
|
+
* `file_server` web server — for that exact path. A clean leaf URL maps to no
|
|
23
|
+
* file there and 404s.
|
|
24
|
+
*
|
|
25
|
+
* Appending `.html` to leaf paths (while leaving trailing-slash folder URLs to
|
|
26
|
+
* the server's `index.html` rule) yields a path that maps to a real file on any
|
|
27
|
+
* static server. Absolute/external URLs, query strings and fragments are left
|
|
28
|
+
* untouched.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} target site-relative path or absolute URL
|
|
31
|
+
* @returns {string} a path/URL a plain static server can serve directly
|
|
32
|
+
*/
|
|
33
|
+
export function servableRedirectTarget( target ) {
|
|
34
|
+
if ( typeof target !== "string" || target === "" ) return target;
|
|
35
|
+
|
|
36
|
+
if ( isExternalUrl( target ) ) return target;
|
|
37
|
+
|
|
38
|
+
// Split off any query string / fragment so the extension lands on the path.
|
|
39
|
+
const [ , path, suffix ] = /^([^?#]*)([?#].*)?$/.exec( target );
|
|
40
|
+
|
|
41
|
+
// Folder URLs ("/de/jobs/") rely on the server's index.html rule; paths that
|
|
42
|
+
// already carry the ".html" extension are served as-is.
|
|
43
|
+
if ( path === "" || path.endsWith( "/" ) || path.endsWith( ".html" ) ) return path + ( suffix ?? "" );
|
|
44
|
+
|
|
45
|
+
return path + ".html" + ( suffix ?? "" );
|
|
46
|
+
}
|