@frigear-nu/base 0.0.0-stage → 0.0.2

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 (39) hide show
  1. package/README.md +89 -2
  2. package/app/app.config.ts +14 -0
  3. package/app/app.d.ts +27 -0
  4. package/app/app.vue +21 -0
  5. package/app/assets/css/main.css +29 -0
  6. package/app/components/App/AppFooter.vue +32 -0
  7. package/app/components/App/AppFooterLeft.vue +71 -0
  8. package/app/components/App/AppFooterRight.vue +60 -0
  9. package/app/components/App/AppHeader.vue +36 -0
  10. package/app/components/App/AppHeaderBody.vue +44 -0
  11. package/app/components/App/AppHeaderCTA.vue +3 -0
  12. package/app/components/App/AppHeaderCenter.vue +32 -0
  13. package/app/components/App/AppHeaderLogo.vue +6 -0
  14. package/app/components/App/AppHeaderRight.vue +7 -0
  15. package/app/components/App/AppLogo.vue +23 -0
  16. package/app/components/IconMenuToggle.vue +83 -0
  17. package/app/components/LanguageSelect.vue +65 -0
  18. package/app/components/content/ContactForm.vue +161 -0
  19. package/app/components/content/HeroBackground.vue +103 -0
  20. package/app/composables/useConfig.ts +12 -0
  21. package/app/composables/useContentLocale.ts +70 -0
  22. package/app/composables/useCurrentPage.ts +17 -0
  23. package/app/composables/useNavigation.ts +13 -0
  24. package/app/composables/usePageSeo.ts +52 -0
  25. package/app/layouts/default.vue +12 -0
  26. package/app/layouts/page.vue +14 -0
  27. package/app/pages/[[lang]]/[...path].vue +28 -0
  28. package/app/templates/LandingPage.vue +13 -0
  29. package/app/utils.ts +1 -0
  30. package/content.config.ts +58 -0
  31. package/modules/config.ts +10 -0
  32. package/modules/routing.ts +112 -0
  33. package/nuxt.config.ts +132 -0
  34. package/package.json +67 -3
  35. package/shared/schema/config.ts +57 -0
  36. package/shared/schema/forms.ts +45 -0
  37. package/shared/schema/page.ts +23 -0
  38. package/shared/utils/content.ts +61 -0
  39. 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.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.2",
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.8",
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
+ }