@uxfront/layer-docs 0.3.0 → 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.
- package/CHANGELOG.md +33 -0
- package/README.md +109 -2
- package/app/components/OgImage/OgImageDocs.satori.vue +40 -0
- package/app/components/OgImage/OgImageLanding.satori.vue +41 -0
- package/app/components/app/AppOgDecoration.vue +27 -0
- package/app/components/app/AppOgLogo.vue +19 -0
- package/app/components/content/StorybookEmbed.vue +160 -0
- package/app/pages/[[lang]]/[...slug].vue +10 -0
- package/app/pages/[[lang]]/docs/[section]/[...slug].vue +6 -0
- package/app/utils/storybookEmbed.test.ts +98 -0
- package/app/utils/storybookEmbed.ts +93 -0
- package/modules/config.ts +22 -0
- package/nuxt.config.ts +31 -0
- package/nuxt.schema.ts +15 -0
- package/package.json +9 -1
- package/storybook/index.test.ts +110 -0
- package/storybook/index.ts +362 -0
- package/test/brand-palette.ts +11 -0
- package/test/no-brand-leakage.test.ts +124 -0
- package/utils/accent.ts +80 -0
|
@@ -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
|
+
"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,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
|
+
});
|