@frigear-nu/base 0.0.0-stage → 0.0.3
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/README.md +89 -2
- package/app/app.config.ts +14 -0
- package/app/app.d.ts +27 -0
- package/app/app.vue +21 -0
- package/app/assets/css/main.css +29 -0
- package/app/components/App/AppFooter.vue +32 -0
- package/app/components/App/AppFooterLeft.vue +71 -0
- package/app/components/App/AppFooterRight.vue +60 -0
- package/app/components/App/AppHeader.vue +36 -0
- package/app/components/App/AppHeaderBody.vue +44 -0
- package/app/components/App/AppHeaderCTA.vue +3 -0
- package/app/components/App/AppHeaderCenter.vue +32 -0
- package/app/components/App/AppHeaderLogo.vue +6 -0
- package/app/components/App/AppHeaderRight.vue +7 -0
- package/app/components/App/AppLogo.vue +23 -0
- package/app/components/IconMenuToggle.vue +83 -0
- package/app/components/LanguageSelect.vue +65 -0
- package/app/components/content/ContactForm.vue +161 -0
- package/app/components/content/HeroBackground.vue +103 -0
- package/app/composables/useConfig.ts +12 -0
- package/app/composables/useContentLocale.ts +70 -0
- package/app/composables/useCurrentPage.ts +17 -0
- package/app/composables/useNavigation.ts +13 -0
- package/app/composables/usePageSeo.ts +52 -0
- package/app/layouts/default.vue +12 -0
- package/app/layouts/page.vue +14 -0
- package/app/pages/[[lang]]/[...path].vue +28 -0
- package/app/templates/LandingPage.vue +13 -0
- package/app/utils.ts +1 -0
- package/content.config.ts +58 -0
- package/modules/config.ts +10 -0
- package/modules/routing.ts +112 -0
- package/nuxt.config.ts +132 -0
- package/package.json +67 -3
- package/shared/schema/config.ts +57 -0
- package/shared/schema/forms.ts +45 -0
- package/shared/schema/page.ts +23 -0
- package/shared/utils/content.ts +61 -0
- package/utils/pages.ts +12 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { defineNuxtModule, extendPages, createResolver } from '@nuxt/kit'
|
|
2
|
+
import { landingPageExists } from '../utils/pages'
|
|
3
|
+
import { getAvailableLocales } from '../shared/utils/content'
|
|
4
|
+
|
|
5
|
+
// `@nuxtjs/i18n` is optional, so we avoid a hard dependency on its types and
|
|
6
|
+
// instead duck-type the subset of its config we need here.
|
|
7
|
+
type LayerI18nOptions = {
|
|
8
|
+
locales?: Array<string | { code: string }>
|
|
9
|
+
defaultLocale?: string
|
|
10
|
+
routesNameSeparator?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// `@nuxtjs/i18n` detects the active locale of a route primarily from its
|
|
14
|
+
// route *name* (falling back to a locale prefix in the path otherwise), by
|
|
15
|
+
// splitting the name on this separator (`@nuxtjs/i18n` defaults to `___`,
|
|
16
|
+
// see `routesNameSeparator`). Since our own `i18n: false` routes below skip
|
|
17
|
+
// its automatic per-locale route generation (which normally produces names
|
|
18
|
+
// like `page___en`), we must replicate that naming convention ourselves so
|
|
19
|
+
// `@nuxtjs/i18n` can still resolve the correct active locale for them -
|
|
20
|
+
// notably including the *default* locale, whose paths have no locale
|
|
21
|
+
// prefix to fall back on (e.g. `/` or `/contact`).
|
|
22
|
+
const DEFAULT_ROUTE_NAME_SEPARATOR = '___'
|
|
23
|
+
|
|
24
|
+
// Locale-root paths (e.g. `/en`) must render the same landing page as `/`
|
|
25
|
+
// instead of falling through to the catch-all content page. Modelled on
|
|
26
|
+
// https://github.com/nuxt-content/docus/blob/main/layer/modules/routing.ts
|
|
27
|
+
export default defineNuxtModule({
|
|
28
|
+
meta: {
|
|
29
|
+
name: 'routing',
|
|
30
|
+
},
|
|
31
|
+
setup(_options, nuxt) {
|
|
32
|
+
const { resolve } = createResolver(import.meta.url)
|
|
33
|
+
|
|
34
|
+
const i18nOptions = (nuxt.options as typeof nuxt.options & { i18n?: LayerI18nOptions }).i18n
|
|
35
|
+
const isI18nEnabled = !!i18nOptions?.locales
|
|
36
|
+
const routeNameSeparator = i18nOptions?.routesNameSeparator || DEFAULT_ROUTE_NAME_SEPARATOR
|
|
37
|
+
|
|
38
|
+
// Only add the landing route if the consuming app doesn't already
|
|
39
|
+
// define its own `index.vue` (which takes precedence over the layer's).
|
|
40
|
+
|
|
41
|
+
if (landingPageExists(nuxt.options.rootDir)) {
|
|
42
|
+
console.warn(`Landing page already exists in the consuming app. Skipping layer landing page. -> make sure to handle locale routes properly.`)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
extendPages((pages) => {
|
|
47
|
+
const pageExist = (path: string) => pages.find(page => page.path === path)
|
|
48
|
+
|
|
49
|
+
const landingTemplate = resolve('../app/templates/LandingPage.vue')
|
|
50
|
+
const localeCodes = getAvailableLocales(nuxt.options as typeof nuxt.options & { i18n?: LayerI18nOptions })
|
|
51
|
+
if (isI18nEnabled) {
|
|
52
|
+
const defaultLocale = i18nOptions?.defaultLocale
|
|
53
|
+
|
|
54
|
+
// Generate a static route per locale index
|
|
55
|
+
const localizedIndexPages = localeCodes.map(code => ({
|
|
56
|
+
name: `lang-index${routeNameSeparator}${code}`,
|
|
57
|
+
path: defaultLocale === code ? `/` : `/${code}`,
|
|
58
|
+
file: landingTemplate,
|
|
59
|
+
meta: {
|
|
60
|
+
layout: 'default',
|
|
61
|
+
// These paths are already locale-specific (e.g. `/en`), so
|
|
62
|
+
// opt them out of `@nuxtjs/i18n`'s own automatic route
|
|
63
|
+
// localization. Otherwise it prefixes each already-prefixed
|
|
64
|
+
// path with its locale again (e.g. `/en` -> `/en/en`), which
|
|
65
|
+
// registers a broken route that Nuxt then tries to prerender.
|
|
66
|
+
i18n: false,
|
|
67
|
+
},
|
|
68
|
+
}))
|
|
69
|
+
|
|
70
|
+
//
|
|
71
|
+
if (localizedIndexPages.length > 0) {
|
|
72
|
+
pages.push(...localizedIndexPages.filter(p => !pageExist(p.path)))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ensures we set the layout here as well?
|
|
76
|
+
for (const { path } of localizedIndexPages) {
|
|
77
|
+
nuxt.options.nitro.routeRules ??= {}
|
|
78
|
+
nuxt.options.nitro.routeRules[path] = {
|
|
79
|
+
appLayout: 'default',
|
|
80
|
+
...nuxt.options.nitro.routeRules[path] || {},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// The catch-all content route (`app/pages/[[lang]]/[...path].vue`)
|
|
85
|
+
// uses a single, optional `[[lang]]` param to serve every locale
|
|
86
|
+
// from one route. Replace it with an explicit route per locale
|
|
87
|
+
// (default locale unprefixed, others prefixed) so its name can
|
|
88
|
+
// carry the locale, same as the landing routes above.
|
|
89
|
+
const catchAllPageIndex = pages.findIndex(page => page.path?.includes(':path(.*)*'))
|
|
90
|
+
const [catchAllPage] = catchAllPageIndex !== -1 ? pages.splice(catchAllPageIndex, 1) : []
|
|
91
|
+
if (catchAllPage) {
|
|
92
|
+
const localizedCatchAllPages = localeCodes.map(code => ({
|
|
93
|
+
...catchAllPage,
|
|
94
|
+
name: `${catchAllPage.name}${routeNameSeparator}${code}`,
|
|
95
|
+
path: defaultLocale === code ? '/:path(.*)*' : `/${code}/:path(.*)*`,
|
|
96
|
+
meta: { ...catchAllPage.meta, i18n: false },
|
|
97
|
+
}))
|
|
98
|
+
pages.push(...localizedCatchAllPages)
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
pages.push({
|
|
102
|
+
name: 'index',
|
|
103
|
+
path: '/',
|
|
104
|
+
file: landingTemplate,
|
|
105
|
+
meta: {
|
|
106
|
+
layout: 'default',
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
},
|
|
112
|
+
})
|
package/nuxt.config.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// https://nuxt.com/docs/api/configuration/nuxt-config
|
|
2
|
+
import { createResolver } from '@nuxt/kit'
|
|
3
|
+
|
|
4
|
+
const resolver = createResolver(import.meta.url)
|
|
5
|
+
export default defineNuxtConfig({
|
|
6
|
+
modules: [
|
|
7
|
+
resolver.resolve('./modules/config'),
|
|
8
|
+
resolver.resolve('./modules/routing'),
|
|
9
|
+
'@nuxt/eslint',
|
|
10
|
+
'@nuxt/icon',
|
|
11
|
+
'@nuxt/image',
|
|
12
|
+
'@comark/nuxt',
|
|
13
|
+
'@nuxtjs/device',
|
|
14
|
+
'@nuxt/hints',
|
|
15
|
+
'@nuxt/fonts',
|
|
16
|
+
'@nuxt/ui',
|
|
17
|
+
'@nuxtjs/sitemap',
|
|
18
|
+
'@nuxtjs/robots',
|
|
19
|
+
'nuxt-og-image',
|
|
20
|
+
'nuxt-seo-utils',
|
|
21
|
+
'@nuxt/content',
|
|
22
|
+
'@nuxtjs/i18n',
|
|
23
|
+
'@frigear-nu/menu',
|
|
24
|
+
(_, nuxt) => {
|
|
25
|
+
if (!nuxt.options.css.find(css => css.includes('main.css'))) {
|
|
26
|
+
nuxt.options.css.push(resolver.resolve('./app/assets/css/main.css'))
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
$meta: {
|
|
31
|
+
name: 'base',
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
content: {
|
|
35
|
+
experimental: { sqliteConnector: 'native' },
|
|
36
|
+
build: {
|
|
37
|
+
markdown: {
|
|
38
|
+
remarkPlugins: {},
|
|
39
|
+
highlight: false,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
// Server source-maps removed to half the required build time for production builds.
|
|
45
|
+
// Source-maps are still generated for the client-side code.
|
|
46
|
+
sourcemap: {
|
|
47
|
+
server: false,
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
features: {
|
|
51
|
+
inlineStyles: true,
|
|
52
|
+
},
|
|
53
|
+
experimental: {
|
|
54
|
+
defaults: {
|
|
55
|
+
nuxtLink: {
|
|
56
|
+
prefetchOn: {
|
|
57
|
+
interaction: true,
|
|
58
|
+
visibility: false,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
compatibilityDate: '2026-07-30',
|
|
65
|
+
|
|
66
|
+
vite: {
|
|
67
|
+
build: {
|
|
68
|
+
rolldownOptions: {
|
|
69
|
+
checks: {
|
|
70
|
+
// Building already spends the vast majority of its time inside
|
|
71
|
+
// bundler plugin hooks (module resolution/transform work), so this
|
|
72
|
+
// check just adds noisy, non-actionable warnings to every build.
|
|
73
|
+
// See https://rolldown.rs/reference/InputOptions.checks#plugintimings
|
|
74
|
+
pluginTimings: false,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
plugins: [
|
|
79
|
+
{
|
|
80
|
+
// Nuxt 4.6's `installedScanEntries` (in `@nuxt/vite-builder`) feeds every
|
|
81
|
+
// node_modules-installed component/plugin/layout/middleware file to Vite's
|
|
82
|
+
// dependency scanner as a scan entry, so it can pre-bundle their deps. Nuxt's
|
|
83
|
+
// own virtual-module resolvers (`#components`, `#imports`, `#app`, `#build/*`,
|
|
84
|
+
// ...) skip resolution during that scan, so the request falls through to
|
|
85
|
+
// Vite/rolldown's default resolver, which tries to resolve the `#`-prefixed
|
|
86
|
+
// specifier as a Node.js subpath import relative to the *importing package*
|
|
87
|
+
// (e.g. `@nuxtjs/i18n`) instead of the Nuxt app root - and fails, since that
|
|
88
|
+
// package never declares the specifier in its own `imports` field. This aborts
|
|
89
|
+
// the whole scan with "Missing '#x' specifier in '<package>' package" and Vite
|
|
90
|
+
// falls back to skipping dependency pre-bundling entirely.
|
|
91
|
+
// Fix pending upstream: https://github.com/nuxt/nuxt/issues/36473
|
|
92
|
+
name: 'frigear-nu:scan-external-virtual-specifiers',
|
|
93
|
+
enforce: 'pre',
|
|
94
|
+
resolveId: {
|
|
95
|
+
filter: { id: /^#/ },
|
|
96
|
+
handler(id, _importer, options) {
|
|
97
|
+
// `scan` is passed at runtime by Vite/rolldown's dependency scanner
|
|
98
|
+
// but isn't part of the public `resolveId` hook option types.
|
|
99
|
+
if ((options as { scan?: boolean } | undefined)?.scan) {
|
|
100
|
+
return { id, external: true }
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
eslint: {
|
|
109
|
+
config: {
|
|
110
|
+
stylistic: {
|
|
111
|
+
commaDangle: 'always-multiline',
|
|
112
|
+
braceStyle: '1tbs',
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
i18n: {
|
|
118
|
+
strategy: 'prefix_except_default',
|
|
119
|
+
detectBrowserLanguage: false,
|
|
120
|
+
},
|
|
121
|
+
menus: {
|
|
122
|
+
content: {
|
|
123
|
+
main: true,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
// Keep app/API/auth routes out of search results; `site.indexable` (set by the host app) decides whether
|
|
128
|
+
// the site as a whole may be indexed at all.
|
|
129
|
+
robots: {
|
|
130
|
+
disallow: ['/admin', '/api', '/auth', '/account'],
|
|
131
|
+
},
|
|
132
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frigear-nu/base",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "Shared Nuxt layer for Frigear websites - bundled modules, UI defaults, content collections, layouts and routing.",
|
|
5
|
+
"homepage": "https://docs.frigear.dev/layers/base",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/frigear-nu/frigear-nu.git",
|
|
9
|
+
"directory": "packages/base-layer"
|
|
10
|
+
},
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"main": "./nuxt.config.ts",
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"app",
|
|
19
|
+
"content.config.ts",
|
|
20
|
+
"modules",
|
|
21
|
+
"nuxt.config.ts",
|
|
22
|
+
"shared",
|
|
23
|
+
"utils"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@comark/nuxt": "0.6.2",
|
|
27
|
+
"@frigear-nu/menu": "0.0.9",
|
|
28
|
+
"@iconify-json/lucide": "^1.2.127",
|
|
29
|
+
"@iconify-json/simple-icons": "^1.2.94",
|
|
30
|
+
"@nuxt/content": "3.16.0",
|
|
31
|
+
"@nuxt/fonts": "^0.14.0",
|
|
32
|
+
"@nuxt/hints": "^1.1.4",
|
|
33
|
+
"@nuxt/icon": "^2.5.1",
|
|
34
|
+
"@nuxt/image": "^2.1.0",
|
|
35
|
+
"@nuxt/kit": "^4.6.0",
|
|
36
|
+
"@nuxt/schema": "^4.6.0",
|
|
37
|
+
"@nuxt/ui": "^4.11.0",
|
|
38
|
+
"@nuxtjs/device": "4.0.0",
|
|
39
|
+
"@nuxtjs/i18n": "^10.6.0",
|
|
40
|
+
"@nuxtjs/robots": "^7.0.2",
|
|
41
|
+
"@nuxtjs/sitemap": "^9.0.1",
|
|
42
|
+
"@vueuse/core": "^15.0.0",
|
|
43
|
+
"defu": "^6.1.7",
|
|
44
|
+
"motion-v": "^2.4.0",
|
|
45
|
+
"nuxt-og-image": "^7.0.2",
|
|
46
|
+
"nuxt-seo-utils": "^9.0.1",
|
|
47
|
+
"scule": "^1.3.0",
|
|
48
|
+
"tailwindcss": "^4.3.3",
|
|
49
|
+
"ufo": "^1.6.4",
|
|
50
|
+
"zod": "^4.5.4"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"nuxt": "^4.6.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@nuxt/eslint": "^1.17.0",
|
|
57
|
+
"eslint": "^10.9.1",
|
|
58
|
+
"nuxt": "^4.6.1",
|
|
59
|
+
"typescript": "^6.0.3",
|
|
60
|
+
"vue-tsc": "^3.3.11"
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"build": "nuxt build",
|
|
64
|
+
"dev": "nuxt dev",
|
|
65
|
+
"preview": "nuxt preview",
|
|
66
|
+
"lint": "eslint .",
|
|
67
|
+
"lint:fix": "eslint . --fix",
|
|
68
|
+
"typecheck": "nuxt typecheck"
|
|
69
|
+
}
|
|
6
70
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from 'zod/v4'
|
|
2
|
+
|
|
3
|
+
export const WebsiteConfigSchema = z.object({
|
|
4
|
+
name: z.union([
|
|
5
|
+
z.string(),
|
|
6
|
+
z.object({
|
|
7
|
+
long: z.string(),
|
|
8
|
+
short: z.string().optional(),
|
|
9
|
+
}),
|
|
10
|
+
]).optional(),
|
|
11
|
+
/**
|
|
12
|
+
* Site-wide SEO defaults. The layer ships none: every value comes from here.
|
|
13
|
+
* `titleTemplate` supports `%s` (page title) and `%siteName`, e.g. `%s | %siteName`.
|
|
14
|
+
*/
|
|
15
|
+
seo: z.object({
|
|
16
|
+
titleTemplate: z.string().optional(),
|
|
17
|
+
description: z.string().optional(),
|
|
18
|
+
image: z.string().optional(),
|
|
19
|
+
}).optional(),
|
|
20
|
+
logo: z.union([
|
|
21
|
+
z.string(),
|
|
22
|
+
z.object({
|
|
23
|
+
light: z.string(),
|
|
24
|
+
dark: z.string(),
|
|
25
|
+
class: z.string().optional(),
|
|
26
|
+
alt: z.string().optional(),
|
|
27
|
+
}),
|
|
28
|
+
]).optional(),
|
|
29
|
+
navigation: z.array(
|
|
30
|
+
z.object({
|
|
31
|
+
label: z.string(),
|
|
32
|
+
description: z.string().optional(),
|
|
33
|
+
to: z.string(),
|
|
34
|
+
icon: z.string().optional(),
|
|
35
|
+
tags: z.array(z.string()).optional(),
|
|
36
|
+
}),
|
|
37
|
+
).optional(),
|
|
38
|
+
footer: z.object({
|
|
39
|
+
sections: z.array(z.object({
|
|
40
|
+
label: z.string(),
|
|
41
|
+
icon: z.string().optional(),
|
|
42
|
+
links: z.array(z.object({
|
|
43
|
+
label: z.string(),
|
|
44
|
+
to: z.string(),
|
|
45
|
+
icon: z.string().optional(),
|
|
46
|
+
})),
|
|
47
|
+
})).optional(),
|
|
48
|
+
copyright: z.string().optional(),
|
|
49
|
+
right: z.array(z.object({
|
|
50
|
+
label: z.string(),
|
|
51
|
+
to: z.string(),
|
|
52
|
+
icon: z.string().optional(),
|
|
53
|
+
variant: z.enum(['link', 'solid', 'outline', 'soft', 'subtle', 'ghost']).optional(),
|
|
54
|
+
size: z.enum(['xs', 'sm', 'md', 'lg', 'xl']).optional(),
|
|
55
|
+
})).optional(),
|
|
56
|
+
}).optional(),
|
|
57
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from 'zod/v4'
|
|
2
|
+
|
|
3
|
+
export const contactFormSubjectOptions = {
|
|
4
|
+
apps: 'Apps',
|
|
5
|
+
websites: 'Nettsider',
|
|
6
|
+
development: 'Utvikling',
|
|
7
|
+
support: 'Support',
|
|
8
|
+
consulting: 'Rådgivning',
|
|
9
|
+
courses: 'Kurs',
|
|
10
|
+
other: 'Annet',
|
|
11
|
+
} as const
|
|
12
|
+
|
|
13
|
+
export const contactFormSubjectSelectItems = Object.entries(contactFormSubjectOptions).map(([key, value]) => ({ value: key, label: value }))
|
|
14
|
+
|
|
15
|
+
export const contactFormSubjectKeys = Object.keys(contactFormSubjectOptions) as [
|
|
16
|
+
keyof typeof contactFormSubjectOptions,
|
|
17
|
+
...Array<keyof typeof contactFormSubjectOptions>,
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
export const contactFormSchema = z
|
|
21
|
+
.object({
|
|
22
|
+
name: z.string({ message: 'Navn er påkrevd.' }),
|
|
23
|
+
email: z
|
|
24
|
+
.string({ message: 'E-post er påkrevd.' })
|
|
25
|
+
.email('Dette må være en gyldig e-postadresse.'),
|
|
26
|
+
phone: z.string().optional(),
|
|
27
|
+
subject: z.enum(contactFormSubjectKeys, { message: 'Emne er påkrevd.' }),
|
|
28
|
+
// optional in base schema, we enforce the condition below
|
|
29
|
+
subjectOther: z.string().optional(),
|
|
30
|
+
message: z
|
|
31
|
+
.string({ message: 'Beskjed feltet er påkrevd.' })
|
|
32
|
+
.nonempty('Beskjed feltet er påkrevd.'),
|
|
33
|
+
_token: z.string().optional(),
|
|
34
|
+
})
|
|
35
|
+
.superRefine((data, ctx) => {
|
|
36
|
+
if (data.subject === 'other' && data.subjectOther?.trim().length === 0) {
|
|
37
|
+
ctx.addIssue({
|
|
38
|
+
code: z.ZodIssueCode.custom,
|
|
39
|
+
path: ['subjectOther'],
|
|
40
|
+
message: 'Du må fylle ut feltet fordi du har valgt "annet".',
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
export type ContactSchema = z.output<typeof contactFormSchema>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from 'zod/v4'
|
|
2
|
+
|
|
3
|
+
export const WebsiteSeoSchema = z.object({
|
|
4
|
+
/** Overrides `title` in `<title>` and social cards (the page header keeps using `title`). */
|
|
5
|
+
title: z.string().optional(),
|
|
6
|
+
description: z.string().optional(),
|
|
7
|
+
/** Social/OG image, absolute or site-relative. */
|
|
8
|
+
image: z.string().optional(),
|
|
9
|
+
/** Ask search engines not to index this page. */
|
|
10
|
+
noindex: z.boolean().optional(),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
export const WebsitePageSchema = z.object({
|
|
14
|
+
title: z.string().optional(),
|
|
15
|
+
description: z.string().optional(),
|
|
16
|
+
seo: WebsiteSeoSchema.optional(),
|
|
17
|
+
/** Wrap the rendered body in a `UContainer` (for plain prose pages such as legal). */
|
|
18
|
+
container: z.boolean().optional(),
|
|
19
|
+
pageHeader: z.object({
|
|
20
|
+
title: z.string().optional(),
|
|
21
|
+
description: z.string().optional(),
|
|
22
|
+
}).optional(),
|
|
23
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
type ConfigWithLocales = {
|
|
2
|
+
i18n?: { locales?: Array<string | { code: string }> }
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
// Matches the locale-suffixed collection naming defined in `content.config.ts`
|
|
6
|
+
|
|
7
|
+
// (e.g. `config` -> `config_no` when the app opts into `@nuxtjs/i18n`).
|
|
8
|
+
export function localizedCollectionName<Name extends string>(
|
|
9
|
+
name: Name,
|
|
10
|
+
locale?: string,
|
|
11
|
+
) {
|
|
12
|
+
return (locale ? `${name}_${locale.replace(/-/g, '_')}` : name) as Name
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function stringOrPropValue<Prop extends Record<string, unknown>>(
|
|
16
|
+
prop: string | undefined | Prop,
|
|
17
|
+
key: Prop extends string ? string : keyof Prop,
|
|
18
|
+
) {
|
|
19
|
+
if (typeof prop === 'undefined') {
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
if (typeof prop === 'string') {
|
|
23
|
+
return prop
|
|
24
|
+
}
|
|
25
|
+
return prop?.[key]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getAvailableLocales(
|
|
29
|
+
config: ConfigWithLocales,
|
|
30
|
+
): string[] {
|
|
31
|
+
return config.i18n?.locales
|
|
32
|
+
? config.i18n.locales.map(locale => typeof locale === 'string' ? locale : locale.code)
|
|
33
|
+
: []
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getCollectionsToQuery(
|
|
37
|
+
locale: string | undefined,
|
|
38
|
+
availableLocales: string[],
|
|
39
|
+
): string[] {
|
|
40
|
+
if (locale && availableLocales.includes(locale)) {
|
|
41
|
+
return [`pages_${locale}`]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return availableLocales.length > 0
|
|
45
|
+
? availableLocales.map(l => `pages_${l}`)
|
|
46
|
+
: ['pages']
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getCollectionFromPath(
|
|
50
|
+
path: string,
|
|
51
|
+
availableLocales: string[],
|
|
52
|
+
): string {
|
|
53
|
+
const pathSegments = path.split('/').filter(Boolean)
|
|
54
|
+
const firstSegment = pathSegments[0]
|
|
55
|
+
|
|
56
|
+
if (firstSegment && availableLocales.includes(firstSegment)) {
|
|
57
|
+
return `pages_${firstSegment}`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return 'pages'
|
|
61
|
+
}
|
package/utils/pages.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { joinURL } from 'ufo'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Checks if the consuming app defines its own `index.vue` page.
|
|
6
|
+
* When true, the app is expected to own landing/locale-root routing itself,
|
|
7
|
+
* so the layer should not rewrite the index page's route.
|
|
8
|
+
*/
|
|
9
|
+
export function landingPageExists(rootDir: string): boolean {
|
|
10
|
+
const indexPagePath = joinURL(rootDir, 'app', 'pages', 'index.vue')
|
|
11
|
+
return existsSync(indexPagePath)
|
|
12
|
+
}
|