@osimart/create-storefront 1.0.9 → 1.0.12
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/app/composables/useMenu.js +7 -3
- package/app/composables/useProductUrl.js +1 -1
- package/app/logic/components/SiteFooter.js +23 -2
- package/app/logic/sections/GallerySection.js +65 -5
- package/app/pages/[category]/[product].vue +1 -1
- package/app/pages/[handle].vue +82 -0
- package/app/pages/pages/[handle].vue +16 -54
- package/package.json +1 -1
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* /categories/<slug> CLASSIFICATION (resource.type = CATEGORY)
|
|
14
14
|
* /collections/<slug> CLASSIFICATION (resource.type = COLLECTION)
|
|
15
15
|
* /products/<slug> PRODUCT
|
|
16
|
-
* /pages/<handle>
|
|
16
|
+
* /<handle> PAGE (one segment; /pages/<handle> 301s here)
|
|
17
17
|
* /search SEARCH
|
|
18
18
|
* /cart CART
|
|
19
19
|
* /account ACCOUNT
|
|
@@ -45,7 +45,7 @@ export function useMenu() {
|
|
|
45
45
|
return `/products/${relation.product.slugified_name}`;
|
|
46
46
|
}
|
|
47
47
|
if (relation.page?.handle) {
|
|
48
|
-
return
|
|
48
|
+
return `/${relation.page.handle}`;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
return "/";
|
|
@@ -63,7 +63,11 @@ export function useMenu() {
|
|
|
63
63
|
*/
|
|
64
64
|
function normalizeItems(items = [], parentId = "m") {
|
|
65
65
|
return items.map((item, index) => {
|
|
66
|
-
|
|
66
|
+
// An External URL left empty links nowhere: "#", which every menu draws
|
|
67
|
+
// as a toggle for its sub-items (or a plain label), never a link.
|
|
68
|
+
// itemUrl() alone would fall through to "/".
|
|
69
|
+
const noLink = item.link_type === "URL" && !String(item.url ?? "").trim();
|
|
70
|
+
const url = noLink ? "#" : itemUrl(item);
|
|
67
71
|
const id = item.id ?? `${parentId}.${index}`;
|
|
68
72
|
return {
|
|
69
73
|
id,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*
|
|
18
18
|
* /categories/<slug> CLASSIFICATION (already matched)
|
|
19
19
|
* /collections/<slug> CLASSIFICATION (already matched)
|
|
20
|
-
*
|
|
20
|
+
* /<handle> PAGE (one segment only)
|
|
21
21
|
* /<category>/<slug> PRODUCT ← needs adding, matched LAST so it
|
|
22
22
|
* can't shadow the prefixes above
|
|
23
23
|
*
|
|
@@ -336,6 +336,27 @@ export default function useLogic({ props }) {
|
|
|
336
336
|
...(bottomThreeZone.value || bottomStacked.value ? {} : { justifyContent: bottomAlignment.value }),
|
|
337
337
|
}));
|
|
338
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Utilities drawn from the block tree (FooterUtilitiesSection's barTree):
|
|
341
|
+
* whether a part has anything to show. A Group shows while any part inside
|
|
342
|
+
* it does, so separators and empty groups never dangle.
|
|
343
|
+
*/
|
|
344
|
+
function partShown(node) {
|
|
345
|
+
if (!node) return false;
|
|
346
|
+
switch (node.type) {
|
|
347
|
+
case "GROUP":
|
|
348
|
+
return (node.children ?? []).some(partShown);
|
|
349
|
+
case "UTILITY_COPYRIGHT":
|
|
350
|
+
return true;
|
|
351
|
+
case "UTILITY_POLICIES":
|
|
352
|
+
return policyLinks.value.length > 0;
|
|
353
|
+
case "UTILITY_SOCIAL":
|
|
354
|
+
return (unref(socials)?.length ?? 0) > 0;
|
|
355
|
+
default:
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
339
360
|
const showPoweredBy = computed(() => settings.value.FOOTER_SHOW_POWERED_BY !== false);
|
|
340
361
|
const poweredByText = computed(() => t(settings.value.FOOTER_POWERED_BY_TEXT));
|
|
341
362
|
// Plain text between the copyright and the link — not part of the link.
|
|
@@ -472,5 +493,5 @@ export default function useLogic({ props }) {
|
|
|
472
493
|
const headingTextStyle = computed(() => textStyleVars("FOOTER_HEADING", "UPPERCASE"));
|
|
473
494
|
const linkTextStyle = computed(() => textStyleVars("FOOTER_LINK", "NONE"));
|
|
474
495
|
|
|
475
|
-
return { isBar, utilityBlocks, settings, brand, pick, barScheme, scheme, appearance, store, storeLogo, mediaUrl, useMenuByHandle, t, menu1, menu2, menu3, columnWidthMode, columnFlexBasis, columns, logoSrc, logoPosition, logoAlignment, logoWidthVars, showBrandDescription, DESCRIPTION_ALIGNMENTS, brandDescriptionStyle, footerText, copyright, socials, socialsEnabled, socialPosition, paymentIcons, languages, countries, currentLanguage, currentCountry, showSocial, socialAlignment, showNewsletter, newsletterOnStart, newsletterStacked, newsletterAlignment, showConsent, newsletterSubmitted, handleNewsletterSubmit, newsletterSuccessMessage, showPaymentIcons, showLanguageSelector, showCountrySelector, containerStyle, footerStyle, columnsAlignment, columnsVerticalAlignment, columnTextAlignment, JUSTIFY, ALIGN, TEXT, bottomAlignment, showBottomBarDivider, bottomThreeZone, bottomStacked, px, barVars, policyClasses, policyStyle, copyrightStyle, orderStyle, barStyle, showPoweredBy, poweredByText, poweredByPrefix, copyrightGap, poweredByUrl, POLICY_SLOTS, policyLinks, socialsInBar, socialsInColumns, socialsUnderNewsletter, hasFooterContent, mobileAccordion, openColumns, toggleColumn, isColumnOpen, FONT_WEIGHTS, FONT_FAMILIES, TEXT_TRANSFORMS, textStyleVars, headingTextStyle, linkTextStyle };
|
|
476
|
-
}
|
|
496
|
+
return { isBar, utilityBlocks, settings, brand, pick, barScheme, scheme, appearance, store, storeLogo, mediaUrl, useMenuByHandle, t, menu1, menu2, menu3, columnWidthMode, columnFlexBasis, columns, logoSrc, logoPosition, logoAlignment, logoWidthVars, showBrandDescription, DESCRIPTION_ALIGNMENTS, brandDescriptionStyle, footerText, copyright, socials, socialsEnabled, socialPosition, paymentIcons, languages, countries, currentLanguage, currentCountry, showSocial, socialAlignment, showNewsletter, newsletterOnStart, newsletterStacked, newsletterAlignment, showConsent, newsletterSubmitted, handleNewsletterSubmit, newsletterSuccessMessage, showPaymentIcons, showLanguageSelector, showCountrySelector, containerStyle, footerStyle, columnsAlignment, columnsVerticalAlignment, columnTextAlignment, JUSTIFY, ALIGN, TEXT, bottomAlignment, showBottomBarDivider, bottomThreeZone, bottomStacked, px, barVars, policyClasses, policyStyle, copyrightStyle, orderStyle, barStyle, showPoweredBy, poweredByText, poweredByPrefix, copyrightGap, poweredByUrl, POLICY_SLOTS, policyLinks, socialsInBar, socialsInColumns, socialsUnderNewsletter, hasFooterContent, partShown, mobileAccordion, openColumns, toggleColumn, isColumnOpen, FONT_WEIGHTS, FONT_FAMILIES, TEXT_TRANSFORMS, textStyleVars, headingTextStyle, linkTextStyle };
|
|
497
|
+
}
|
|
@@ -1,9 +1,67 @@
|
|
|
1
1
|
import { NuxtLink } from "#components";
|
|
2
2
|
import { Autoplay, Pagination } from "swiper/modules";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* GALLERY, Shopify-style:
|
|
6
|
+
*
|
|
7
|
+
* section the frame — width, padding, colours, CONTENT_GAP
|
|
8
|
+
* header blocks ordinary blocks (a Group of Text / Button…), drawn by
|
|
9
|
+
* ContentNode wherever they sit
|
|
10
|
+
* Images block GALLERY_CONTENT: the layout (grid / masonry /
|
|
11
|
+
* carousel / marquee + every image setting) and, inside
|
|
12
|
+
* it, the Image (MEDIA) blocks. Where it sits among the
|
|
13
|
+
* blocks is where the images go; hiding it hides them.
|
|
14
|
+
*
|
|
15
|
+
* An older section (no Images block: heading and layout on the section,
|
|
16
|
+
* images at the top level) renders exactly as before.
|
|
17
|
+
*/
|
|
4
18
|
export default function useLogic({ props }) {
|
|
5
|
-
const { s, t, shellStyle, innerStyle, align, customClass, heading } =
|
|
19
|
+
const { s: sectionSettings, t, shellStyle, innerStyle, align, customClass, heading: legacyHeadingText } =
|
|
20
|
+
useSectionShell(() => props.section);
|
|
6
21
|
const { mediaUrl } = useMediaUrl();
|
|
22
|
+
|
|
23
|
+
const blocks = computed(() => props.section.blocks ?? []);
|
|
24
|
+
const contentBlock = computed(() => blocks.value.find((b) => b.type === "GALLERY_CONTENT") ?? null);
|
|
25
|
+
const legacy = computed(() => !contentBlock.value);
|
|
26
|
+
|
|
27
|
+
/** The gallery's own settings: the Images block's, or (older) the section's. */
|
|
28
|
+
const s = computed(() => (legacy.value ? sectionSettings.value : contentBlock.value.settings ?? {}));
|
|
29
|
+
|
|
30
|
+
/** The image blocks, in order. */
|
|
31
|
+
const mediaBlocks = computed(() => {
|
|
32
|
+
if (legacy.value) return blocks.value.filter((b) => b.type === "MEDIA" && !b.parent_key);
|
|
33
|
+
const key = contentBlock.value.key;
|
|
34
|
+
return blocks.value.filter((b) => b.type === "MEDIA" && b.parent_key === key);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// The header and anything else around the images: ordinary blocks. The
|
|
38
|
+
// Images block stays a plain node (raw) — this section draws it.
|
|
39
|
+
const { tree } = useContentTree(() => props.section, {
|
|
40
|
+
raw: ["GALLERY_CONTENT"],
|
|
41
|
+
rootDefaults: { DIRECTION: "VERTICAL", GAP: 24 },
|
|
42
|
+
index: () => props.index,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** The page, in block order; the images go where the Images block is. */
|
|
46
|
+
const parts = computed(() => {
|
|
47
|
+
if (legacy.value) return [{ kind: "gallery", key: "gallery" }];
|
|
48
|
+
return tree.value.children.map((node) =>
|
|
49
|
+
node.type === "GALLERY_CONTENT"
|
|
50
|
+
? { kind: "gallery", key: node.key }
|
|
51
|
+
: { kind: "block", key: node.key, node }
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** data-osi-block for the images area (the editor outlines / selects it). */
|
|
56
|
+
const contentKey = computed(() => contentBlock.value?.key || undefined);
|
|
57
|
+
|
|
58
|
+
const stackGap = computed(() => {
|
|
59
|
+
const n = Number(sectionSettings.value.CONTENT_GAP);
|
|
60
|
+
return `${Number.isFinite(n) && sectionSettings.value.CONTENT_GAP !== "" ? n : 24}px`;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Older sections: their HEADING / SUBHEADING, until they're converted.
|
|
64
|
+
const heading = computed(() => (legacy.value ? legacyHeadingText.value : ""));
|
|
7
65
|
const placeholders = usePlaceholders();
|
|
8
66
|
const NuxtLinkComponent = NuxtLink;
|
|
9
67
|
|
|
@@ -16,13 +74,15 @@ export default function useLogic({ props }) {
|
|
|
16
74
|
const TILE_SIZES = ["NORMAL", "TALL", "WIDE", "LARGE"];
|
|
17
75
|
|
|
18
76
|
const items = computed(() => {
|
|
19
|
-
const rows =
|
|
77
|
+
const rows = mediaBlocks.value
|
|
20
78
|
.filter((block) => !block.disabled)
|
|
21
79
|
.map((block) => {
|
|
22
80
|
const bs = block.settings ?? {};
|
|
23
81
|
const media = bs.MEDIA ?? block.media ?? block.relations?.media ?? bs.IMAGE ?? null;
|
|
24
82
|
return {
|
|
25
83
|
key: block.id ?? block.key,
|
|
84
|
+
// The block's key, for the editor overlay (data-osi-block).
|
|
85
|
+
blockKey: block.key || undefined,
|
|
26
86
|
src: mediaUrl(media),
|
|
27
87
|
caption: t(bs.CAPTION) || "",
|
|
28
88
|
url: bs.URL || bs.LINK_URL || null,
|
|
@@ -115,7 +175,7 @@ export default function useLogic({ props }) {
|
|
|
115
175
|
() => layout.value === "GRID" && !isNatural.value && items.value.some((i) => i.size !== "NORMAL")
|
|
116
176
|
);
|
|
117
177
|
|
|
118
|
-
const subheading = computed(() => t(
|
|
178
|
+
const subheading = computed(() => (legacy.value ? t(sectionSettings.value.SUBHEADING) : ""));
|
|
119
179
|
const showCaptions = computed(() => !!s.value.SHOW_CAPTIONS);
|
|
120
180
|
const captionOverlay = computed(() => String(s.value.CAPTION_POSITION || "BELOW").toUpperCase() === "OVERLAY");
|
|
121
181
|
|
|
@@ -228,5 +288,5 @@ export default function useLogic({ props }) {
|
|
|
228
288
|
|
|
229
289
|
const current = computed(() => (openIndex.value === null ? null : realItems.value[openIndex.value]));
|
|
230
290
|
|
|
231
|
-
return { s, t, shellStyle, innerStyle, align, customClass, heading, mediaUrl, placeholders, NuxtLinkComponent, has, num, px, TILE_SIZES, items, layout, marqueeTrack, marqueeVars, MQ_ALIGN, marqueeMode, marqueeSizeBy, marqueeWidths, marqueeReverse, marqueePause, RATIOS, RATIO_PARTS, TILE_SPANS, isNatural, bento, subheading, showCaptions, captionOverlay, vars, rootClasses, perView, overflows, loop, slides, onSwiper, onSlideChange, goPrev, goNext, isBeginning, isEnd, breakpoints, autoplay, showArrows, showDots, lightboxEnabled, realItems, openIndex, isOpen, tagFor, open, close, step, onKeydown, current, Autoplay, Pagination };
|
|
232
|
-
}
|
|
291
|
+
return { legacy, contentBlock, contentKey, parts, stackGap, s, t, shellStyle, innerStyle, align, customClass, heading, mediaUrl, placeholders, NuxtLinkComponent, has, num, px, TILE_SIZES, items, layout, marqueeTrack, marqueeVars, MQ_ALIGN, marqueeMode, marqueeSizeBy, marqueeWidths, marqueeReverse, marqueePause, RATIOS, RATIO_PARTS, TILE_SPANS, isNatural, bento, subheading, showCaptions, captionOverlay, vars, rootClasses, perView, overflows, loop, slides, onSwiper, onSlideChange, goPrev, goNext, isBeginning, isEnd, breakpoints, autoplay, showArrows, showDots, lightboxEnabled, realItems, openIndex, isOpen, tagFor, open, close, step, onKeydown, current, Autoplay, Pagination };
|
|
292
|
+
}
|
|
@@ -14,7 +14,7 @@ import { useProductResource } from "#osi/composables/useProductDetail";
|
|
|
14
14
|
*
|
|
15
15
|
* /categories/<slug> CLASSIFICATION
|
|
16
16
|
* /collections/<slug> CLASSIFICATION
|
|
17
|
-
*
|
|
17
|
+
* /<handle> PAGE (one segment, never meets this route)
|
|
18
18
|
* /products/<slug> PRODUCT (keep — uncategorised products use it)
|
|
19
19
|
* /<category>/<slug> PRODUCT ← last
|
|
20
20
|
*
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<SectionRenderer v-if="data?.sections?.length" :sections="data.sections" />
|
|
4
|
+
|
|
5
|
+
<div v-else-if="!error" class="osi-empty">
|
|
6
|
+
<p>This page hasn't been set up yet.</p>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div v-else class="osi-empty">
|
|
10
|
+
<p>We couldn't load this page.</p>
|
|
11
|
+
<pre v-if="isDev" class="osi-empty-detail">{{ error.message }}</pre>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
</template>
|
|
15
|
+
<script setup>
|
|
16
|
+
/**
|
|
17
|
+
* A merchant page — `/<handle>` (Shopify's /pages/<handle>, without the prefix).
|
|
18
|
+
*
|
|
19
|
+
* One segment only. Nuxt matches static routes first (checkout, login,
|
|
20
|
+
* search…), then this, then [...slug] — so a platform route always wins, and
|
|
21
|
+
* the backend refuses page handles that would collide with one
|
|
22
|
+
* (RESERVED_HANDLES).
|
|
23
|
+
*
|
|
24
|
+
* Because this catches every unknown one-segment path (/whatever), only a PAGE
|
|
25
|
+
* payload counts. Anything else — NOT_FOUND, a 404 — is a real 404, drawn by
|
|
26
|
+
* error.vue with the theme's 404 page and a 404 status.
|
|
27
|
+
*
|
|
28
|
+
* The old /pages/<handle> URLs 301 here (pages/pages/[handle].vue).
|
|
29
|
+
*/
|
|
30
|
+
import { useRenderContext } from '#osi/composables/useRenderContext';
|
|
31
|
+
|
|
32
|
+
definePageMeta({ name: 'page' });
|
|
33
|
+
|
|
34
|
+
const route = useRoute();
|
|
35
|
+
const { render, store } = useStorefront();
|
|
36
|
+
const { setRenderContext, resource } = useRenderContext();
|
|
37
|
+
const origin = useRequestURL().origin;
|
|
38
|
+
|
|
39
|
+
const { data, error } = await useAsyncData(
|
|
40
|
+
`render:${route.path}`,
|
|
41
|
+
() => render(route.path)
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const templateName = (payload) => {
|
|
45
|
+
const t = payload?.template;
|
|
46
|
+
return String((t && typeof t === 'object' ? t.name : t) ?? '').toUpperCase();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const status = error.value?.status ?? error.value?.statusCode;
|
|
50
|
+
const isPage = !!data.value && templateName(data.value) === 'PAGE';
|
|
51
|
+
|
|
52
|
+
if (status === 404 || (!error.value && !isPage)) {
|
|
53
|
+
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setRenderContext(data.value);
|
|
57
|
+
watch(data, (payload) => setRenderContext(payload));
|
|
58
|
+
|
|
59
|
+
const isDev = import.meta.dev;
|
|
60
|
+
|
|
61
|
+
const seo = computed(() => data.value?.seo ?? {});
|
|
62
|
+
|
|
63
|
+
useHead({
|
|
64
|
+
title: () => seo.value.title_full ?? seo.value.title ?? resource.value?.name ?? store.value?.name ?? '',
|
|
65
|
+
titleTemplate: (title) => title ?? '',
|
|
66
|
+
meta: () => [
|
|
67
|
+
{ name: 'description', content: seo.value.description ?? undefined },
|
|
68
|
+
{ property: 'og:title', content: seo.value.title_full ?? seo.value.title ?? undefined },
|
|
69
|
+
{ property: 'og:description', content: seo.value.description ?? undefined },
|
|
70
|
+
{ property: 'og:image', content: seo.value.image ?? undefined },
|
|
71
|
+
{ name: 'robots', content: seo.value.no_index ? 'noindex, nofollow' : 'index, follow' },
|
|
72
|
+
],
|
|
73
|
+
link: () => seo.value.canonical_path
|
|
74
|
+
? [{ rel: 'canonical', href: `${origin}${seo.value.canonical_path}` }]
|
|
75
|
+
: [],
|
|
76
|
+
});
|
|
77
|
+
</script>
|
|
78
|
+
|
|
79
|
+
<style scoped>
|
|
80
|
+
.osi-empty { padding: 6rem 1rem; text-align: center; color: var(--osi-text); opacity: 0.7; }
|
|
81
|
+
.osi-empty-detail { margin-top: 1rem; font-size: 12px; white-space: pre-wrap; }
|
|
82
|
+
</style>
|
|
@@ -1,58 +1,20 @@
|
|
|
1
|
-
<template>
|
|
2
|
-
<div>
|
|
3
|
-
<SectionRenderer v-if="data?.sections?.length" :sections="data.sections" />
|
|
4
|
-
|
|
5
|
-
<div v-else-if="!error" class="osi-empty">
|
|
6
|
-
<p>This page hasn't been set up yet.</p>
|
|
7
|
-
</div>
|
|
8
|
-
|
|
9
|
-
<div v-else class="osi-empty">
|
|
10
|
-
<p>We couldn't load this page.</p>
|
|
11
|
-
<pre v-if="isDev" class="osi-empty-detail">{{ error.message }}</pre>
|
|
12
|
-
</div>
|
|
13
|
-
</div>
|
|
14
|
-
</template>
|
|
15
1
|
<script setup>
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
() =>
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
setRenderContext(data.value);
|
|
32
|
-
watch(data, (payload) => setRenderContext(payload));
|
|
33
|
-
|
|
34
|
-
const isDev = import.meta.dev;
|
|
35
|
-
|
|
36
|
-
const seo = computed(() => data.value?.seo ?? {});
|
|
37
|
-
const origin = useRequestURL().origin;
|
|
38
|
-
|
|
39
|
-
useHead({
|
|
40
|
-
title: () => seo.value.title_full ?? seo.value.title ?? resource.value?.name ?? store.value?.name ?? '',
|
|
41
|
-
titleTemplate: (title) => title ?? '',
|
|
42
|
-
meta: () => [
|
|
43
|
-
{ name: 'description', content: seo.value.description ?? undefined },
|
|
44
|
-
{ property: 'og:title', content: seo.value.title_full ?? seo.value.title ?? undefined },
|
|
45
|
-
{ property: 'og:description', content: seo.value.description ?? undefined },
|
|
46
|
-
{ property: 'og:image', content: seo.value.image ?? undefined },
|
|
47
|
-
{ name: 'robots', content: seo.value.no_index ? 'noindex, nofollow' : 'index, follow' },
|
|
48
|
-
],
|
|
49
|
-
link: () => seo.value.canonical_path
|
|
50
|
-
? [{ rel: 'canonical', href: `${origin}${seo.value.canonical_path}` }]
|
|
51
|
-
: [],
|
|
2
|
+
/**
|
|
3
|
+
* Old page URL — `/pages/<handle>` → `/<handle>`, 301.
|
|
4
|
+
*
|
|
5
|
+
* Kept so links already shared, indexed or saved in a menu's custom URL keep
|
|
6
|
+
* working. The redirect happens in route middleware, before anything renders,
|
|
7
|
+
* so the server answers with the 301 itself (good for search engines).
|
|
8
|
+
*/
|
|
9
|
+
definePageMeta({
|
|
10
|
+
middleware: (to) =>
|
|
11
|
+
navigateTo(
|
|
12
|
+
{ path: `/${to.params.handle}`, query: to.query, hash: to.hash },
|
|
13
|
+
{ redirectCode: 301 }
|
|
14
|
+
),
|
|
52
15
|
});
|
|
53
16
|
</script>
|
|
54
17
|
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
</style>
|
|
18
|
+
<template>
|
|
19
|
+
<div />
|
|
20
|
+
</template>
|
package/package.json
CHANGED