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/FRONTMATTER.md ADDED
@@ -0,0 +1,248 @@
1
+ # Scavold front matter reference
2
+
3
+ Front matter is YAML declared at the top of a Markdown file between `---` fences.
4
+ Scavold reads a set of well-known keys from every page's front matter at build time
5
+ to drive navigation, localisation, redirects, and URL aliasing. All keys are
6
+ optional unless noted otherwise.
7
+
8
+ ```yaml
9
+ ---
10
+ title: My page title
11
+ label: Short label
12
+ order: 10
13
+ locale: de
14
+ ---
15
+ ```
16
+
17
+ ---
18
+
19
+ ## `title`
20
+
21
+ **Type:** `string`
22
+
23
+ The display title for the page. Used in menus and breadcrumbs when no `label` is
24
+ set, and exposed as `node.title` on the hierarchy node.
25
+
26
+ When absent, Scavold falls back to the first `#` heading found in the page
27
+ body, then to the path segment if no heading exists either.
28
+
29
+ ```yaml
30
+ ---
31
+ title: Datenschutzerklärung
32
+ ---
33
+ ```
34
+
35
+ ---
36
+
37
+ ## `label`
38
+
39
+ **Type:** `string`
40
+
41
+ A short navigation label used in menus and breadcrumbs in place of `title`. Useful
42
+ when the full page title is too long for a navigation item.
43
+
44
+ When absent, menus fall back to `title`, then to the path segment.
45
+
46
+ ```yaml
47
+ ---
48
+ title: Allgemeine Geschäftsbedingungen
49
+ label: AGB
50
+ ---
51
+ ```
52
+
53
+ ---
54
+
55
+ ## `order`
56
+
57
+ **Type:** `number`
58
+
59
+ Controls the position of this page among its siblings in the hierarchy. Pages are
60
+ sorted by `order` ascending; pages without an `order` value follow in filename order
61
+ after all ordered pages.
62
+
63
+ Using multiples of 10 leaves room to insert pages later without renumbering
64
+ existing ones.
65
+
66
+ ```yaml
67
+ ---
68
+ order: 20
69
+ ---
70
+ ```
71
+
72
+ A section with mixed ordered and unordered pages:
73
+
74
+ ```
75
+ 10 → kontakt.md
76
+ 20 → leistungen/
77
+ (no order) → agb.md # follows after ordered pages, a–z
78
+ (no order) → impressum.md
79
+ ```
80
+
81
+ ---
82
+
83
+ ## `url`
84
+
85
+ **Type:** `string`
86
+
87
+ Overrides the public URL of the page. VitePress builds the page at this path
88
+ instead of the URL derived from the source file location. The source path never
89
+ appears in the browser's address bar.
90
+
91
+ The value is a root-relative path without a leading slash. The `.md` extension is
92
+ optional. Index-style paths (`de/impressum/`) are also accepted.
93
+
94
+ ```yaml
95
+ ---
96
+ url: de/impressum
97
+ ---
98
+ ```
99
+
100
+ For example, a page whose source lives under a `footer/` section would normally be
101
+ reachable at `/de/footer/impressum`. With the declaration above its URL becomes
102
+ `/de/impressum` — the `/footer/` segment is invisible to visitors.
103
+
104
+ Menu links and breadcrumbs produced by Scavold automatically use the alias URL.
105
+
106
+ **Conflict detection:** if two pages declare the same `url` value, the build aborts
107
+ with an error naming both conflicting files. Each alias must be unique across the
108
+ entire site.
109
+
110
+ ---
111
+
112
+ ## `locale` / `lang`
113
+
114
+ **Type:** `string` (BCP 47 language tag, e.g. `de`, `en`, `fr-CH`)
115
+
116
+ Declares the language of this page. The value is inherited by all pages in the same
117
+ section — setting it once on a section's `index.md` is enough for the whole
118
+ subtree. Individual pages can override the inherited value.
119
+
120
+ `locale` is the preferred key. `lang` is accepted as an alias for backwards
121
+ compatibility; when both are present, `locale` takes precedence.
122
+
123
+ ```yaml
124
+ ---
125
+ locale: de
126
+ ---
127
+ ```
128
+
129
+ Scavold uses the resolved locale to:
130
+
131
+ - drive the locale switcher (`ScavoldLocaleMenu`)
132
+ - select the correct branch in locale-conditional redirects
133
+ - resolve `{locale}` placeholders in `ScavoldMenu`'s `from-path` prop
134
+
135
+ ---
136
+
137
+ ## `translations`
138
+
139
+ **Type:** `object` — map of BCP 47 locale code → relative page path
140
+
141
+ Declares counterpart pages in other locales. Used by `ScavoldLocaleMenu` to offer
142
+ direct links to the same content in a different language.
143
+
144
+ ```yaml
145
+ ---
146
+ translations:
147
+ en: en/about.md
148
+ fr: fr/a-propos.md
149
+ ---
150
+ ```
151
+
152
+ Paths are relative to the project's pages folder. The `.md` extension is required.
153
+
154
+ **Automatic back-links:** when page A declares `translations.en: en/about.md`,
155
+ Scavold automatically adds the reverse entry (`translations.de: de/ueber-uns.md`)
156
+ to `en/about.md` at build time — provided that page has no explicit `translations`
157
+ entry for that locale already. This means you only need to declare the link on one
158
+ side.
159
+
160
+ If an explicit back-link on the target page points to a *different* page, Scavold
161
+ emits a warning and the explicit declaration takes precedence.
162
+
163
+ ---
164
+
165
+ ## `hide`
166
+
167
+ **Type:** `boolean | "menu" | "breadcrumb"`
168
+
169
+ Controls whether this page appears in menus, breadcrumbs, or both.
170
+
171
+ | Value | Effect |
172
+ |---|---|
173
+ | `false` or absent | Visible everywhere (default) |
174
+ | `true` | Hidden in both menus and breadcrumbs |
175
+ | `"menu"` | Hidden in menus only |
176
+ | `"breadcrumb"` | Hidden in breadcrumbs only |
177
+
178
+ ```yaml
179
+ ---
180
+ hide: true
181
+ ---
182
+ ```
183
+
184
+ ```yaml
185
+ ---
186
+ hide: menu
187
+ ---
188
+ ```
189
+
190
+ Pages hidden from menus are still reachable by direct URL and still appear as nodes
191
+ in the hierarchy tree — they are only excluded from rendered navigation.
192
+
193
+ ---
194
+
195
+ ## `redirect`
196
+
197
+ **Type:** `string | object`
198
+
199
+ Declares a redirect from this page to another URL. Two forms are supported:
200
+ unconditional and locale-conditional.
201
+
202
+ ### Unconditional redirect
203
+
204
+ A plain string value, or an object with only a `"*"` key, redirects all visitors
205
+ regardless of locale. The redirect is compiled into VitePress `rewrites` at build
206
+ time and handled server-side — the original URL is never served to the browser.
207
+
208
+ ```yaml
209
+ ---
210
+ redirect: other-page.md
211
+ ---
212
+ ```
213
+
214
+ ```yaml
215
+ ---
216
+ redirect:
217
+ "*": other-page.md
218
+ ---
219
+ ```
220
+
221
+ Paths are resolved relative to the current page's location.
222
+
223
+ ### Locale-conditional redirect
224
+
225
+ An object with locale keys routes visitors to different pages depending on the
226
+ language preferences reported by their browser (`navigator.languages`). This is
227
+ handled client-side on first render.
228
+
229
+ ```yaml
230
+ ---
231
+ redirect:
232
+ de: /de/
233
+ en: /en/
234
+ "*": /en/
235
+ ---
236
+ ```
237
+
238
+ Scavold matches the browser's preferred languages against the map keys in order and
239
+ calls `location.replace()` on the first match, replacing the current history entry
240
+ so the back button never loops back to the redirect page. The `"*"` key serves as a
241
+ catch-all fallback.
242
+
243
+ A `<noscript>` `<meta http-equiv="refresh">` element is injected for the `"*"`
244
+ target, covering browsers with JavaScript disabled.
245
+
246
+ Themes using `<ScavoldLayout>` as their root wrapper get locale redirect handling
247
+ automatically. Themes that manage their own layout root can use the `useRedirect()`
248
+ composable and `<ScavoldLocaleRedirect>` component directly — see `COMPONENTS.md`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cepharum GmbH, Germany
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # Scavold
2
+
3
+ Scavold is a VitePress theme framework — a scaffold for building custom VitePress
4
+ themes with Vue at the core. It handles the structural concerns common to every
5
+ content-driven website: page hierarchy, locale management, responsive images, and
6
+ custom containers for Markdown-driven structures on your pages such as galleries, grids etc.
7
+
8
+ ```sh
9
+ bun add vitepress vue scavold
10
+ ```
11
+
12
+ Scavold is currently published as a pre-release, so install it explicitly while
13
+ `latest` does not exist yet:
14
+
15
+ ```sh
16
+ bun add vitepress vue scavold@next
17
+ ```
18
+
19
+ Responsive image generation uses [sharp](https://sharp.pixelplumbing.com/), which
20
+ installs a platform-specific native binary — build environments therefore need to
21
+ be able to fetch it (Node 20 or newer).
22
+
23
+ Full documentation, including a quick-start guide, component and front matter
24
+ references, and deployment instructions, is available at
25
+
26
+ **[https://scavold.io](https://scavold.io)**.
@@ -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
+ <article :class="classes" v-bind="dataAttrs">
10
+ <slot />
11
+ </article>
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
+ <aside :class="classes" v-bind="dataAttrs">
10
+ <slot />
11
+ </aside>
12
+ </template>
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+ import { useHierarchy } from "../composables/hierarchy";
4
+ import { useScavoldI18n } from "../composables/useI18n.js";
5
+ import ScavoldMenuItems from "./ScavoldMenuItems.vue";
6
+ import type { Scavold } from "../index";
7
+
8
+ interface BreadcrumbProps {
9
+
10
+ /** Include the current page as the last crumb. Default: true. */
11
+ includeCurrent?: boolean;
12
+
13
+ /** Include the root node (typically "Home") as the first crumb. Default: false. */
14
+ includeRoot?: boolean;
15
+ }
16
+
17
+ const props = withDefaults( defineProps<BreadcrumbProps>(), {
18
+ includeCurrent: true,
19
+ includeRoot: false,
20
+ } );
21
+
22
+ const { collectAncestors } = useHierarchy();
23
+ const { t } = useScavoldI18n();
24
+
25
+ const items = computed<Scavold.MenuItem[]>( () =>
26
+ collectAncestors( props.includeCurrent, props.includeRoot )
27
+ );
28
+
29
+ const ariaLabel = t( "nav.breadcrumb" );
30
+ </script>
31
+
32
+ <template>
33
+ <nav v-if="items.length" class="breadcrumb" :aria-label="ariaLabel">
34
+ <ScavoldMenuItems :items="items" />
35
+ </nav>
36
+ </template>
@@ -0,0 +1,16 @@
1
+ <script setup>
2
+ import { useContainer, containerProps } from "../composables/useContainer.js";
3
+
4
+ const props = defineProps( containerProps );
5
+ const { rootTag, containerName, classes, dataAttrs } = useContainer( props );
6
+ </script>
7
+
8
+ <template>
9
+ <component
10
+ :is="rootTag"
11
+ :class="[containerName, classes]"
12
+ v-bind="dataAttrs"
13
+ >
14
+ <slot />
15
+ </component>
16
+ </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
+ <footer :class="classes" v-bind="dataAttrs">
10
+ <slot />
11
+ </footer>
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
+ <header :class="classes" v-bind="dataAttrs">
10
+ <slot />
11
+ </header>
12
+ </template>
@@ -0,0 +1,33 @@
1
+ <script setup>
2
+ defineProps( {
3
+ src: { type: String, required: true },
4
+ srcset: { type: String, default: "" },
5
+ webpSrcset: { type: String, default: "" },
6
+ sizes: { type: String, default: "100vw" },
7
+ alt: { type: String, default: "" },
8
+ } );
9
+ </script>
10
+
11
+ <template>
12
+ <picture>
13
+ <source
14
+ v-if="webpSrcset"
15
+ type="image/webp"
16
+ :srcset="webpSrcset"
17
+ :sizes="sizes"
18
+ />
19
+ <source
20
+ v-if="srcset"
21
+ :srcset="srcset"
22
+ :sizes="sizes"
23
+ />
24
+ <img
25
+ :src="src"
26
+ :alt="alt"
27
+ :role="alt ? undefined : 'presentation'"
28
+ :sizes="sizes"
29
+ loading="lazy"
30
+ decoding="async"
31
+ />
32
+ </picture>
33
+ </template>
@@ -0,0 +1,21 @@
1
+ <script setup>
2
+ import { watchEffect } from "vue";
3
+ import { useL10n } from "@cepharum/vue3-i18n";
4
+ import { useRedirect } from "../composables/useRedirect.js";
5
+ import { useHierarchy } from "../composables/hierarchy";
6
+ import ScavoldLocaleRedirect from "./ScavoldLocaleRedirect.vue";
7
+ import ScavoldSimpleRedirect from "./ScavoldSimpleRedirect.vue";
8
+
9
+ const { isLocaleRedirect, isSimpleRedirect } = useRedirect();
10
+ const { currentLocale } = useHierarchy();
11
+
12
+ watchEffect( () => {
13
+ useL10n().setLocale( currentLocale.value );
14
+ } );
15
+ </script>
16
+
17
+ <template>
18
+ <ScavoldLocaleRedirect v-if="isLocaleRedirect" />
19
+ <ScavoldSimpleRedirect v-else-if="isSimpleRedirect" />
20
+ <slot v-else />
21
+ </template>
@@ -0,0 +1,86 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+ import { useHierarchy } from "../composables/hierarchy";
4
+ import { useScavoldI18n } from "../composables/useI18n.js";
5
+ import type { Scavold } from "../index";
6
+
7
+ interface LocaleMenuProps {
8
+
9
+ /**
10
+ * How to discover available locales.
11
+ *
12
+ * "auto" — try explicit, then inherited, then global; use first non-empty result (default).
13
+ * "explicit" — only locales listed in the current page's own `translations` frontmatter.
14
+ * "inherited" — explicit translations of current page plus any ancestor's `translations`.
15
+ * "global" — every locale present anywhere in the page hierarchy; link leads to the
16
+ * topmost page of that locale.
17
+ */
18
+ detection?: Scavold.LocaleDetection;
19
+
20
+ /**
21
+ * When true, include the current locale as a non-navigating item in the list.
22
+ * Useful when the locale switcher always shows the full set of locales.
23
+ * Default: false
24
+ */
25
+ includeCurrent?: boolean;
26
+
27
+ /**
28
+ * When true, render nothing if only one locale is available (or none after
29
+ * filtering). Default: true
30
+ */
31
+ hideIfSingle?: boolean;
32
+
33
+ /** Accessible label for the nav landmark. */
34
+ label?: string;
35
+ }
36
+
37
+ const props = withDefaults( defineProps<LocaleMenuProps>(), {
38
+ detection: "auto",
39
+ includeCurrent: false,
40
+ hideIfSingle: true,
41
+ label: undefined,
42
+ } );
43
+
44
+ const { collectLocaleLinks, currentLocale } = useHierarchy();
45
+ const { t } = useScavoldI18n();
46
+
47
+ const links = computed<Scavold.LocaleLink[]>( () =>
48
+ collectLocaleLinks( props.detection, props.includeCurrent )
49
+ );
50
+
51
+ const visible = computed( () => {
52
+ if ( props.hideIfSingle && links.value.length <= 1 ) {
53
+ return false;
54
+ }
55
+
56
+ return links.value.length > 0;
57
+ } );
58
+
59
+ const navLabel = computed( () => props.label ?? t( "nav.locale" ).value );
60
+ </script>
61
+
62
+ <template>
63
+ <nav v-if="visible" class="locale-nav" :aria-label="navLabel">
64
+ <ul>
65
+ <li
66
+ v-for="link of links"
67
+ :key="link.locale"
68
+ :class="{
69
+ current: link.current,
70
+ }"
71
+ :lang="link.locale"
72
+ >
73
+ <a
74
+ v-if="!link.current"
75
+ :href="link.href"
76
+ :hreflang="link.locale"
77
+ :aria-label="link.locale"
78
+ >{{ link.locale }}</a>
79
+ <span
80
+ v-else
81
+ :aria-current="'true'"
82
+ >{{ link.locale }}</span>
83
+ </li>
84
+ </ul>
85
+ </nav>
86
+ </template>
@@ -0,0 +1,47 @@
1
+ <script setup>
2
+ import { useData } from "vitepress";
3
+ import { onMounted } from "vue";
4
+ import { isExternalUrl, servableRedirectTarget } from "../lib/redirectTarget.js";
5
+
6
+ const { frontmatter } = useData();
7
+
8
+ /**
9
+ * Picks the best redirect target from the locale map in frontmatter.redirect.
10
+ * Tries each language in navigator.languages in order; falls back to "*".
11
+ *
12
+ * @param {Record<string,string>} map
13
+ * @returns {string|null}
14
+ */
15
+ function pickTarget( map ) {
16
+ for ( const lang of ( navigator.languages ?? [] ) ) {
17
+ // exact match first (e.g. "de-AT" → "de-AT")
18
+ if ( map[lang] ) return map[lang];
19
+ // prefix match (e.g. "de-AT" → "de")
20
+ const prefix = lang.split( "-" )[0];
21
+ if ( map[prefix] ) return map[prefix];
22
+ }
23
+ return map["*"] ?? null;
24
+ }
25
+
26
+ onMounted( () => {
27
+ const map = frontmatter.value.redirect;
28
+ if ( !map || typeof map !== "object" ) return;
29
+ const target = pickTarget( map );
30
+ if ( !target ) return;
31
+ if ( isExternalUrl( target ) ) {
32
+ // External locale targets: open in a new tab. The locale is resolved at
33
+ // runtime so build-time <meta http-equiv="refresh"> is not an option here.
34
+ // The click interceptor in lib/index.js does not cover locale-redirect
35
+ // pages either (the map only contains simple-string redirects), so
36
+ // window.open() is the correct call. It is invoked from onMounted which
37
+ // is async relative to the nav click, but modern browsers propagate
38
+ // user-activation through the Promise chain long enough for this to work
39
+ // in practice; popup-blocked fallback is acceptable for a locale redirect.
40
+ window.open( target, "_blank", "noopener,noreferrer" );
41
+ } else {
42
+ location.replace( servableRedirectTarget( target ) );
43
+ }
44
+ } );
45
+ </script>
46
+
47
+ <template><!-- redirect handled in onMounted --></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
+ <main :class="classes" v-bind="dataAttrs">
10
+ <slot />
11
+ </main>
12
+ </template>
@@ -0,0 +1,82 @@
1
+ <script setup lang="ts">
2
+ import { computed } from "vue";
3
+ import { useHierarchy } from "../composables/hierarchy";
4
+ import ScavoldMenuItems from "./ScavoldMenuItems.vue";
5
+ import type { Scavold } from "../index";
6
+
7
+ interface MenuProps {
8
+
9
+ /** Selects the parent node by path, supporting a `{locale}` placeholder that is
10
+ * replaced with the current page's locale at runtime. Takes priority over
11
+ * `fromRoot` and `from` when set. Examples: `"de/footer"`, `"{locale}/footer"`. */
12
+ fromPath?: string;
13
+
14
+ /** Absolute depth from root. 1 = top-level pages, 2 = second level, etc.
15
+ * When set, `from` is ignored. Nothing rendered if current page has no ancestor at this depth.
16
+ * Ignored when fromPath is set. */
17
+ fromRoot?: number;
18
+
19
+ /** Depth relative to current page. 0 = siblings (default), 1 = children,
20
+ * -1 = aunt/uncle level, etc. Ignored when fromRoot or fromPath is set. */
21
+ from?: number;
22
+
23
+ /** Additional levels to descend. 0 = flat list (default), 1 = one level of children, -1 = unlimited. */
24
+ depth?: number;
25
+
26
+ /** Expand only the branch leading to the current page. Useful with depth > 0. */
27
+ activeOnly?: boolean;
28
+
29
+ /** Expand all nodes regardless of active branch. Enables sitemap-style rendering.
30
+ * activeOnly takes precedence when both are set. */
31
+ expand?: boolean;
32
+
33
+ /** Accessible label for the nav landmark. Required when multiple ScavoldMenu
34
+ * instances appear on the same page so screen readers can distinguish them. */
35
+ label?: string;
36
+ }
37
+
38
+ const props = withDefaults( defineProps<MenuProps>(), {
39
+ fromPath: undefined,
40
+ fromRoot: undefined,
41
+ from: 0,
42
+ depth: 0,
43
+ activeOnly: false,
44
+ expand: false,
45
+ label: undefined,
46
+ } );
47
+
48
+ const { current, ancestorAtDepth, collectItems, resolveByPath } = useHierarchy();
49
+
50
+ const items = computed<Scavold.MenuItem[]>( () => {
51
+ let parent: Scavold.HierarchyNode | undefined;
52
+
53
+ if ( props.fromPath !== undefined ) {
54
+ parent = resolveByPath( props.fromPath );
55
+ } else if ( props.fromRoot !== undefined ) {
56
+ parent = ancestorAtDepth( current.value, props.fromRoot );
57
+ } else {
58
+ let iter: Scavold.HierarchyNode | undefined = current.value;
59
+ const steps = -props.from;
60
+
61
+ if ( steps > 0 ) {
62
+ for ( let i = 0; i < steps && iter?.parent; i++ ) {
63
+ iter = iter.parent;
64
+ }
65
+
66
+ parent = iter?.parent;
67
+ } else if ( steps === 0 ) {
68
+ parent = iter?.parent;
69
+ } else {
70
+ parent = iter;
71
+ }
72
+ }
73
+
74
+ return collectItems( parent, props.depth, props.activeOnly, props.expand );
75
+ } );
76
+ </script>
77
+
78
+ <template>
79
+ <nav v-if="items.length" :aria-label="label || undefined">
80
+ <ScavoldMenuItems :items="items" />
81
+ </nav>
82
+ </template>