@uxfront/layer-docs 0.2.1 → 0.4.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.
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildStorybookEmbedUrl,
4
+ resolveStorybookBaseUrl,
5
+ STORYBOOK_EMBED_DEFAULT_HEIGHTS,
6
+ storybookEmbedCssHeight,
7
+ } from "./storybookEmbed";
8
+
9
+ const TEMPLATE = "https://{framework}.storybook.example.com";
10
+ const STORY = "components-actions-button--default";
11
+
12
+ describe("resolveStorybookBaseUrl", () => {
13
+ it("substitutes every occurrence of the framework placeholder", () => {
14
+ expect(resolveStorybookBaseUrl("https://{framework}.example.com/{framework}", "vue")).toBe(
15
+ "https://vue.example.com/vue",
16
+ );
17
+ });
18
+
19
+ it("passes a template without a placeholder through, for a single Storybook", () => {
20
+ expect(resolveStorybookBaseUrl("https://storybook.example.com", "vue")).toBe(
21
+ "https://storybook.example.com",
22
+ );
23
+ });
24
+
25
+ it("trims trailing slashes so the built URL never doubles them", () => {
26
+ expect(resolveStorybookBaseUrl("https://storybook.example.com//")).toBe(
27
+ "https://storybook.example.com",
28
+ );
29
+ });
30
+ });
31
+
32
+ describe("buildStorybookEmbedUrl", () => {
33
+ it("addresses the preview surface by default and marks itself as a direct embed", () => {
34
+ expect(buildStorybookEmbedUrl({ template: TEMPLATE, story: STORY, framework: "vue" })).toBe(
35
+ `https://vue.storybook.example.com/iframe.html?id=${STORY}&viewMode=story&docsEmbed=1`,
36
+ );
37
+ });
38
+
39
+ // `full` and `panel` reproduce the manager URLs the migrating docs site
40
+ // already ships; a changed parameter here is a changed rendering there.
41
+ it("addresses the manager in full mode", () => {
42
+ expect(
43
+ buildStorybookEmbedUrl({ template: TEMPLATE, story: STORY, framework: "vue", mode: "full" }),
44
+ ).toBe(
45
+ `https://vue.storybook.example.com/?path=/story/${STORY}&full=1&shortcuts=false&singleStory=true`,
46
+ );
47
+ });
48
+
49
+ it("addresses the manager with its addon panel in panel mode", () => {
50
+ expect(
51
+ buildStorybookEmbedUrl({ template: TEMPLATE, story: STORY, framework: "vue", mode: "panel" }),
52
+ ).toBe(
53
+ `https://vue.storybook.example.com/?path=/story/${STORY}&shortcuts=false&singleStory=true`,
54
+ );
55
+ });
56
+
57
+ it("only marks the preview surface — a manager embed is relayed, not direct", () => {
58
+ for (const mode of ["full", "panel"] as const) {
59
+ expect(buildStorybookEmbedUrl({ template: TEMPLATE, story: STORY, mode })).not.toContain(
60
+ "docsEmbed",
61
+ );
62
+ }
63
+ });
64
+
65
+ it("encodes the story id rather than splicing it into the query raw", () => {
66
+ expect(
67
+ buildStorybookEmbedUrl({ template: "https://sb.example.com", story: "a&b=c" }),
68
+ ).toContain("id=a%26b%3Dc&");
69
+ });
70
+
71
+ it("degrades to a same-origin URL when no consumer configured a host", () => {
72
+ expect(buildStorybookEmbedUrl({ template: "", story: STORY })).toBe(
73
+ `/iframe.html?id=${STORY}&viewMode=story&docsEmbed=1`,
74
+ );
75
+ });
76
+ });
77
+
78
+ describe("STORYBOOK_EMBED_DEFAULT_HEIGHTS", () => {
79
+ // Reserved before the story reports anything — the space that keeps the embed
80
+ // from shifting the page. Panel carries the addon panel on top of the story.
81
+ it("reserves more room for the panel surface than the bare story", () => {
82
+ expect(STORYBOOK_EMBED_DEFAULT_HEIGHTS).toEqual({ preview: 320, full: 320, panel: 600 });
83
+ });
84
+ });
85
+
86
+ describe("storybookEmbedCssHeight", () => {
87
+ it("reads a bare number as pixels, in either type markdown may deliver", () => {
88
+ expect(storybookEmbedCssHeight(420)).toBe("420px");
89
+ expect(storybookEmbedCssHeight("420")).toBe("420px");
90
+ expect(storybookEmbedCssHeight(" 420 ")).toBe("420px");
91
+ });
92
+
93
+ it("passes a CSS length through untouched", () => {
94
+ expect(storybookEmbedCssHeight("30rem")).toBe("30rem");
95
+ expect(storybookEmbedCssHeight("420px")).toBe("420px");
96
+ expect(storybookEmbedCssHeight("min(80vh, 600px)")).toBe("min(80vh, 600px)");
97
+ });
98
+ });
@@ -0,0 +1,93 @@
1
+ import { DOCS_EMBED_PARAM } from "../../storybook";
2
+
3
+ /**
4
+ * Which Storybook surface a `StorybookEmbed` addresses.
5
+ *
6
+ * The three values are not styling choices — each is a different URL against a
7
+ * different Storybook surface, and the height signal reaches the page by a
8
+ * different route in each. See `@uxfront/layer-docs/storybook`.
9
+ *
10
+ * - `preview` — `iframe.html`, the story on its own with no Storybook UI.
11
+ * - `full` — the manager in full-canvas mode: story plus toolbar, no panel.
12
+ * - `panel` — the manager with its addon panel, for stories documented through
13
+ * controls or actions.
14
+ */
15
+ export type StorybookEmbedMode = "preview" | "full" | "panel";
16
+
17
+ /**
18
+ * Height an embed occupies before Storybook reports its own, and the height it
19
+ * keeps if no bridge is installed on the Storybook side.
20
+ *
21
+ * The panel default is taller because the addon panel is a fixed cost on top of
22
+ * the story. Reserving the space up front is what keeps the embed from shifting
23
+ * the page when the real height arrives.
24
+ */
25
+ export const STORYBOOK_EMBED_DEFAULT_HEIGHTS: Record<StorybookEmbedMode, number> = {
26
+ preview: 320,
27
+ full: 320,
28
+ panel: 600,
29
+ };
30
+
31
+ /**
32
+ * Resolve a Storybook base URL from a template.
33
+ *
34
+ * A `{framework}` placeholder addresses a per-framework Storybook deployment
35
+ * (`https://{framework}.storybook.example.com`); a template without one is used
36
+ * as-is, so a single-Storybook site needs no placeholder.
37
+ */
38
+ export function resolveStorybookBaseUrl(template: string, framework?: string): string {
39
+ return template.replaceAll("{framework}", framework ?? "").replace(/\/+$/, "");
40
+ }
41
+
42
+ export interface StorybookEmbedUrlOptions {
43
+ /** Base URL template, optionally containing `{framework}`. */
44
+ template: string;
45
+ /** Storybook story id, e.g. `components-actions-button--default`. */
46
+ story: string;
47
+ /** Framework substituted into the template's `{framework}` placeholder. */
48
+ framework?: string;
49
+ /** Storybook surface to address. Defaults to `preview`. */
50
+ mode?: StorybookEmbedMode;
51
+ }
52
+
53
+ /**
54
+ * Build the `src` for an embedded story.
55
+ *
56
+ * `preview` is marked with `docsEmbed=1`: embedded that way the docs page is
57
+ * the preview's direct parent, so the preview bridge must post the final height
58
+ * itself rather than the intermediate one it sends to a manager.
59
+ *
60
+ * An empty template yields a root-relative URL rather than a broken absolute
61
+ * one, which is what an unconfigured consumer gets — a same-origin 404 it can
62
+ * see, not a silent request to nowhere.
63
+ */
64
+ export function buildStorybookEmbedUrl(options: StorybookEmbedUrlOptions): string {
65
+ const { template, story, framework, mode = "preview" } = options;
66
+
67
+ const base = resolveStorybookBaseUrl(template, framework);
68
+ const id = encodeURIComponent(story);
69
+
70
+ if (mode === "preview") {
71
+ return `${base}/iframe.html?id=${id}&viewMode=story&${DOCS_EMBED_PARAM}=1`;
72
+ }
73
+
74
+ // `singleStory` hides the sidebar, `shortcuts` stops the embed from
75
+ // swallowing keystrokes meant for the page around it, and `full` drops the
76
+ // addon panel.
77
+ const full = mode === "full" ? "&full=1" : "";
78
+
79
+ return `${base}/?path=/story/${id}${full}&shortcuts=false&singleStory=true`;
80
+ }
81
+
82
+ /**
83
+ * Coerce an author-supplied height to a CSS length. A bare number — in either
84
+ * type, since markdown attributes arrive as strings — means pixels; anything
85
+ * else is already a CSS length and is passed through.
86
+ */
87
+ export function storybookEmbedCssHeight(height: number | string): string {
88
+ if (typeof height === "number") return `${height}px`;
89
+
90
+ const trimmed = height.trim();
91
+
92
+ return /^\d+(\.\d+)?$/.test(trimmed) ? `${trimmed}px` : trimmed;
93
+ }
package/modules/config.ts CHANGED
@@ -4,6 +4,7 @@ import { existsSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { inferSiteURL, getPackageJsonMetadata } from "../utils/meta";
6
6
  import { getGitBranch, getGitEnv, getLocalGitInfo } from "../utils/git";
7
+ import { resolveBrandAccent } from "../utils/accent";
7
8
 
8
9
  export default defineNuxtModule({
9
10
  meta: {
@@ -51,6 +52,27 @@ export default defineNuxtModule({
51
52
  branch: getGitBranch(),
52
53
  });
53
54
 
55
+ /*
56
+ ** OG IMAGE ACCENT
57
+ **
58
+ ** Bind the card accent to the same token the site uses — see
59
+ ** `utils/accent.ts` for why it has to be a literal by the time satori
60
+ ** runs. Written as a default: a consumer that sets `ogImage.accent` in its
61
+ ** own `app.config.ts` still wins on the layer merge, and one that sets
62
+ ** nothing gets a branded card with no configuration at all.
63
+ */
64
+ const { accent, reason } = resolveBrandAccent({
65
+ cssEntries: nuxt.options.css ?? [],
66
+ rootDir: dir,
67
+ });
68
+ if (reason) {
69
+ console.warn(
70
+ `[Docus] OG image accent falls back to ${accent}: ${reason}. ` +
71
+ `Set \`ogImage.accent\` in app.config.ts to silence this.`,
72
+ );
73
+ }
74
+ nuxt.options.appConfig.ogImage = defu(nuxt.options.appConfig.ogImage, { accent });
75
+
54
76
  /*
55
77
  ** I18N
56
78
  */
package/nuxt.config.ts CHANGED
@@ -14,12 +14,31 @@ const { resolve } = createResolver(import.meta.url);
14
14
  * locale site "just works". A consumer that wants localisation registers the
15
15
  * module itself; `modules/config` + `useDocusI18n` pick it up automatically.
16
16
  *
17
+ * ## Styling: the consumer owns the single Tailwind entry
18
+ *
19
+ * This layer deliberately does NOT register `app/assets/css/main.css` in `css`.
20
+ * Its base is palette-free, so a consumer's brand `@theme` only compiles if it
21
+ * lives inside the same Tailwind pass — which means the consumer's CSS file has
22
+ * to *import* this base, not sit beside it. Registering it here as well gave
23
+ * every consumer two Tailwind entries and a byte-for-byte duplicate of every
24
+ * base utility in the shipped stylesheet (+26.7 KB gzip on inkline, UXF-118),
25
+ * and left each consumer to un-register what the layer had just registered.
26
+ *
27
+ * Consumers therefore register exactly one CSS entry, their own:
28
+ *
29
+ * ```ts
30
+ * // nuxt.config.ts
31
+ * css: ["./app/assets/css/main.css"],
32
+ * ```
33
+ *
34
+ * whose first line imports this base, followed by the brand `@theme`. Both
35
+ * halves are guarded by `@uxfront/layer-docs/test`; see README § Styling.
36
+ *
17
37
  * https://nuxt.com/docs/getting-started/layers
18
38
  */
19
39
  export default defineNuxtConfig({
20
40
  compatibilityDate: "2025-07-22",
21
41
  telemetry: false,
22
- css: [resolve("./app/assets/css/main.css")],
23
42
  // Expose `app/constants/` (DOCS_SECTIONS, findDocsSectionBySlug) as
24
43
  // auto-imports. The layer ships a neutral empty default; a consumer's own
25
44
  // `app/constants/` overrides it via auto-import dir precedence.
@@ -37,6 +56,7 @@ export default defineNuxtConfig({
37
56
  "@nuxtjs/sitemap",
38
57
  "@nuxt/content",
39
58
  "nuxt-llms",
59
+ "nuxt-og-image",
40
60
  ],
41
61
  icon: {
42
62
  serverBundle: "local",
@@ -109,6 +129,24 @@ export default defineNuxtConfig({
109
129
  nitroConfig.prerender.routes.push(...routes);
110
130
  },
111
131
  },
132
+ /**
133
+ * OG images are on by default — same class of SEO plumbing as sitemap and
134
+ * robots, which this layer also registers unasked. A consumer opts out with
135
+ * the module's own `ogImage: { enabled: false }`; no layer-specific key.
136
+ *
137
+ * Prerendering bakes every card at build time, so no runtime generation
138
+ * endpoint is needed (or exposed unsigned).
139
+ * @docs https://nuxtseo.com/og-image/guides/zero-runtime
140
+ */
141
+ ogImage: {
142
+ zeroRuntime: true,
143
+ // 1.91:1, the ratio every major crawler crops to. The module's own default
144
+ // is 1200×600 (2:1), which gets letterboxed or trimmed on most previews.
145
+ defaults: {
146
+ width: 1200,
147
+ height: 630,
148
+ },
149
+ },
112
150
  // Generic sitemap defaults; brand values (url/name) come from the consumer's
113
151
  // `site` config or are inferred in `modules/config`.
114
152
  sitemap: {
@@ -126,6 +164,18 @@ export default defineNuxtConfig({
126
164
  host: "https://us.i.posthog.com",
127
165
  defaults: "2025-05-24",
128
166
  },
167
+ // Where `StorybookEmbed` points. Empty here — the host is a consumer
168
+ // fact, and where a Storybook is deployed changes without a code change,
169
+ // so this is runtime config (`NUXT_PUBLIC_STORYBOOK_BASE_URL`) rather
170
+ // than app config. A `{framework}` placeholder addresses per-framework
171
+ // deployments; a template without one is used as-is.
172
+ storybookBaseUrl: "",
173
+ // Migration-only: a brand namespace whose `<ns>:theme` / `<ns>:height`
174
+ // messages a consumer's Storybook already speaks. Set it and the embed
175
+ // emits and accepts both those names and the neutral ones, so the docs
176
+ // site and the Storybook can deploy in either order. Unset it once both
177
+ // sides are on `@uxfront/layer-docs/storybook`.
178
+ storybookLegacyMessageNamespace: "",
129
179
  },
130
180
  },
131
181
  });
package/nuxt.schema.ts CHANGED
@@ -327,6 +327,21 @@ export default defineNuxtSchema({
327
327
  }),
328
328
  },
329
329
  }),
330
+ ogImage: group({
331
+ title: "OG image",
332
+ description: "Social share card options.",
333
+ icon: "i-lucide-image",
334
+ fields: {
335
+ accent: field({
336
+ type: "string",
337
+ title: "Accent",
338
+ description:
339
+ "Accent colour for the OG card headline, as a literal CSS colour — satori has no custom properties, so `var(--ui-primary)` cannot be used here. Left unset, it is resolved at build time by following `--ui-primary` to the `--color-<scale>` literal it aliases in the registered CSS, so most consumers never set it.",
340
+ icon: "i-lucide-palette",
341
+ default: "",
342
+ }),
343
+ },
344
+ }),
330
345
  github: group({
331
346
  title: "GitHub",
332
347
  description: "GitHub configuration.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uxfront/layer-docs",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Neutral, brandable Nuxt-layer documentation theme. Consumers extend it and supply their own branding, content and section topology.",
5
5
  "keywords": [
6
6
  "docs",
@@ -23,6 +23,8 @@
23
23
  "i18n",
24
24
  "modules",
25
25
  "server",
26
+ "storybook",
27
+ "test",
26
28
  "utils",
27
29
  "nuxt.config.ts",
28
30
  "nuxt.schema.ts",
@@ -43,6 +45,14 @@
43
45
  "types": "./utils/content.ts",
44
46
  "import": "./utils/content.ts"
45
47
  },
48
+ "./storybook": {
49
+ "types": "./storybook/index.ts",
50
+ "import": "./storybook/index.ts"
51
+ },
52
+ "./test": {
53
+ "types": "./test/brand-palette.ts",
54
+ "import": "./test/brand-palette.ts"
55
+ },
46
56
  "./app/assets/css/main.css": "./app/assets/css/main.css",
47
57
  "./package.json": "./package.json"
48
58
  },
@@ -60,7 +70,8 @@
60
70
  "ufo": "^1.6.4"
61
71
  },
62
72
  "devDependencies": {
63
- "typescript": "^6.0.3"
73
+ "typescript": "^6.0.3",
74
+ "vitest": "^4.1.10"
64
75
  },
65
76
  "peerDependencies": {
66
77
  "@nuxt/content": "^3.14.0",
@@ -72,11 +83,15 @@
72
83
  "@nuxtjs/mdc": "^0.22.0",
73
84
  "@nuxtjs/robots": "^6.1.1",
74
85
  "@nuxtjs/sitemap": "^8.2.1",
86
+ "@resvg/resvg-js": "^2.6.0",
75
87
  "nuxt": "^4.4.8",
76
88
  "nuxt-llms": "^0.2.0",
89
+ "nuxt-og-image": "^6.6.0",
77
90
  "posthog-js": "^1.386.6",
91
+ "satori": "^0.26.0",
78
92
  "tailwindcss": "^4.3.1",
79
93
  "typescript": "^6.0.3",
94
+ "vitest": "^4.1.10",
80
95
  "vue": "^3.5.38"
81
96
  },
82
97
  "peerDependenciesMeta": {
@@ -91,6 +106,9 @@
91
106
  },
92
107
  "typescript": {
93
108
  "optional": true
109
+ },
110
+ "vitest": {
111
+ "optional": true
94
112
  }
95
113
  },
96
114
  "scripts": {
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ DOCS_EMBED_HEIGHT_MESSAGE,
4
+ DOCS_EMBED_PREVIEW_HEIGHT_MESSAGE,
5
+ DOCS_EMBED_THEME_MESSAGE,
6
+ docsEmbedMessageNames,
7
+ readDocsEmbedHeight,
8
+ readDocsEmbedTheme,
9
+ resolveDocsEmbedRelayHeight,
10
+ } from "./index.ts";
11
+
12
+ describe("docsEmbedMessageNames", () => {
13
+ it("uses the neutral names alone when no legacy namespace is configured", () => {
14
+ expect(docsEmbedMessageNames()).toEqual({
15
+ theme: [DOCS_EMBED_THEME_MESSAGE],
16
+ previewHeight: [DOCS_EMBED_PREVIEW_HEIGHT_MESSAGE],
17
+ height: [DOCS_EMBED_HEIGHT_MESSAGE],
18
+ });
19
+ });
20
+
21
+ it("adds the legacy names for the compat window, neutral first", () => {
22
+ expect(docsEmbedMessageNames("acme")).toEqual({
23
+ theme: [DOCS_EMBED_THEME_MESSAGE, "acme:theme"],
24
+ previewHeight: [DOCS_EMBED_PREVIEW_HEIGHT_MESSAGE, "acme:preview-height"],
25
+ height: [DOCS_EMBED_HEIGHT_MESSAGE, "acme:height"],
26
+ });
27
+ });
28
+
29
+ // An unset `NUXT_PUBLIC_*` variable arrives as "" rather than undefined, so
30
+ // the empty cases have to mean "no compat window" and not "`:theme`".
31
+ it.each(["", " ", undefined])("treats %o as no legacy namespace", (namespace) => {
32
+ expect(docsEmbedMessageNames(namespace).theme).toEqual([DOCS_EMBED_THEME_MESSAGE]);
33
+ });
34
+ });
35
+
36
+ describe("readDocsEmbedTheme", () => {
37
+ const names = docsEmbedMessageNames("acme").theme;
38
+
39
+ it("reads the neutral name", () => {
40
+ expect(readDocsEmbedTheme({ type: DOCS_EMBED_THEME_MESSAGE, theme: "dark" }, names)).toBe(
41
+ "dark",
42
+ );
43
+ });
44
+
45
+ it("reads the legacy name, so a stale docs deploy still themes the story", () => {
46
+ expect(readDocsEmbedTheme({ type: "acme:theme", theme: "dark" }, names)).toBe("dark");
47
+ });
48
+
49
+ it("ignores a legacy name once the compat window is closed", () => {
50
+ const neutralOnly = docsEmbedMessageNames().theme;
51
+
52
+ expect(readDocsEmbedTheme({ type: "acme:theme", theme: "dark" }, neutralOnly)).toBeNull();
53
+ });
54
+
55
+ it("ignores unrelated traffic on the same channel", () => {
56
+ expect(readDocsEmbedTheme({ type: "webpackHotUpdate" }, names)).toBeNull();
57
+ expect(readDocsEmbedTheme("a string message", names)).toBeNull();
58
+ expect(readDocsEmbedTheme(null, names)).toBeNull();
59
+ expect(readDocsEmbedTheme({ theme: "dark" }, names)).toBeNull();
60
+ });
61
+
62
+ it("falls back to light for any theme that is not exactly dark", () => {
63
+ expect(readDocsEmbedTheme({ type: DOCS_EMBED_THEME_MESSAGE, theme: "sepia" }, names)).toBe(
64
+ "light",
65
+ );
66
+ expect(readDocsEmbedTheme({ type: DOCS_EMBED_THEME_MESSAGE }, names)).toBe("light");
67
+ });
68
+ });
69
+
70
+ describe("readDocsEmbedHeight", () => {
71
+ const names = docsEmbedMessageNames("acme").height;
72
+
73
+ it("reads both the neutral and the legacy name", () => {
74
+ expect(readDocsEmbedHeight({ type: DOCS_EMBED_HEIGHT_MESSAGE, height: 420 }, names)).toBe(420);
75
+ expect(readDocsEmbedHeight({ type: "acme:height", height: 420 }, names)).toBe(420);
76
+ });
77
+
78
+ it("does not confuse the preview leg with the docs leg", () => {
79
+ expect(
80
+ readDocsEmbedHeight({ type: DOCS_EMBED_PREVIEW_HEIGHT_MESSAGE, height: 420 }, names),
81
+ ).toBeNull();
82
+ });
83
+
84
+ // These reach a style attribute, so a bad one is a collapsed or absurd frame
85
+ // rather than an exception.
86
+ it.each([0, -10, Number.NaN, Number.POSITIVE_INFINITY, "420", null, undefined])(
87
+ "rejects %o as a height",
88
+ (height) => {
89
+ expect(readDocsEmbedHeight({ type: DOCS_EMBED_HEIGHT_MESSAGE, height }, names)).toBeNull();
90
+ },
91
+ );
92
+ });
93
+
94
+ describe("resolveDocsEmbedRelayHeight", () => {
95
+ it("adds only padding in full mode, where no manager chrome is visible", () => {
96
+ expect(resolveDocsEmbedRelayHeight({ contentHeight: 300 })).toBe(364);
97
+ });
98
+
99
+ it("adds the measured chrome in panel mode", () => {
100
+ expect(resolveDocsEmbedRelayHeight({ contentHeight: 300, chromeHeight: 120 })).toBe(484);
101
+ });
102
+
103
+ it("ignores negative chrome, which means the preview outgrew its own window", () => {
104
+ expect(resolveDocsEmbedRelayHeight({ contentHeight: 300, chromeHeight: -50 })).toBe(364);
105
+ });
106
+
107
+ it("takes an explicit padding", () => {
108
+ expect(resolveDocsEmbedRelayHeight({ contentHeight: 300, padding: 0 })).toBe(300);
109
+ });
110
+ });