@uxfront/layer-docs 0.1.0

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 (51) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +53 -0
  4. package/app/app.config.ts +82 -0
  5. package/app/app.vue +138 -0
  6. package/app/assets/css/main.css +15 -0
  7. package/app/components/IconMenuToggle.vue +79 -0
  8. package/app/components/LanguageSelect.vue +73 -0
  9. package/app/components/MorphingGradientBackground.vue +261 -0
  10. package/app/components/app/AppFooter.vue +13 -0
  11. package/app/components/app/AppFooterCenter.vue +17 -0
  12. package/app/components/app/AppFooterLeft.vue +21 -0
  13. package/app/components/app/AppFooterRight.vue +33 -0
  14. package/app/components/app/AppHeader.vue +105 -0
  15. package/app/components/app/AppHeaderBody.vue +14 -0
  16. package/app/components/app/AppHeaderCTA.vue +31 -0
  17. package/app/components/app/AppHeaderCenter.vue +10 -0
  18. package/app/components/app/AppHeaderLogo.vue +16 -0
  19. package/app/components/app/AppSearch.vue +59 -0
  20. package/app/components/app/AppSubHeader.vue +21 -0
  21. package/app/components/content/BrowserFrame.vue +28 -0
  22. package/app/components/content/FrameworkSwitcher.vue +47 -0
  23. package/app/components/content/Video.vue +103 -0
  24. package/app/components/docs/DocsAsideLeftBody.vue +20 -0
  25. package/app/components/docs/DocsAsideRightBottom.vue +15 -0
  26. package/app/components/docs/DocsPageHeaderLinks.vue +74 -0
  27. package/app/composables/useDocsSections.ts +50 -0
  28. package/app/composables/useDocusI18n.ts +49 -0
  29. package/app/composables/useFramework.ts +70 -0
  30. package/app/constants/sections.ts +25 -0
  31. package/app/error.vue +140 -0
  32. package/app/layouts/default.vue +23 -0
  33. package/app/pages/[[lang]]/[...slug].vue +48 -0
  34. package/app/pages/[[lang]]/docs/[section]/[...slug].vue +171 -0
  35. package/app/plugins/i18n.ts +21 -0
  36. package/app/plugins/posthog.client.ts +31 -0
  37. package/app/types/non-route-categories.ts +12 -0
  38. package/app/utils/flattenNavigation.ts +22 -0
  39. package/app/utils/foldNonRouteCategories.ts +47 -0
  40. package/app/utils/prerender.ts +9 -0
  41. package/i18n/locales/en.json +22 -0
  42. package/modules/config.ts +122 -0
  43. package/modules/routing.ts +20 -0
  44. package/nuxt.config.ts +130 -0
  45. package/package.json +98 -0
  46. package/server/plugins/llms-redirect.ts +60 -0
  47. package/server/routes/raw/[...slug].md.get.ts +74 -0
  48. package/tsconfig.json +17 -0
  49. package/utils/content.ts +118 -0
  50. package/utils/git.ts +114 -0
  51. package/utils/meta.ts +28 -0
