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,45 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Scavold } from "../index";
|
|
3
|
+
|
|
4
|
+
defineProps<{
|
|
5
|
+
items: Scavold.MenuItem[];
|
|
6
|
+
}>();
|
|
7
|
+
|
|
8
|
+
function label( node: Scavold.HierarchyNode ): string {
|
|
9
|
+
if ( node.label ) return node.label;
|
|
10
|
+
if ( node.title ) return node.title;
|
|
11
|
+
|
|
12
|
+
// Fall back to the last meaningful path segment (strip index.md, extension)
|
|
13
|
+
const segment = node.path
|
|
14
|
+
.replace( /\/index\.md$/, "" )
|
|
15
|
+
.replace( /\.md$/, "" )
|
|
16
|
+
.split( "/" )
|
|
17
|
+
.filter( Boolean )
|
|
18
|
+
.pop();
|
|
19
|
+
|
|
20
|
+
return segment ?? node.path;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function href( node: Scavold.HierarchyNode ): string {
|
|
24
|
+
return "/" + ( node.url ?? node.path ).replace( /\.md$/, "" ).replace( /\/index$/, "/" );
|
|
25
|
+
}
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<template>
|
|
29
|
+
<ul>
|
|
30
|
+
<li
|
|
31
|
+
v-for="item of items"
|
|
32
|
+
:key="item.node.path"
|
|
33
|
+
:class="{
|
|
34
|
+
active: item.active,
|
|
35
|
+
current: item.current,
|
|
36
|
+
}"
|
|
37
|
+
>
|
|
38
|
+
<a :href="href( item.node )" :aria-current="item.current ? 'page' : undefined">{{ label( item.node ) }}</a>
|
|
39
|
+
<ScavoldMenuItems
|
|
40
|
+
v-if="item.children.length"
|
|
41
|
+
:items="item.children"
|
|
42
|
+
/>
|
|
43
|
+
</li>
|
|
44
|
+
</ul>
|
|
45
|
+
</template>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { useContainer, containerProps } from "../composables/useContainer.js";
|
|
3
|
+
|
|
4
|
+
const props = defineProps( containerProps );
|
|
5
|
+
const { classes, dataAttrs } = useContainer( props );
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<nav :class="classes" v-bind="dataAttrs">
|
|
10
|
+
<slot />
|
|
11
|
+
</nav>
|
|
12
|
+
</template>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { useContainer, containerProps } from "../composables/useContainer.js";
|
|
3
|
+
|
|
4
|
+
const props = defineProps( containerProps );
|
|
5
|
+
const { classes, dataAttrs } = useContainer( props );
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<section :class="classes" v-bind="dataAttrs">
|
|
10
|
+
<slot />
|
|
11
|
+
</section>
|
|
12
|
+
</template>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { useData } from "vitepress";
|
|
3
|
+
import { computed, onMounted } from "vue";
|
|
4
|
+
import { isExternalUrl, servableRedirectTarget } from "../lib/redirectTarget.js";
|
|
5
|
+
|
|
6
|
+
const { frontmatter, page } = useData();
|
|
7
|
+
|
|
8
|
+
// The resolved, server-servable redirect target. External URLs pass through
|
|
9
|
+
// unchanged — they are handled at build time via <meta http-equiv="refresh">
|
|
10
|
+
// injected by transformPageData (direct page loads) and by the capture-phase
|
|
11
|
+
// click interceptor in lib/index.js (SPA navigation). Only internal targets
|
|
12
|
+
// need runtime handling here.
|
|
13
|
+
const target = computed( () => {
|
|
14
|
+
const redirect = frontmatter.value.redirect;
|
|
15
|
+
if ( typeof redirect !== "string" ) return null;
|
|
16
|
+
if ( isExternalUrl( redirect ) ) return null;
|
|
17
|
+
if ( redirect.startsWith( "/" ) ) return servableRedirectTarget( redirect );
|
|
18
|
+
// Relative .md path — resolve against the current page's directory.
|
|
19
|
+
// page.relativePath is used (not location.href) so the computed works in
|
|
20
|
+
// SSR/build context where location is undefined.
|
|
21
|
+
const dir = page.value.relativePath.replace( /\/[^/]*$/, "" );
|
|
22
|
+
const resolved = dir ? `${dir}/${redirect}` : redirect;
|
|
23
|
+
return servableRedirectTarget( "/" + resolved.replace( /\/index\.md$/, "/" ).replace( /\.md$/, "" ) );
|
|
24
|
+
} );
|
|
25
|
+
|
|
26
|
+
onMounted( () => {
|
|
27
|
+
if ( !target.value ) return;
|
|
28
|
+
// Internal redirects: replace the history entry so this page never appears
|
|
29
|
+
// in the browser back-stack. This path is taken on both direct page loads
|
|
30
|
+
// and (as a fallback) SPA navigations not caught by the click interceptor.
|
|
31
|
+
location.replace( target.value );
|
|
32
|
+
} );
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<template><!-- redirect handled in head (external) or onMounted (internal) --></template>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { useVideo, videoProps } from "../composables/useVideo.js";
|
|
3
|
+
|
|
4
|
+
const props = defineProps( videoProps );
|
|
5
|
+
|
|
6
|
+
const { src, poster, autoplay, loop, muted, preload, overlay, controls, ariaLabel } =
|
|
7
|
+
useVideo( props );
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<!-- overlay mode: the video is a background, the slot content sits on top -->
|
|
12
|
+
<div v-if="overlay && src" class="scavold-video scavold-video--overlay">
|
|
13
|
+
<video
|
|
14
|
+
class="scavold-video__media"
|
|
15
|
+
:src="src"
|
|
16
|
+
:poster="poster || undefined"
|
|
17
|
+
:autoplay="autoplay || undefined"
|
|
18
|
+
:loop="loop || undefined"
|
|
19
|
+
:muted="muted || undefined"
|
|
20
|
+
:controls="controls || undefined"
|
|
21
|
+
:preload="preload"
|
|
22
|
+
:aria-label="ariaLabel"
|
|
23
|
+
playsinline
|
|
24
|
+
/>
|
|
25
|
+
<div class="scavold-video__overlay">
|
|
26
|
+
<slot />
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<!-- default mode: plain inline player; the slot is fallback content -->
|
|
31
|
+
<video
|
|
32
|
+
v-else-if="src"
|
|
33
|
+
class="scavold-video scavold-video__media"
|
|
34
|
+
:src="src"
|
|
35
|
+
:poster="poster || undefined"
|
|
36
|
+
:autoplay="autoplay || undefined"
|
|
37
|
+
:loop="loop || undefined"
|
|
38
|
+
:muted="muted || undefined"
|
|
39
|
+
:preload="preload"
|
|
40
|
+
:aria-label="ariaLabel"
|
|
41
|
+
controls
|
|
42
|
+
playsinline
|
|
43
|
+
>
|
|
44
|
+
<!-- <track kind="captions" src="..." srclang="..." label="..." /> -->
|
|
45
|
+
<slot />
|
|
46
|
+
</video>
|
|
47
|
+
</template>
|
|
48
|
+
|
|
49
|
+
<style scoped>
|
|
50
|
+
/*
|
|
51
|
+
* Functional layout only — visual styling (scrim, colours, alignment, min-height)
|
|
52
|
+
* belongs to the consuming theme. The overlay stacks the video and the content in
|
|
53
|
+
* a single grid cell so the content defines the block's height and the video
|
|
54
|
+
* covers the same area behind it.
|
|
55
|
+
*/
|
|
56
|
+
.scavold-video--overlay {
|
|
57
|
+
position: relative;
|
|
58
|
+
display: grid;
|
|
59
|
+
overflow: hidden;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.scavold-video--overlay .scavold-video__media {
|
|
63
|
+
grid-area: 1 / 1;
|
|
64
|
+
width: 100%;
|
|
65
|
+
height: 100%;
|
|
66
|
+
object-fit: cover;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.scavold-video--overlay .scavold-video__overlay {
|
|
70
|
+
grid-area: 1 / 1;
|
|
71
|
+
position: relative;
|
|
72
|
+
z-index: 1;
|
|
73
|
+
}
|
|
74
|
+
</style>
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { useData } from "vitepress";
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
import { useL10n } from "@cepharum/vue3-i18n";
|
|
4
|
+
import type { Scavold } from "../index";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
*
|
|
8
|
+
*/
|
|
9
|
+
export function useHierarchy() {
|
|
10
|
+
const { page } = useData();
|
|
11
|
+
|
|
12
|
+
const reparent = ( node: Scavold.HierarchyNode ) => {
|
|
13
|
+
if ( node?.subs ) {
|
|
14
|
+
for ( const sub of Object.values( node.subs ) ) {
|
|
15
|
+
Object.defineProperty( sub, "parent", { value: node, writable: true } );
|
|
16
|
+
|
|
17
|
+
reparent( sub );
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const hierarchy = computed( () => {
|
|
23
|
+
// eslint-disable-next-line no-shadow
|
|
24
|
+
const { hierarchy } = page.value as Scavold.AugmentedPageData;
|
|
25
|
+
|
|
26
|
+
reparent( hierarchy );
|
|
27
|
+
|
|
28
|
+
return hierarchy;
|
|
29
|
+
} );
|
|
30
|
+
|
|
31
|
+
const findByPath = ( thread: Scavold.HierarchyNode | undefined, searchedPath: string ): Scavold.HierarchyNode | undefined => {
|
|
32
|
+
if ( !thread ) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if ( thread.path === searchedPath ) {
|
|
37
|
+
return thread;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if ( thread.subs ) {
|
|
41
|
+
for ( const sub of Object.values( thread.subs ) ) {
|
|
42
|
+
const match = findByPath( sub, searchedPath );
|
|
43
|
+
|
|
44
|
+
if ( match != null ) {
|
|
45
|
+
return match;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return undefined;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const current = computed( () => hierarchy.value ? findByPath( hierarchy.value, page.value.filePath ) : undefined );
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Returns the ancestor of `node` that is exactly `targetDepth` levels below
|
|
57
|
+
* the root, following the path upward from `node`. Returns undefined if
|
|
58
|
+
* `node` is not deep enough or `targetDepth` is 0 (root itself has no
|
|
59
|
+
* listable parent at depth 0).
|
|
60
|
+
*/
|
|
61
|
+
const ancestorAtDepth = ( node: Scavold.HierarchyNode | undefined, targetDepth: number ): Scavold.HierarchyNode | undefined => {
|
|
62
|
+
if ( node == null ) {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Compute depth of this node by walking to root.
|
|
67
|
+
let depth = 0;
|
|
68
|
+
let iter: Scavold.HierarchyNode | undefined = node;
|
|
69
|
+
|
|
70
|
+
while ( iter?.parent ) {
|
|
71
|
+
depth++;
|
|
72
|
+
iter = iter.parent;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// targetDepth is the depth of *listed items*, so the parent node to
|
|
76
|
+
// list from is at targetDepth - 1.
|
|
77
|
+
const parentDepth = targetDepth - 1;
|
|
78
|
+
|
|
79
|
+
if ( depth < parentDepth ) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Walk up from node until we reach parentDepth.
|
|
84
|
+
iter = node;
|
|
85
|
+
|
|
86
|
+
for ( let d = depth; d > parentDepth && iter?.parent; d-- ) {
|
|
87
|
+
iter = iter.parent;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return iter;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Returns true if `node` should be hidden in the given rendering context.
|
|
95
|
+
* Reads the `hide` frontmatter property:
|
|
96
|
+
* false / absent — visible everywhere
|
|
97
|
+
* true — hidden everywhere
|
|
98
|
+
* "menu" — hidden in menus only
|
|
99
|
+
* "breadcrumb" — hidden in breadcrumbs only
|
|
100
|
+
*/
|
|
101
|
+
const isHidden = ( node: Scavold.HierarchyNode, context: "menu" | "breadcrumb" ): boolean => {
|
|
102
|
+
const hide = node.frontmatter?.hide;
|
|
103
|
+
|
|
104
|
+
if ( hide === true ) {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if ( hide === "menu" || hide === "breadcrumb" ) {
|
|
109
|
+
return hide === context;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return false;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Returns true if `node` is on the path from the root to the current page
|
|
117
|
+
* (i.e. is an ancestor of current, or is current itself).
|
|
118
|
+
*/
|
|
119
|
+
const isOnActivePath = ( node: Scavold.HierarchyNode ): boolean => {
|
|
120
|
+
let iter: Scavold.HierarchyNode | undefined = current.value;
|
|
121
|
+
|
|
122
|
+
while ( iter ) {
|
|
123
|
+
if ( iter === node ) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
iter = iter.parent;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return false;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Collects menu items recursively from a starting node.
|
|
135
|
+
*
|
|
136
|
+
* @param parent the node whose direct children form the listed items
|
|
137
|
+
* @param remaining levels left to descend; -1 means unlimited
|
|
138
|
+
* @param activeOnly only expand children along the active path
|
|
139
|
+
* @param expand expand all nodes with children regardless of active path
|
|
140
|
+
*/
|
|
141
|
+
const collectItems = (
|
|
142
|
+
parent: Scavold.HierarchyNode | undefined,
|
|
143
|
+
remaining: number,
|
|
144
|
+
activeOnly: boolean,
|
|
145
|
+
expand: boolean,
|
|
146
|
+
): Scavold.MenuItem[] => {
|
|
147
|
+
if ( parent?.subs == null ) {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return Object.values( parent.subs )
|
|
152
|
+
.filter( node => !isHidden( node, "menu" ) )
|
|
153
|
+
.map( node => {
|
|
154
|
+
const shouldDescend = remaining !== 0 && (
|
|
155
|
+
expand ||
|
|
156
|
+
( activeOnly && isOnActivePath( node ) )
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
node,
|
|
161
|
+
active: isOnActivePath( node ),
|
|
162
|
+
current: node === current.value,
|
|
163
|
+
children: shouldDescend
|
|
164
|
+
? collectItems( node, remaining === -1 ? -1 : remaining - 1, activeOnly, expand )
|
|
165
|
+
: [],
|
|
166
|
+
};
|
|
167
|
+
} );
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Collects the ancestor chain from root to the current page as a flat array
|
|
172
|
+
* of MenuItem objects, suitable for breadcrumb rendering.
|
|
173
|
+
*
|
|
174
|
+
* @param includeCurrent include the current page as the last item
|
|
175
|
+
* @param includeRoot include the root node as the first item
|
|
176
|
+
*/
|
|
177
|
+
const collectAncestors = (
|
|
178
|
+
includeCurrent: boolean,
|
|
179
|
+
includeRoot: boolean,
|
|
180
|
+
): Scavold.MenuItem[] => {
|
|
181
|
+
const chain: Scavold.HierarchyNode[] = [];
|
|
182
|
+
let iter: Scavold.HierarchyNode | undefined = current.value;
|
|
183
|
+
|
|
184
|
+
while ( iter ) {
|
|
185
|
+
chain.unshift( iter );
|
|
186
|
+
iter = iter.parent;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return chain
|
|
190
|
+
.filter( ( node, index ) => {
|
|
191
|
+
if ( index === 0 && !includeRoot ) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if ( node === current.value && !includeCurrent ) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if ( isHidden( node, "breadcrumb" ) ) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return true;
|
|
204
|
+
} )
|
|
205
|
+
.map( node => ( {
|
|
206
|
+
node,
|
|
207
|
+
active: true,
|
|
208
|
+
current: node === current.value,
|
|
209
|
+
children: [],
|
|
210
|
+
} ) );
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The resolved locale for the current page. Falls back to the browser locale
|
|
215
|
+
* (via @cepharum/vue3-i18n) when no ancestor declares one.
|
|
216
|
+
*/
|
|
217
|
+
const currentLocale = computed( () =>
|
|
218
|
+
current.value?.locale ?? useL10n().locale
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Resolves a raw path string to a hierarchy node. Supported placeholders:
|
|
223
|
+
* {locale} — full BCP 47 locale tag (e.g. "de-CH", "en")
|
|
224
|
+
* {lang} — primary language subtag only (e.g. "de", "en")
|
|
225
|
+
* Tries the interpolated path as-is, then with `/index.md`, then with `.md`.
|
|
226
|
+
*/
|
|
227
|
+
const resolveByPath = ( rawPath: string ): Scavold.HierarchyNode | undefined => {
|
|
228
|
+
const locale = currentLocale.value ?? "";
|
|
229
|
+
const lang = locale.split( "-" )[0];
|
|
230
|
+
const resolved = rawPath
|
|
231
|
+
.replace( /\{locale\}/g, locale )
|
|
232
|
+
.replace( /\{lang\}/g, lang );
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
findByPath( hierarchy.value, resolved ) ??
|
|
236
|
+
findByPath( hierarchy.value, resolved.replace( /\/?$/, "/index.md" ) ) ??
|
|
237
|
+
findByPath( hierarchy.value, resolved.replace( /\/?$/, ".md" ) )
|
|
238
|
+
);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Converts a hierarchy node path to a root-relative href.
|
|
243
|
+
* Prefers node.url (the alias output path) when declared.
|
|
244
|
+
*/
|
|
245
|
+
const nodeHref = ( node: Scavold.HierarchyNode ): string =>
|
|
246
|
+
"/" + ( node.url ?? node.path ).replace( /\.md$/, "" ).replace( /\/index$/, "/" );
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Walks the full hierarchy depth-first and collects every node, flattened.
|
|
250
|
+
*/
|
|
251
|
+
const allNodes = ( root: Scavold.HierarchyNode | undefined ): Scavold.HierarchyNode[] => {
|
|
252
|
+
const result: Scavold.HierarchyNode[] = [];
|
|
253
|
+
|
|
254
|
+
const walk = ( node: Scavold.HierarchyNode | undefined ) => {
|
|
255
|
+
if ( !node ) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
result.push( node );
|
|
260
|
+
|
|
261
|
+
if ( node.subs ) {
|
|
262
|
+
for ( const sub of Object.values( node.subs ) ) {
|
|
263
|
+
walk( sub );
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
walk( root );
|
|
269
|
+
|
|
270
|
+
return result;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Returns the topmost page node for a given locale — the shallowest isPage
|
|
275
|
+
* node in the hierarchy that carries that locale.
|
|
276
|
+
*/
|
|
277
|
+
const topmostForLocale = ( locale: string ): Scavold.HierarchyNode | undefined => {
|
|
278
|
+
let best: Scavold.HierarchyNode | undefined;
|
|
279
|
+
let bestDepth = Infinity;
|
|
280
|
+
|
|
281
|
+
for ( const node of allNodes( hierarchy.value ) ) {
|
|
282
|
+
if ( !node.isPage || node.locale !== locale ) {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let depth = 0;
|
|
287
|
+
let iter: Scavold.HierarchyNode | undefined = node;
|
|
288
|
+
|
|
289
|
+
while ( iter?.parent ) {
|
|
290
|
+
depth++;
|
|
291
|
+
iter = iter.parent;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if ( depth < bestDepth ) {
|
|
295
|
+
best = node;
|
|
296
|
+
bestDepth = depth;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return best;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Collects available locale links for the current page.
|
|
305
|
+
*
|
|
306
|
+
* @param detection
|
|
307
|
+
* "auto" — try explicit, then inherited, then global; use first non-empty result.
|
|
308
|
+
* "explicit" — only locales declared in the current page's own `translations` frontmatter.
|
|
309
|
+
* "inherited" — walks up the ancestor chain and, for each locale, uses the link declared
|
|
310
|
+
* by the *nearest* ancestor (including the current page itself) that has a
|
|
311
|
+
* `translations` entry for that locale. This means a section index page can
|
|
312
|
+
* declare `translations` once and all its descendants without their own
|
|
313
|
+
* translation link will fall back to the section-level translation — useful
|
|
314
|
+
* when not every page in one locale has a counterpart in another.
|
|
315
|
+
* "global" — any locale present anywhere in the hierarchy; link goes to the topmost
|
|
316
|
+
* page of that locale regardless of any `translations` declarations.
|
|
317
|
+
* @param includeCurrent include the current locale in the result (default false)
|
|
318
|
+
*/
|
|
319
|
+
const collectLocaleLinks = (
|
|
320
|
+
detection: Scavold.LocaleDetection,
|
|
321
|
+
includeCurrent = false,
|
|
322
|
+
): Scavold.LocaleLink[] => {
|
|
323
|
+
if ( detection === "auto" ) {
|
|
324
|
+
for ( const mode of [ "explicit", "inherited", "global" ] as const ) {
|
|
325
|
+
const result = collectLocaleLinks( mode, includeCurrent );
|
|
326
|
+
|
|
327
|
+
if ( result.length > 0 ) {
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return [];
|
|
333
|
+
}
|
|
334
|
+
const locale = currentLocale.value;
|
|
335
|
+
const links = new Map<string, string>(); // locale → href
|
|
336
|
+
|
|
337
|
+
if ( detection === "global" ) {
|
|
338
|
+
// Collect all distinct locales in the hierarchy.
|
|
339
|
+
for ( const node of allNodes( hierarchy.value ) ) {
|
|
340
|
+
if ( node.isPage && node.locale && !links.has( node.locale ) ) {
|
|
341
|
+
const top = topmostForLocale( node.locale );
|
|
342
|
+
|
|
343
|
+
if ( top ) {
|
|
344
|
+
links.set( node.locale, nodeHref( top ) );
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
} else {
|
|
349
|
+
// Walk up the ancestor chain; for "explicit" stop after current node.
|
|
350
|
+
let iter: Scavold.HierarchyNode | undefined = current.value;
|
|
351
|
+
|
|
352
|
+
while ( iter ) {
|
|
353
|
+
const translations = iter.frontmatter?.translations as Record<string, string> | undefined;
|
|
354
|
+
|
|
355
|
+
if ( translations ) {
|
|
356
|
+
for ( const [ loc, path ] of Object.entries( translations ) ) {
|
|
357
|
+
if ( !links.has( loc ) ) {
|
|
358
|
+
links.set( loc, "/" + String( path ).replace( /\.md$/, "" ).replace( /\/index$/, "/" ) );
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if ( detection === "explicit" ) {
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
iter = iter.parent;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return Array.from( links.entries() )
|
|
372
|
+
.filter( ( [ loc ] ) => includeCurrent || loc !== locale )
|
|
373
|
+
.map( ( [ loc, href ] ) => ( {
|
|
374
|
+
locale: loc,
|
|
375
|
+
href,
|
|
376
|
+
current: loc === locale,
|
|
377
|
+
} ) );
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
hierarchy,
|
|
382
|
+
current,
|
|
383
|
+
currentLocale,
|
|
384
|
+
ancestorAtDepth,
|
|
385
|
+
isOnActivePath,
|
|
386
|
+
collectItems,
|
|
387
|
+
collectAncestors,
|
|
388
|
+
collectLocaleLinks,
|
|
389
|
+
resolveByPath,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { computed } from "vue";
|
|
2
|
+
import { SECTIONING_ELEMENTS } from "../lib/containers.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared composable for Scavold container components.
|
|
6
|
+
*
|
|
7
|
+
* Consumes the props emitted by the markdown-it container renderer and exposes
|
|
8
|
+
* derived values ready for binding in templates:
|
|
9
|
+
*
|
|
10
|
+
* - `rootTag` — the HTML element to use as root (sectioning element if
|
|
11
|
+
* the container name matches one, otherwise "div")
|
|
12
|
+
* - `containerName` — the original container name from markdown
|
|
13
|
+
* - `classes` — class string from boolean arguments
|
|
14
|
+
* - `dataAttrs` — object of data-* attribute key/value pairs
|
|
15
|
+
*
|
|
16
|
+
* Custom container components should declare the same props and call this
|
|
17
|
+
* composable to get consistent behaviour without duplicating logic.
|
|
18
|
+
*
|
|
19
|
+
* @param {import('vue').ToRefs<{class: string, [key: string]: any}>} props
|
|
20
|
+
* @returns {{ rootTag, containerName, classes, dataAttrs }}
|
|
21
|
+
*/
|
|
22
|
+
export function useContainer( props ) {
|
|
23
|
+
const containerName = computed( () => props.dataContainer ?? "" );
|
|
24
|
+
|
|
25
|
+
const rootTag = computed( () =>
|
|
26
|
+
SECTIONING_ELEMENTS.has( containerName.value ) ? containerName.value : "div"
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const classes = computed( () => props.class ?? "" );
|
|
30
|
+
|
|
31
|
+
const dataAttrs = computed( () => {
|
|
32
|
+
const attrs = {};
|
|
33
|
+
|
|
34
|
+
for ( const [ key, value ] of Object.entries( props ) ) {
|
|
35
|
+
if ( key.startsWith( "data-" ) ) {
|
|
36
|
+
attrs[key] = value;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return attrs;
|
|
41
|
+
} );
|
|
42
|
+
|
|
43
|
+
return { rootTag, containerName, classes, dataAttrs };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Props definition shared by all Scavold container components. Declare these
|
|
48
|
+
* via `defineProps( containerProps )` in any custom container component.
|
|
49
|
+
*/
|
|
50
|
+
export const containerProps = {
|
|
51
|
+
class: {
|
|
52
|
+
type: String,
|
|
53
|
+
default: "",
|
|
54
|
+
},
|
|
55
|
+
dataContainer: {
|
|
56
|
+
type: String,
|
|
57
|
+
default: "",
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useL10n } from "@cepharum/vue3-i18n";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Registers Scavold's built-in translations into the shared l10n context under
|
|
5
|
+
* the `@scavold` namespace. All keys are accessed as `@scavold.<key>`.
|
|
6
|
+
*
|
|
7
|
+
* Call once from enhanceApp before components mount. The consuming theme's own
|
|
8
|
+
* setLoader() (the default namespace) runs last per library convention, so any
|
|
9
|
+
* `@scavold.*` key can be overridden by including it in the theme's translation
|
|
10
|
+
* files.
|
|
11
|
+
*
|
|
12
|
+
* Safe to call multiple times — useL10n() returns the same shared context.
|
|
13
|
+
*/
|
|
14
|
+
export function registerScavoldI18n() {
|
|
15
|
+
useL10n().setNamespaceLoader( "@scavold", locale =>
|
|
16
|
+
import( `../l10n/${locale}.json` ).catch( () =>
|
|
17
|
+
import( "../l10n/en.json" )
|
|
18
|
+
)
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Composable for use inside Scavold components. Returns a reactive `t()`
|
|
24
|
+
* helper that looks up keys in the `@scavold` namespace.
|
|
25
|
+
*
|
|
26
|
+
* const { t } = useScavoldI18n();
|
|
27
|
+
* t("nav.breadcrumb") // resolves "@scavold.nav.breadcrumb"
|
|
28
|
+
*
|
|
29
|
+
* @returns {{ t: (key: string) => import('vue').ComputedRef<string> }}
|
|
30
|
+
*/
|
|
31
|
+
export function useScavoldI18n() {
|
|
32
|
+
const l10n = useL10n();
|
|
33
|
+
|
|
34
|
+
const t = key => l10n.translate( `@scavold.${key}` );
|
|
35
|
+
|
|
36
|
+
return { t };
|
|
37
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { computed } from "vue";
|
|
2
|
+
import { useData } from "vitepress";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Provides redirect-related state derived from the current page's frontmatter.
|
|
6
|
+
*
|
|
7
|
+
* @returns {{ isLocaleRedirect: import("vue").ComputedRef<boolean> }}
|
|
8
|
+
*/
|
|
9
|
+
export function useRedirect() {
|
|
10
|
+
const { frontmatter } = useData();
|
|
11
|
+
|
|
12
|
+
const isLocaleRedirect = computed( () => {
|
|
13
|
+
const r = frontmatter.value.redirect;
|
|
14
|
+
return r !== null && typeof r === "object" && !Array.isArray( r );
|
|
15
|
+
} );
|
|
16
|
+
|
|
17
|
+
const isSimpleRedirect = computed( () => typeof frontmatter.value.redirect === "string" );
|
|
18
|
+
|
|
19
|
+
return { isLocaleRedirect, isSimpleRedirect };
|
|
20
|
+
}
|