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/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
+ }