@@ -0,0 +1,171 @@
1
+ <script setup lang="ts">
2
+ import { kebabCase } from "scule";
3
+ import type { ContentNavigationItem, Collections, PageCollectionItemBase } from "@nuxt/content";
4
+
5
+ type DocsPageItem = PageCollectionItemBase & {
6
+ links?: Array<{
7
+ label: string;
8
+ icon: string;
9
+ to: string;
10
+ target?: string;
11
+ }>;
12
+ seo?: { title?: string; description?: string };
13
+ };
14
+ import { findPageHeadline } from "@nuxt/content/utils";
15
+
16
+ definePageMeta({
17
+ layout: "default",
18
+ });
19
+
20
+ const route = useRoute();
21
+ const { locale, isEnabled, t } = useDocusI18n();
22
+ const appConfig = useAppConfig();
23
+ const navigationMap = inject<Ref<Record<string, ContentNavigationItem[]>>>("navigation");
24
+
25
+ const sectionSlug = computed(() => route.params.section as string);
26
+ const section = computed(() => findDocsSectionBySlug(sectionSlug.value));
27
+
28
+ if (!section.value) {
29
+ throw createError({
30
+ statusCode: 404,
31
+ statusMessage: "Page not found",
32
+ fatal: true,
33
+ });
34
+ }
35
+
36
+ const collectionName = computed(() =>
37
+ isEnabled.value ? `docs_${section.value!.key}_${locale.value}` : `docs_${section.value!.key}`,
38
+ );
39
+
40
+ const navigation = computed(() => navigationMap?.value?.[section.value!.key] ?? []);
41
+
42
+ const [{ data: page }, { data: surround }] = await Promise.all([
43
+ useAsyncData(
44
+ kebabCase(route.path),
45
+ () =>
46
+ queryCollection(collectionName.value as keyof Collections)
47
+ .path(route.path)
48
+ .first() as Promise<DocsPageItem>,
49
+ ),
50
+ useAsyncData(`${kebabCase(route.path)}-surround`, () => {
51
+ return queryCollectionItemSurroundings(collectionName.value as keyof Collections, route.path, {
52
+ fields: ["description"],
53
+ });
54
+ }),
55
+ ]);
56
+
57
+ if (!page.value) {
58
+ throw createError({
59
+ statusCode: 404,
60
+ statusMessage: "Page not found",
61
+ fatal: true,
62
+ });
63
+ }
64
+
65
+ // Add the page path to the prerender list
66
+ addPrerenderPath(`/raw${route.path}.md`);
67
+
68
+ const title = page.value.seo?.title || page.value.title;
69
+ const description = page.value.seo?.description || page.value.description;
70
+
71
+ useSeoMeta({
72
+ title,
73
+ ogTitle: title,
74
+ description,
75
+ ogDescription: description,
76
+ });
77
+
78
+ const headline = ref(findPageHeadline(navigation.value, page.value?.path));
79
+ watch(
80
+ () => navigation.value,
81
+ () => {
82
+ headline.value = findPageHeadline(navigation.value, page.value?.path) || headline.value;
83
+ },
84
+ );
85
+
86
+ const github = computed(() => (appConfig.github ? appConfig.github : null));
87
+
88
+ const editLink = computed(() => {
89
+ if (!github.value) {
90
+ return;
91
+ }
92
+
93
+ return [
94
+ github.value.url,
95
+ "edit",
96
+ github.value.branch,
97
+ github.value.rootDir,
98
+ "content",
99
+ `${page.value?.stem}.${page.value?.extension}`,
100
+ ]
101
+ .filter(Boolean)
102
+ .join("/");
103
+ });
104
+ </script>
105
+
106
+ <template>
107
+ <UPage v-if="page">
108
+ <UPageHeader
109
+ :title="page.title"
110
+ :description="page.description"
111
+ :headline="headline"
112
+ :ui="{
113
+ wrapper: 'flex-row items-center flex-wrap justify-between',
114
+ }"
115
+ >
116
+ <template #links>
117
+ <UButton
118
+ v-for="(link, index) in (page as DocsPageItem).links"
119
+ :key="index"
120
+ size="sm"
121
+ v-bind="link"
122
+ />
123
+
124
+ <DocsPageHeaderLinks />
125
+ </template>
126
+ </UPageHeader>
127
+
128
+ <UPageBody>
129
+ <ContentRenderer v-if="page" :value="page" />
130
+
131
+ <USeparator>
132
+ <div v-if="github" class="flex items-center gap-2 text-sm text-muted">
133
+ <UButton
134
+ variant="link"
135
+ color="neutral"
136
+ :to="editLink"
137
+ target="_blank"
138
+ icon="i-lucide-pen"
139
+ :ui="{ leadingIcon: 'size-4' }"
140
+ >
141
+ {{ t("docs.edit") }}
142
+ </UButton>
143
+ <span>{{ t("common.or") }}</span>
144
+ <UButton
145
+ variant="link"
146
+ color="neutral"
147
+ :to="`${github.url}/issues/new/choose`"
148
+ target="_blank"
149
+ icon="i-lucide-alert-circle"
150
+ :ui="{ leadingIcon: 'size-4' }"
151
+ >
152
+ {{ t("docs.report") }}
153
+ </UButton>
154
+ </div>
155
+ </USeparator>
156
+ <UContentSurround :surround="surround" />
157
+ </UPageBody>
158
+
159
+ <template v-if="page?.body?.toc?.links?.length" #right>
160
+ <UContentToc
161
+ highlight
162
+ :title="appConfig.toc?.title || t('docs.toc')"
163
+ :links="page.body?.toc?.links"
164
+ >
165
+ <template #bottom>
166
+ <DocsAsideRightBottom />
167
+ </template>
168
+ </UContentToc>
169
+ </template>
170
+ </UPage>
171
+ </template>
@@ -0,0 +1,21 @@
1
+ export default defineNuxtPlugin(() => {
2
+ // Consumers opt out via `app.config` (`i18nRedirect.enabled: false`).
3
+ if (!useAppConfig().i18nRedirect?.enabled) {
4
+ return;
5
+ }
6
+
7
+ const nuxtApp = useNuxtApp();
8
+
9
+ const i18nConfig = nuxtApp.$config.public.i18n as { defaultLocale?: string } | undefined;
10
+ if (!i18nConfig) {
11
+ return;
12
+ }
13
+
14
+ addRouteMiddleware((to) => {
15
+ if (to.path === "/") {
16
+ const cookieLocale = useCookie("i18n_redirected").value || i18nConfig.defaultLocale || "en";
17
+
18
+ return navigateTo(`/${cookieLocale}`);
19
+ }
20
+ });
21
+ });
@@ -0,0 +1,31 @@
1
+ import type { ConfigDefaults } from "posthog-js";
2
+ import posthog from "posthog-js";
3
+ import { defineNuxtPlugin } from "#app";
4
+
5
+ export default defineNuxtPlugin(() => {
6
+ if (import.meta.dev) {
7
+ return;
8
+ }
9
+
10
+ // Consumers opt out via `app.config` (`analytics.enabled: false`).
11
+ if (!useAppConfig().analytics?.enabled) {
12
+ return;
13
+ }
14
+
15
+ const runtimeConfig = useRuntimeConfig();
16
+
17
+ const posthogClient = posthog.init(runtimeConfig.public.posthog.key, {
18
+ api_host: runtimeConfig.public.posthog.host,
19
+ defaults: runtimeConfig.public.posthog.defaults as ConfigDefaults,
20
+ person_profiles: "identified_only", // or 'always' to create profiles for anonymous users as well
21
+ loaded: (posthog) => {
22
+ if (import.meta.env.MODE === "development") posthog.debug();
23
+ },
24
+ });
25
+
26
+ return {
27
+ provide: {
28
+ posthog: () => posthogClient,
29
+ },
30
+ };
31
+ });
@@ -0,0 +1,12 @@
1
+ export interface NonRouteCategoryMeta {
2
+ title: string;
3
+ icon?: string | false;
4
+ defaultOpen?: boolean;
5
+ order: number;
6
+ }
7
+
8
+ declare module "nuxt/schema" {
9
+ interface AppConfig {
10
+ nonRouteCategories?: Record<string, NonRouteCategoryMeta>;
11
+ }
12
+ }
@@ -0,0 +1,22 @@
1
+ import type { ContentNavigationItem } from "@nuxt/content";
2
+
3
+ /**
4
+ * Flatten navigation items that are false-positive groups.
5
+ * Nuxt Content's index detection regex matches filenames containing "index"
6
+ * as a substring (e.g. "z-index"), incorrectly nesting them as groups.
7
+ * This flattens groups with a single child sharing the same path.
8
+ */
9
+ export function flattenNavigation(items: ContentNavigationItem[]): ContentNavigationItem[] {
10
+ return items.map((item) => {
11
+ if (item.children?.length === 1 && item.children[0]?.path === item.path) {
12
+ const { children: _, ...parent } = item;
13
+ return { ...parent, ...item.children[0], children: undefined };
14
+ }
15
+
16
+ if (item.children?.length) {
17
+ return { ...item, children: flattenNavigation(item.children) };
18
+ }
19
+
20
+ return item;
21
+ });
22
+ }
@@ -0,0 +1,47 @@
1
+ import type { ContentNavigationItem } from "@nuxt/content";
2
+ import type { NonRouteCategoryMeta } from "~/types/non-route-categories";
3
+
4
+ /**
5
+ * Rebuilds the sidebar grouping for non-route categories.
6
+ *
7
+ * Pages under a `route: false` folder are flattened in the URL, so the native
8
+ * navigation tree lists them as direct children with the category level gone.
9
+ * This re-nests each such page under a synthetic group node (rendered as a
10
+ * non-navigating accordion header), matching pages to categories by `stem`.
11
+ */
12
+ export function foldNonRouteCategories(
13
+ items: ContentNavigationItem[],
14
+ categories: Record<string, NonRouteCategoryMeta>,
15
+ ): ContentNavigationItem[] {
16
+ return items.map((node) => {
17
+ if (!node.children?.length) {
18
+ return node;
19
+ }
20
+ const passthrough: ContentNavigationItem[] = [];
21
+ const groups = new Map<
22
+ string,
23
+ { meta: NonRouteCategoryMeta; children: ContentNavigationItem[] }
24
+ >();
25
+ for (const child of node.children) {
26
+ const dir = child.stem ? child.stem.split("/").slice(0, -1).join("/") : "";
27
+ const meta = categories[dir];
28
+ if (meta && child.page !== false) {
29
+ const group = groups.get(dir) ?? { meta, children: [] };
30
+ group.children.push(child);
31
+ groups.set(dir, group);
32
+ } else {
33
+ passthrough.push(foldNonRouteCategories([child], categories)[0]!);
34
+ }
35
+ }
36
+ const groupNodes = [...groups.values()]
37
+ .sort((a, b) => a.meta.order - b.meta.order || a.meta.title.localeCompare(b.meta.title))
38
+ .map(({ meta, children }) => ({
39
+ title: meta.title,
40
+ ...(meta.icon ? { icon: meta.icon } : {}),
41
+ ...(meta.defaultOpen !== undefined ? { defaultOpen: meta.defaultOpen } : {}),
42
+ page: false as const,
43
+ children,
44
+ })) as ContentNavigationItem[];
45
+ return { ...node, children: [...passthrough, ...groupNodes] };
46
+ });
47
+ }
@@ -0,0 +1,9 @@
1
+ export const addPrerenderPath = (path: string) => {
2
+ const event = useRequestEvent();
3
+ if (event) {
4
+ event.node.res.setHeader(
5
+ "x-nitro-prerender",
6
+ [event.node.res.getHeader("x-nitro-prerender"), path].filter(Boolean).join(","),
7
+ );
8
+ }
9
+ };
@@ -0,0 +1,22 @@
1
+ {
2
+ "common": {
3
+ "or": "or",
4
+ "error": {
5
+ "title": "Page not found",
6
+ "description": "We are sorry but this page could not be found."
7
+ }
8
+ },
9
+ "docs": {
10
+ "copy": {
11
+ "page": "Copy page",
12
+ "link": "Copy Markdown page",
13
+ "view": "View as Markdown",
14
+ "gpt": "Open in ChatGPT",
15
+ "claude": "Open in Claude"
16
+ },
17
+ "links": "Community",
18
+ "toc": "On this page",
19
+ "report": "Report an issue",
20
+ "edit": "Edit this page"
21
+ }
22
+ }
@@ -0,0 +1,122 @@
1
+ import { createResolver, defineNuxtModule } from "@nuxt/kit";
2
+ import { defu } from "defu";
3
+ import { existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { inferSiteURL, getPackageJsonMetadata } from "../utils/meta";
6
+ import { getGitBranch, getGitEnv, getLocalGitInfo } from "../utils/git";
7
+
8
+ export default defineNuxtModule({
9
+ meta: {
10
+ name: "config",
11
+ },
12
+ async setup(_options, nuxt) {
13
+ const dir = nuxt.options.rootDir;
14
+ const url = inferSiteURL();
15
+ const meta = await getPackageJsonMetadata(dir);
16
+ const gitInfo = (await getLocalGitInfo(dir)) || getGitEnv();
17
+ const site = nuxt.options.site;
18
+ const siteName =
19
+ (typeof site === "object" ? site.name : undefined) || meta.name || gitInfo?.name || "";
20
+
21
+ nuxt.options.llms = defu(nuxt.options.llms, {
22
+ domain: url,
23
+ title: siteName,
24
+ description: meta.description || "",
25
+ full: {
26
+ title: siteName,
27
+ description: meta.description || "",
28
+ },
29
+ });
30
+
31
+ nuxt.options.site = defu(nuxt.options.site, {
32
+ url,
33
+ name: siteName,
34
+ debug: false,
35
+ }) as typeof nuxt.options.site;
36
+
37
+ nuxt.options.appConfig.header = defu(nuxt.options.appConfig.header, {
38
+ title: siteName,
39
+ });
40
+
41
+ nuxt.options.appConfig.seo = defu(nuxt.options.appConfig.seo, {
42
+ titleTemplate: `%s - ${siteName}`,
43
+ title: siteName,
44
+ description: meta.description || "",
45
+ });
46
+
47
+ nuxt.options.appConfig.github = defu(nuxt.options.appConfig.github, {
48
+ owner: gitInfo?.owner,
49
+ name: gitInfo?.name,
50
+ url: gitInfo?.url,
51
+ branch: getGitBranch(),
52
+ });
53
+
54
+ /*
55
+ ** I18N
56
+ */
57
+ if (nuxt.options.i18n && nuxt.options.i18n.locales) {
58
+ const { resolve } = createResolver(import.meta.url);
59
+
60
+ // Filter locales to only include existing ones
61
+ const filteredLocales = nuxt.options.i18n.locales.filter((locale) => {
62
+ const localeCode = typeof locale === "string" ? locale : locale.code;
63
+
64
+ // Check for JSON locale file
65
+ const localeFilePath = resolve("../i18n/locales", `${localeCode}.json`);
66
+ const hasLocaleFile = existsSync(localeFilePath);
67
+
68
+ // Check for content folder
69
+ const contentPath = join(nuxt.options.rootDir, "content", localeCode);
70
+ const hasContentFolder = existsSync(contentPath);
71
+
72
+ if (!hasLocaleFile) {
73
+ console.warn(
74
+ `[Docus] Locale file not found: ${localeCode}.json - skipping locale "${localeCode}"`,
75
+ );
76
+ }
77
+
78
+ if (!hasContentFolder) {
79
+ console.warn(
80
+ `[Docus] Content folder not found: content/${localeCode}/ - skipping locale "${localeCode}"`,
81
+ );
82
+ }
83
+
84
+ return hasLocaleFile && hasContentFolder;
85
+ });
86
+
87
+ // Override strategy to prefix
88
+ nuxt.options.i18n = {
89
+ ...nuxt.options.i18n,
90
+ strategy: "prefix",
91
+ };
92
+
93
+ // Expose filtered locales
94
+ nuxt.options.runtimeConfig.public.docus = {
95
+ filteredLocales,
96
+ };
97
+
98
+ nuxt.hook("i18n:registerModule", (register) => {
99
+ const langDir = resolve("../i18n/locales");
100
+
101
+ const locales = filteredLocales?.map((locale) => {
102
+ return typeof locale === "string"
103
+ ? {
104
+ code: locale,
105
+ name: locale,
106
+ file: `${locale}.json`,
107
+ }
108
+ : {
109
+ code: locale.code,
110
+ name: locale.name || locale.code,
111
+ file: `${locale.code}.json`,
112
+ };
113
+ });
114
+
115
+ register({
116
+ langDir,
117
+ locales,
118
+ });
119
+ });
120
+ }
121
+ },
122
+ });
@@ -0,0 +1,20 @@
1
+ import { defineNuxtModule, createResolver } from "@nuxt/kit";
2
+
3
+ export default defineNuxtModule({
4
+ meta: {
5
+ name: "routing",
6
+ },
7
+ async setup(_options, nuxt) {
8
+ const { resolve } = createResolver(import.meta.url);
9
+
10
+ // Ensure useDocusI18n is available in the app
11
+ nuxt.hook("imports:extend", (imports) => {
12
+ if (imports.some((i) => i.name === "useDocusI18n")) return;
13
+
14
+ imports.push({
15
+ name: "useDocusI18n",
16
+ from: resolve("../app/composables/useDocusI18n"),
17
+ });
18
+ });
19
+ },
20
+ });
package/nuxt.config.ts ADDED
@@ -0,0 +1,130 @@
1
+ import { createResolver, useNuxt } from "@nuxt/kit";
2
+
3
+ const { resolve } = createResolver(import.meta.url);
4
+
5
+ /**
6
+ * `@uxfront/layer-docs` — a neutral, brandable Nuxt-layer documentation theme.
7
+ *
8
+ * Consumers extend it via `extends: ['@uxfront/layer-docs']` and supply their
9
+ * own branding (title, logos, socials, palette) through their `app.config.ts`,
10
+ * merged over the layer's neutral defaults by Nuxt's `defu` layer merge. The
11
+ * section topology (`DOCS_SECTIONS`) and markdown content stay in the consumer.
12
+ *
13
+ * i18n is OFF by default: `@nuxtjs/i18n` is not registered here, so a v1 single
14
+ * locale site "just works". A consumer that wants localisation registers the
15
+ * module itself; `modules/config` + `useDocusI18n` pick it up automatically.
16
+ *
17
+ * https://nuxt.com/docs/getting-started/layers
18
+ */
19
+ export default defineNuxtConfig({
20
+ compatibilityDate: "2025-07-22",
21
+ telemetry: false,
22
+ css: [resolve("./app/assets/css/main.css")],
23
+ // Expose `app/constants/` (DOCS_SECTIONS, findDocsSectionBySlug) as
24
+ // auto-imports. The layer ships a neutral empty default; a consumer's own
25
+ // `app/constants/` overrides it via auto-import dir precedence.
26
+ imports: {
27
+ dirs: ["constants"],
28
+ },
29
+ modules: [
30
+ resolve("./modules/config"),
31
+ resolve("./modules/routing"),
32
+ "@nuxt/ui",
33
+ "@nuxt/image",
34
+ "@nuxt/scripts",
35
+ "@nuxtjs/robots",
36
+ "@nuxtjs/sitemap",
37
+ "@nuxt/content",
38
+ "nuxt-llms",
39
+ ],
40
+ icon: {
41
+ serverBundle: "local",
42
+ fetchTimeout: 10000,
43
+ },
44
+ // Syntax-highlighting language set + MDC auto-unwrap. Shared across every
45
+ // docs consumer; brand-agnostic.
46
+ content: {
47
+ build: {
48
+ markdown: {
49
+ highlight: {
50
+ langs: [
51
+ "bash",
52
+ "diff",
53
+ "json",
54
+ "js",
55
+ "ts",
56
+ "tsx",
57
+ "html",
58
+ "css",
59
+ "vue",
60
+ "svelte",
61
+ "astro",
62
+ "shell",
63
+ "mdc",
64
+ "md",
65
+ "yaml",
66
+ ],
67
+ },
68
+ remarkPlugins: {
69
+ "remark-mdc": {
70
+ options: {
71
+ autoUnwrap: true,
72
+ },
73
+ },
74
+ },
75
+ },
76
+ },
77
+ },
78
+ nitro: {
79
+ prerender: {
80
+ crawlLinks: true,
81
+ failOnError: false,
82
+ autoSubfolderIndex: false,
83
+ },
84
+ },
85
+ hooks: {
86
+ // Seed the prerender crawl with the site root(s). Without i18n that's just
87
+ // "/"; with i18n it's each locale root ("/en", "/fr", ...). Everything else
88
+ // is reached via crawlLinks.
89
+ "nitro:config"(nitroConfig) {
90
+ const nuxt = useNuxt();
91
+ const i18nOptions = nuxt.options.i18n as
92
+ | { locales?: Array<string | { code: string }> }
93
+ | undefined;
94
+
95
+ const routes: string[] = [];
96
+ if (!i18nOptions?.locales?.length) {
97
+ routes.push("/");
98
+ } else {
99
+ routes.push(
100
+ ...i18nOptions.locales.map((locale) =>
101
+ typeof locale === "string" ? `/${locale}` : `/${locale.code}`,
102
+ ),
103
+ );
104
+ }
105
+
106
+ nitroConfig.prerender = nitroConfig.prerender || {};
107
+ nitroConfig.prerender.routes = nitroConfig.prerender.routes || [];
108
+ nitroConfig.prerender.routes.push(...routes);
109
+ },
110
+ },
111
+ // Generic sitemap defaults; brand values (url/name) come from the consumer's
112
+ // `site` config or are inferred in `modules/config`.
113
+ sitemap: {
114
+ enabled: true,
115
+ discoverImages: true,
116
+ discoverVideos: true,
117
+ },
118
+ // Neutral analytics defaults. The PostHog client plugin (ships in this layer)
119
+ // only initialises in production when `app.config` `analytics.enabled` is true
120
+ // and a key is present; consumers override the key/host from their own env.
121
+ runtimeConfig: {
122
+ public: {
123
+ posthog: {
124
+ key: "",
125
+ host: "https://us.i.posthog.com",
126
+ defaults: "2025-05-24",
127
+ },
128
+ },
129
+ },
130
+ });