@uxfront/layer-docs 0.3.0 → 0.4.1

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,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
@@ -56,6 +56,7 @@ export default defineNuxtConfig({
56
56
  "@nuxtjs/sitemap",
57
57
  "@nuxt/content",
58
58
  "nuxt-llms",
59
+ "nuxt-og-image",
59
60
  ],
60
61
  icon: {
61
62
  serverBundle: "local",
@@ -128,6 +129,24 @@ export default defineNuxtConfig({
128
129
  nitroConfig.prerender.routes.push(...routes);
129
130
  },
130
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
+ },
131
150
  // Generic sitemap defaults; brand values (url/name) come from the consumer's
132
151
  // `site` config or are inferred in `modules/config`.
133
152
  sitemap: {
@@ -145,6 +164,18 @@ export default defineNuxtConfig({
145
164
  host: "https://us.i.posthog.com",
146
165
  defaults: "2025-05-24",
147
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: "",
148
179
  },
149
180
  },
150
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.3.0",
3
+ "version": "0.4.1",
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,7 @@
23
23
  "i18n",
24
24
  "modules",
25
25
  "server",
26
+ "storybook",
26
27
  "test",
27
28
  "utils",
28
29
  "nuxt.config.ts",
@@ -44,6 +45,10 @@
44
45
  "types": "./utils/content.ts",
45
46
  "import": "./utils/content.ts"
46
47
  },
48
+ "./storybook": {
49
+ "types": "./storybook/index.ts",
50
+ "import": "./storybook/index.ts"
51
+ },
47
52
  "./test": {
48
53
  "types": "./test/brand-palette.ts",
49
54
  "import": "./test/brand-palette.ts"
@@ -78,9 +83,12 @@
78
83
  "@nuxtjs/mdc": "^0.22.0",
79
84
  "@nuxtjs/robots": "^6.1.1",
80
85
  "@nuxtjs/sitemap": "^8.2.1",
86
+ "@resvg/resvg-js": "^2.6.0",
81
87
  "nuxt": "^4.4.8",
82
88
  "nuxt-llms": "^0.2.0",
89
+ "nuxt-og-image": "^6.6.0",
83
90
  "posthog-js": "^1.386.6",
91
+ "satori": "^0.26.0",
84
92
  "tailwindcss": "^4.3.1",
85
93
  "typescript": "^6.0.3",
86
94
  "vitest": "^4.1.10",
@@ -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
+ });