@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
package/package.json ADDED
@@ -0,0 +1,98 @@
1
+ {
2
+ "name": "@uxfront/layer-docs",
3
+ "version": "0.1.0",
4
+ "description": "Neutral, brandable Nuxt-layer documentation theme. Consumers extend it and supply their own branding, content and section topology.",
5
+ "keywords": [
6
+ "docs",
7
+ "documentation",
8
+ "docus",
9
+ "nuxt",
10
+ "nuxt-layer",
11
+ "theme"
12
+ ],
13
+ "homepage": "https://github.com/uxfront-com/uxfront/tree/main/packages/layer-docs#readme",
14
+ "bugs": "https://github.com/uxfront-com/uxfront/issues",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/uxfront-com/uxfront.git",
19
+ "directory": "packages/layer-docs"
20
+ },
21
+ "files": [
22
+ "app",
23
+ "i18n",
24
+ "modules",
25
+ "server",
26
+ "utils",
27
+ "nuxt.config.ts",
28
+ "tsconfig.json",
29
+ "README.md",
30
+ "CHANGELOG.md",
31
+ "LICENSE"
32
+ ],
33
+ "type": "module",
34
+ "main": "./nuxt.config.ts",
35
+ "types": "./nuxt.config.ts",
36
+ "exports": {
37
+ ".": {
38
+ "types": "./nuxt.config.ts",
39
+ "import": "./nuxt.config.ts"
40
+ },
41
+ "./content": {
42
+ "types": "./utils/content.ts",
43
+ "import": "./utils/content.ts"
44
+ },
45
+ "./app/assets/css/main.css": "./app/assets/css/main.css",
46
+ "./package.json": "./package.json"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "dependencies": {
52
+ "@vueuse/core": "^14.3.0",
53
+ "defu": "^6.1.4",
54
+ "git-url-parse": "^16.1.0",
55
+ "minimark": "^0.2.0",
56
+ "motion-v": "^1.7.2",
57
+ "pkg-types": "^2.3.1",
58
+ "scule": "^1.3.0",
59
+ "ufo": "^1.6.4"
60
+ },
61
+ "devDependencies": {
62
+ "typescript": "^6.0.3"
63
+ },
64
+ "peerDependencies": {
65
+ "@nuxt/content": "^3.14.0",
66
+ "@nuxt/image": "^1.11.0",
67
+ "@nuxt/kit": "^4.4.8",
68
+ "@nuxt/scripts": "^0.11.13",
69
+ "@nuxt/ui": "^4.8.2",
70
+ "@nuxtjs/i18n": "^10.4.0",
71
+ "@nuxtjs/mdc": "^0.22.0",
72
+ "@nuxtjs/robots": "^6.1.1",
73
+ "@nuxtjs/sitemap": "^8.2.1",
74
+ "nuxt": "^4.4.8",
75
+ "nuxt-llms": "^0.2.0",
76
+ "posthog-js": "^1.386.6",
77
+ "tailwindcss": "^4.3.1",
78
+ "typescript": "^6.0.3",
79
+ "vue": "^3.5.38"
80
+ },
81
+ "peerDependenciesMeta": {
82
+ "@nuxtjs/i18n": {
83
+ "optional": true
84
+ },
85
+ "@nuxtjs/mdc": {
86
+ "optional": true
87
+ },
88
+ "posthog-js": {
89
+ "optional": true
90
+ },
91
+ "typescript": {
92
+ "optional": true
93
+ }
94
+ },
95
+ "scripts": {
96
+ "postinstall": "nuxt prepare"
97
+ }
98
+ }
@@ -0,0 +1,60 @@
1
+ import type { H3Event } from "h3";
2
+ import { withoutTrailingSlash } from "ufo";
3
+
4
+ /**
5
+ * Serve `/llms.txt` from the homepage for AI agents and crawlers.
6
+ *
7
+ * Human browsers still get the rendered homepage; only clients that ask for
8
+ * markdown (`Accept: text/markdown`) or identify as `curl` are redirected to
9
+ * the `nuxt-llms` entry point. This mirrors Docus's agent-friendly behaviour
10
+ * but works across Nitro presets (we deploy the node-server output), where the
11
+ * upstream Vercel-config rewrite does not apply.
12
+ *
13
+ * It runs on the `request` hook rather than as route middleware so it fires
14
+ * before Nitro's public-asset handler serves the prerendered homepage — route
15
+ * middleware would never see a request for a statically generated page.
16
+ */
17
+ export default defineNitroPlugin((nitroApp) => {
18
+ nitroApp.hooks.hook("request", (event) => {
19
+ if (event.method !== "GET" && event.method !== "HEAD") {
20
+ return;
21
+ }
22
+
23
+ const path = withoutTrailingSlash(event.path.split("?")[0]) || "/";
24
+ if (!isRootPath(event, path)) {
25
+ return;
26
+ }
27
+
28
+ const accept = getHeader(event, "accept") ?? "";
29
+ const userAgent = getHeader(event, "user-agent") ?? "";
30
+
31
+ const wantsMarkdown = accept.includes("text/markdown");
32
+ const isCurl = /^curl\//i.test(userAgent);
33
+
34
+ if (wantsMarkdown || isCurl) {
35
+ return sendRedirect(event, "/llms.txt", 302);
36
+ }
37
+ });
38
+ });
39
+
40
+ /**
41
+ * True for `/` and, when i18n is enabled, each locale homepage (e.g. `/en`).
42
+ */
43
+ function isRootPath(event: H3Event, path: string) {
44
+ if (path === "/") {
45
+ return true;
46
+ }
47
+
48
+ const i18n = useRuntimeConfig(event).public.i18n as
49
+ | { locales?: (string | { code: string })[] }
50
+ | undefined;
51
+
52
+ if (!i18n?.locales) {
53
+ return false;
54
+ }
55
+
56
+ return i18n.locales.some((locale) => {
57
+ const code = typeof locale === "string" ? locale : locale.code;
58
+ return path === `/${code}`;
59
+ });
60
+ }
@@ -0,0 +1,74 @@
1
+ import { withLeadingSlash } from "ufo";
2
+ import { stringify } from "minimark/stringify";
3
+ import { queryCollection } from "@nuxt/content/nitro";
4
+ import type { Collections } from "@nuxt/content";
5
+ import { DOCS_SECTIONS } from "~/constants/sections";
6
+
7
+ export default eventHandler(async (event) => {
8
+ const slug = getRouterParams(event)["slug.md"];
9
+ if (!slug?.endsWith(".md")) {
10
+ throw createError({
11
+ statusCode: 404,
12
+ statusMessage: "Page not found",
13
+ fatal: true,
14
+ });
15
+ }
16
+
17
+ const path = withLeadingSlash(slug.replace(".md", ""));
18
+ const config = useRuntimeConfig(event).public;
19
+
20
+ const pathSegments = path.split("/").filter(Boolean);
21
+ let localeSegment: string | undefined;
22
+ let sectionIndex = 1; // expect /docs/<section>/...
23
+
24
+ const i18n = config.i18n as
25
+ | { locales?: (string | { code: string })[]; defaultLocale?: string }
26
+ | undefined;
27
+
28
+ if (i18n?.locales) {
29
+ const availableLocales = i18n.locales.map((locale) =>
30
+ typeof locale === "string" ? locale : locale.code,
31
+ );
32
+ const firstSegment = pathSegments[0];
33
+ if (firstSegment && availableLocales.includes(firstSegment)) {
34
+ localeSegment = firstSegment;
35
+ sectionIndex = 2;
36
+ } else if (i18n.defaultLocale) {
37
+ localeSegment = i18n.defaultLocale;
38
+ }
39
+ }
40
+
41
+ const sectionSlug = pathSegments[sectionIndex];
42
+ const section = DOCS_SECTIONS.find((s) => s.slug === sectionSlug);
43
+ if (!section) {
44
+ throw createError({
45
+ statusCode: 404,
46
+ statusMessage: "Page not found",
47
+ fatal: true,
48
+ });
49
+ }
50
+
51
+ const collectionName = localeSegment
52
+ ? `docs_${section.key}_${localeSegment}`
53
+ : `docs_${section.key}`;
54
+
55
+ const page = await queryCollection(event, collectionName as keyof Collections)
56
+ .path(path)
57
+ .first();
58
+ if (!page) {
59
+ throw createError({
60
+ statusCode: 404,
61
+ statusMessage: "Page not found",
62
+ fatal: true,
63
+ });
64
+ }
65
+
66
+ // Add title and description to the top of the page if missing
67
+ if (page.body.value[0]?.[0] !== "h1") {
68
+ page.body.value.unshift(["blockquote", {}, page.description]);
69
+ page.body.value.unshift(["h1", {}, page.title]);
70
+ }
71
+
72
+ setHeader(event, "Content-Type", "text/markdown; charset=utf-8");
73
+ return stringify({ ...page.body, type: "minimark" }, { format: "markdown/html" });
74
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ {
5
+ "path": "./.nuxt/tsconfig.app.json"
6
+ },
7
+ {
8
+ "path": "./.nuxt/tsconfig.server.json"
9
+ },
10
+ {
11
+ "path": "./.nuxt/tsconfig.shared.json"
12
+ },
13
+ {
14
+ "path": "./.nuxt/tsconfig.node.json"
15
+ }
16
+ ]
17
+ }
@@ -0,0 +1,118 @@
1
+ import type { DefinedCollection } from "@nuxt/content";
2
+ import { defineContentConfig, defineCollection, z } from "@nuxt/content";
3
+ import { useNuxt } from "@nuxt/kit";
4
+
5
+ /**
6
+ * Minimal structural shape of a documentation section descriptor. The consuming
7
+ * app owns the concrete `DOCS_SECTIONS` array (and may extend each entry with
8
+ * extra fields like `icon`/`label` used by the runtime nav); this helper only
9
+ * reads the fields it needs to build Nuxt Content collections.
10
+ */
11
+ export interface DocsSectionDescriptor {
12
+ /** Stable collection key, e.g. "guide" → collection `docs_guide`. */
13
+ key: string;
14
+ /** URL segment under `/docs/<slug>`. */
15
+ slug: string;
16
+ /** Human label, used as a fallback nav title. */
17
+ label: string;
18
+ /** Source folder(s) under `content/docs/`. String or list. */
19
+ folder: string | string[];
20
+ /**
21
+ * When `folder` is a list, the index whose pages mount at the section root
22
+ * (`/docs/<slug>`) instead of `/docs/<slug>/<folder>`. Defaults to none.
23
+ */
24
+ rootFolder?: number;
25
+ }
26
+
27
+ const createDocsSchema = () =>
28
+ z.object({
29
+ links: z
30
+ .array(
31
+ z.object({
32
+ label: z.string(),
33
+ icon: z.string(),
34
+ to: z.string(),
35
+ target: z.string().optional(),
36
+ }),
37
+ )
38
+ .optional(),
39
+ });
40
+
41
+ const buildDocsSource = (section: DocsSectionDescriptor, pathPrefix = "", urlPrefix = "") => {
42
+ const folders = Array.isArray(section.folder) ? section.folder : [section.folder];
43
+ const baseUrl = `${urlPrefix}/docs/${section.slug}`;
44
+
45
+ if (folders.length === 1) {
46
+ return {
47
+ include: `${pathPrefix}docs/${folders[0]}/**/*.{md,yml}`,
48
+ prefix: baseUrl,
49
+ };
50
+ }
51
+
52
+ const rootIndex = typeof section.rootFolder === "number" ? section.rootFolder : -1;
53
+
54
+ return folders.map((folder, index) => ({
55
+ include: `${pathPrefix}docs/${folder}/**/*.{md,yml}`,
56
+ prefix: index === rootIndex ? baseUrl : `${baseUrl}/${folder.replace(/^\d+\./, "")}`,
57
+ }));
58
+ };
59
+
60
+ /**
61
+ * Builds the Nuxt Content configuration for a documentation site from a list of
62
+ * section descriptors. A consumer's `content.config.ts` becomes a one-liner:
63
+ *
64
+ * ```ts
65
+ * import { defineDocsCollections } from "@uxfront/layer-docs/content";
66
+ * import { DOCS_SECTIONS } from "./app/constants/sections";
67
+ * export default defineDocsCollections(DOCS_SECTIONS);
68
+ * ```
69
+ *
70
+ * Produces one `landing` collection (root markdown) plus one `docs_<key>`
71
+ * collection per section. When `@nuxtjs/i18n` is configured with `locales`, the
72
+ * collections are generated per-locale (`landing_<code>`, `docs_<key>_<code>`)
73
+ * and sourced from a matching `content/<code>/` subtree; otherwise a single flat
74
+ * set is produced. The section topology and content stay in the consuming app.
75
+ */
76
+ export function defineDocsCollections(sections: DocsSectionDescriptor[]) {
77
+ const { options } = useNuxt();
78
+ const locales = options.i18n?.locales;
79
+
80
+ let collections: Record<string, DefinedCollection>;
81
+
82
+ if (locales && Array.isArray(locales) && locales.length > 0) {
83
+ collections = {};
84
+ for (const locale of locales) {
85
+ const code = typeof locale === "string" ? locale : locale.code;
86
+
87
+ collections[`landing_${code}`] = defineCollection({
88
+ type: "page",
89
+ source: [{ include: `${code}/*.md` }],
90
+ });
91
+
92
+ for (const section of sections) {
93
+ collections[`docs_${section.key}_${code}`] = defineCollection({
94
+ type: "page",
95
+ source: buildDocsSource(section, `${code}/`, `/${code}`),
96
+ schema: createDocsSchema(),
97
+ });
98
+ }
99
+ }
100
+ } else {
101
+ collections = {
102
+ landing: defineCollection({
103
+ type: "page",
104
+ source: [{ include: "*.md" }],
105
+ }),
106
+ };
107
+
108
+ for (const section of sections) {
109
+ collections[`docs_${section.key}`] = defineCollection({
110
+ type: "page",
111
+ source: buildDocsSource(section),
112
+ schema: createDocsSchema(),
113
+ });
114
+ }
115
+ }
116
+
117
+ return defineContentConfig({ collections });
118
+ }
package/utils/git.ts ADDED
@@ -0,0 +1,114 @@
1
+ import { execSync } from "node:child_process";
2
+ import { readGitConfig } from "pkg-types";
3
+ import gitUrlParse from "git-url-parse";
4
+
5
+ export interface GitInfo {
6
+ // Repository name
7
+ name: string;
8
+ // Repository owner/organization
9
+ owner: string;
10
+ // Repository URL
11
+ url: string;
12
+ }
13
+
14
+ export function getGitBranch() {
15
+ const envName =
16
+ process.env.CF_PAGES_BRANCH ||
17
+ process.env.CI_COMMIT_BRANCH ||
18
+ process.env.VERCEL_GIT_COMMIT_REF ||
19
+ process.env.BRANCH ||
20
+ process.env.GITHUB_REF_NAME;
21
+
22
+ if (envName && envName !== "HEAD") {
23
+ return envName;
24
+ }
25
+ try {
26
+ const branch = execSync("git rev-parse --abbrev-ref HEAD", {
27
+ stdio: ["ignore", "pipe", "ignore"],
28
+ })
29
+ .toString()
30
+ .trim();
31
+ if (branch && branch !== "HEAD") {
32
+ return branch;
33
+ }
34
+ } catch {
35
+ // Ignore error
36
+ }
37
+
38
+ return "main";
39
+ }
40
+
41
+ export async function getLocalGitInfo(rootDir: string): Promise<GitInfo | undefined> {
42
+ const remote = await getLocalGitRemote(rootDir);
43
+ if (!remote) {
44
+ return;
45
+ }
46
+
47
+ // https://www.npmjs.com/package/git-url-parse#clipboard-example
48
+ const { name, owner, source } = gitUrlParse(remote);
49
+ const url = `https://${source}/${owner}/${name}`;
50
+
51
+ return {
52
+ name,
53
+ owner,
54
+ url,
55
+ };
56
+ }
57
+
58
+ async function getLocalGitRemote(dir: string): Promise<string | undefined> {
59
+ try {
60
+ const parsed = await readGitConfig(dir);
61
+ if (!parsed) {
62
+ return;
63
+ }
64
+ return parsed.remote?.["origin"]?.url;
65
+ } catch {
66
+ // Ignore error
67
+ }
68
+ }
69
+
70
+ export function getGitEnv(): GitInfo {
71
+ // https://github.com/unjs/std-env/issues/59
72
+ const envInfo = {
73
+ // Provider
74
+ provider:
75
+ process.env.VERCEL_GIT_PROVIDER || // vercel
76
+ (process.env.GITHUB_SERVER_URL ? "github" : undefined) || // github
77
+ "",
78
+ // Owner
79
+ owner:
80
+ process.env.VERCEL_GIT_REPO_OWNER || // vercel
81
+ process.env.GITHUB_REPOSITORY_OWNER || // github
82
+ process.env.CI_PROJECT_PATH?.split("/").shift() || // gitlab
83
+ "",
84
+ // Name
85
+ name:
86
+ process.env.VERCEL_GIT_REPO_SLUG ||
87
+ process.env.GITHUB_REPOSITORY?.split("/").pop() || // github
88
+ process.env.CI_PROJECT_PATH?.split("/").splice(1).join("/") || // gitlab
89
+ "",
90
+ // Url
91
+ url: process.env.REPOSITORY_URL || "", // netlify
92
+ };
93
+
94
+ if (!envInfo.url && envInfo.provider && envInfo.owner && envInfo.name) {
95
+ envInfo.url = `https://${envInfo.provider}.com/${envInfo.owner}/${envInfo.name}`;
96
+ }
97
+
98
+ // If only url available (ex: Netlify)
99
+ if (!envInfo.name && !envInfo.owner && envInfo.url) {
100
+ try {
101
+ const { name, owner } = gitUrlParse(envInfo.url);
102
+ envInfo.name = name;
103
+ envInfo.owner = owner;
104
+ } catch {
105
+ // Ignore error
106
+ }
107
+ }
108
+
109
+ return {
110
+ name: envInfo.name,
111
+ owner: envInfo.owner,
112
+ url: envInfo.url,
113
+ };
114
+ }
package/utils/meta.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+
4
+ export function inferSiteURL() {
5
+ // https://github.com/unjs/std-env/issues/59
6
+ return (
7
+ process.env.NUXT_SITE_URL ||
8
+ (process.env.NEXT_PUBLIC_VERCEL_URL && `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`) || // Vercel
9
+ process.env.URL || // Netlify
10
+ process.env.CI_PAGES_URL || // Gitlab Pages
11
+ process.env.CF_PAGES_URL // Cloudflare Pages
12
+ );
13
+ }
14
+
15
+ export async function getPackageJsonMetadata(dir: string) {
16
+ try {
17
+ const packageJson = await readFile(resolve(dir, "package.json"), "utf-8");
18
+ const parsed = JSON.parse(packageJson);
19
+ return {
20
+ name: parsed.name,
21
+ description: parsed.description,
22
+ };
23
+ } catch {
24
+ return {
25
+ name: "docs",
26
+ };
27
+ }
28
+ }