@uxfront/layer-docs 0.4.0 → 0.5.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 (99) hide show
  1. package/README.md +31 -220
  2. package/app/app.config.ts +8 -92
  3. package/app/components/content/FrameworkSwitcher.vue +66 -40
  4. package/app/components/docs/DocsAsideLeftTop.vue +9 -15
  5. package/app/components/docs/DocsFrameworkSelect.vue +4 -7
  6. package/app/composables/useFramework.ts +30 -38
  7. package/nuxt.config.ts +14 -170
  8. package/package.json +10 -77
  9. package/CHANGELOG.md +0 -180
  10. package/LICENSE +0 -21
  11. package/app/app.vue +0 -138
  12. package/app/assets/css/main.css +0 -15
  13. package/app/components/IconMenuToggle.vue +0 -92
  14. package/app/components/LanguageSelect.vue +0 -73
  15. package/app/components/MorphingGradientBackground.vue +0 -261
  16. package/app/components/OgImage/OgImageDocs.satori.vue +0 -40
  17. package/app/components/OgImage/OgImageLanding.satori.vue +0 -41
  18. package/app/components/app/AppFooter.vue +0 -13
  19. package/app/components/app/AppFooterCenter.vue +0 -17
  20. package/app/components/app/AppFooterLeft.vue +0 -21
  21. package/app/components/app/AppFooterRight.vue +0 -33
  22. package/app/components/app/AppHeader.vue +0 -123
  23. package/app/components/app/AppHeaderAttribution.vue +0 -45
  24. package/app/components/app/AppHeaderBody.vue +0 -14
  25. package/app/components/app/AppHeaderCTA.vue +0 -31
  26. package/app/components/app/AppHeaderCenter.vue +0 -10
  27. package/app/components/app/AppHeaderLogo.vue +0 -16
  28. package/app/components/app/AppOgDecoration.vue +0 -27
  29. package/app/components/app/AppOgLogo.vue +0 -19
  30. package/app/components/app/AppSearch.vue +0 -59
  31. package/app/components/app/AppSubHeader.vue +0 -21
  32. package/app/components/content/BrowserFrame.vue +0 -28
  33. package/app/components/content/GradientPageHero.vue +0 -35
  34. package/app/components/content/StorybookEmbed.vue +0 -160
  35. package/app/components/content/Video.vue +0 -103
  36. package/app/components/docs/DocsAsideLeftBody.vue +0 -20
  37. package/app/components/docs/DocsAsideRightBottom.vue +0 -15
  38. package/app/components/docs/DocsPageHeaderLinks.vue +0 -75
  39. package/app/composables/useDocsSections.ts +0 -57
  40. package/app/composables/useDocusI18n.ts +0 -49
  41. package/app/constants/sections.ts +0 -25
  42. package/app/error.vue +0 -140
  43. package/app/layouts/default.vue +0 -24
  44. package/app/pages/[[lang]]/[...slug].vue +0 -58
  45. package/app/pages/[[lang]]/docs/[section]/[...slug].vue +0 -180
  46. package/app/plugins/i18n.ts +0 -21
  47. package/app/plugins/posthog.client.ts +0 -56
  48. package/app/types/non-route-categories.ts +0 -12
  49. package/app/utils/flattenNavigation.ts +0 -22
  50. package/app/utils/foldNonRouteCategories.ts +0 -47
  51. package/app/utils/prerender.ts +0 -9
  52. package/app/utils/storybookEmbed.test.ts +0 -98
  53. package/app/utils/storybookEmbed.ts +0 -93
  54. package/i18n/locales/ar.json +0 -24
  55. package/i18n/locales/be.json +0 -24
  56. package/i18n/locales/bn.json +0 -24
  57. package/i18n/locales/ca.json +0 -24
  58. package/i18n/locales/ckb.json +0 -24
  59. package/i18n/locales/cs.json +0 -24
  60. package/i18n/locales/da.json +0 -24
  61. package/i18n/locales/de.json +0 -24
  62. package/i18n/locales/el.json +0 -24
  63. package/i18n/locales/en.json +0 -24
  64. package/i18n/locales/et.json +0 -24
  65. package/i18n/locales/fr.json +0 -24
  66. package/i18n/locales/he.json +0 -24
  67. package/i18n/locales/hi.json +0 -24
  68. package/i18n/locales/hy.json +0 -24
  69. package/i18n/locales/it.json +0 -24
  70. package/i18n/locales/ja.json +0 -24
  71. package/i18n/locales/kk.json +0 -24
  72. package/i18n/locales/km.json +0 -24
  73. package/i18n/locales/ko.json +0 -24
  74. package/i18n/locales/ky.json +0 -24
  75. package/i18n/locales/lb.json +0 -24
  76. package/i18n/locales/ms.json +0 -24
  77. package/i18n/locales/nb.json +0 -24
  78. package/i18n/locales/pl.json +0 -24
  79. package/i18n/locales/ru.json +0 -24
  80. package/i18n/locales/sl.json +0 -24
  81. package/i18n/locales/sv.json +0 -24
  82. package/i18n/locales/uk.json +0 -24
  83. package/i18n/locales/ur.json +0 -24
  84. package/i18n/locales/vi.json +0 -24
  85. package/modules/config.ts +0 -144
  86. package/modules/optimizeDeps.ts +0 -45
  87. package/modules/routing.ts +0 -20
  88. package/nuxt.schema.ts +0 -374
  89. package/server/plugins/llms-redirect.ts +0 -60
  90. package/server/routes/raw/[...slug].md.get.ts +0 -74
  91. package/storybook/index.test.ts +0 -110
  92. package/storybook/index.ts +0 -362
  93. package/test/brand-palette.ts +0 -235
  94. package/test/no-brand-leakage.test.ts +0 -124
  95. package/tsconfig.json +0 -17
  96. package/utils/accent.ts +0 -80
  97. package/utils/content.ts +0 -193
  98. package/utils/git.ts +0 -114
  99. package/utils/meta.ts +0 -28
@@ -1,74 +0,0 @@
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
- });
@@ -1,110 +0,0 @@
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
- });
@@ -1,362 +0,0 @@
1
- /**
2
- * `@uxfront/layer-docs/storybook` — the Storybook-side half of the docs embed
3
- * contract.
4
- *
5
- * The docs page and the Storybook it embeds talk over `postMessage`, and they
6
- * deploy independently. That makes the message names a contract between two
7
- * repositories, so they live here — imported by both sides — rather than being
8
- * retyped as string literals in a manager config, a preview config and a Vue
9
- * component that can each drift on their own.
10
- *
11
- * This module is deliberately framework-free: no Vue, no Nuxt, no Storybook
12
- * imports. It runs inside a Storybook manager or preview bundle, where none of
13
- * those are guaranteed and the Storybook API surface differs per framework. The
14
- * addon-specific work — flipping a dark-mode addon, updating a theme store —
15
- * stays in the consumer's config and is reached through the `onTheme` callback.
16
- *
17
- * ## The two topologies
18
- *
19
- * A docs page embeds one of two Storybook surfaces, and the height signal takes
20
- * a different route through each:
21
- *
22
- * - **`full` / `panel`** — the page embeds the Storybook *manager*
23
- * (`/?path=/story/…`). The preview iframe measures the story and posts
24
- * {@link DOCS_EMBED_PREVIEW_HEIGHT_MESSAGE} up to the manager, which adds its
25
- * own chrome and padding and posts {@link DOCS_EMBED_HEIGHT_MESSAGE} to the
26
- * docs page.
27
- * - **`preview`** — the page embeds `iframe.html` directly. No manager exists
28
- * to relay, so the component marks the URL with `{@link DOCS_EMBED_PARAM}=1`
29
- * and the preview bridge posts {@link DOCS_EMBED_HEIGHT_MESSAGE} straight to
30
- * its parent.
31
- *
32
- * Either way exactly one signal name reaches the docs page.
33
- *
34
- * ## The compat window
35
- *
36
- * A docs site that already ships its own branded message names sets
37
- * `legacyNamespace`. Every bridge then **accepts both** names on the messages
38
- * it receives and **emits both** on the messages it sends, so a stale deploy on
39
- * either side keeps working. Handling is idempotent — the same theme or height
40
- * applied twice is the same state — so a peer that understands both names
41
- * simply acts on whichever arrives first with no visible difference.
42
- *
43
- * Drop the option once both sides are on the neutral names.
44
- *
45
- * @example Storybook manager config
46
- * ```ts
47
- * // .storybook/manager.ts
48
- * import { installDocsEmbedManagerBridge } from "@uxfront/layer-docs/storybook";
49
- *
50
- * installDocsEmbedManagerBridge({
51
- * legacyNamespace: "acme",
52
- * onTheme: (theme) => applyManagerTheme(theme),
53
- * });
54
- * ```
55
- *
56
- * @example Storybook preview config
57
- * ```ts
58
- * // .storybook/preview.ts
59
- * import { installDocsEmbedPreviewBridge } from "@uxfront/layer-docs/storybook";
60
- *
61
- * installDocsEmbedPreviewBridge({
62
- * legacyNamespace: "acme",
63
- * onTheme: (theme) => channel.emit(DARK_MODE_EVENT_NAME, theme === "dark"),
64
- * });
65
- * ```
66
- */
67
-
68
- /** Docs page → Storybook: the colour mode the embedding page is displaying. */
69
- export const DOCS_EMBED_THEME_MESSAGE = "uxfront:docs-embed:theme";
70
-
71
- /** Storybook preview → Storybook manager: the measured height of the story. */
72
- export const DOCS_EMBED_PREVIEW_HEIGHT_MESSAGE = "uxfront:docs-embed:preview-height";
73
-
74
- /** Storybook → docs page: the height the embedding iframe should be given. */
75
- export const DOCS_EMBED_HEIGHT_MESSAGE = "uxfront:docs-embed:height";
76
-
77
- /**
78
- * URL flag the docs component appends when it embeds `iframe.html` directly.
79
- * Its presence tells the preview bridge that no manager sits between it and the
80
- * docs page, so it must post the final height itself.
81
- */
82
- export const DOCS_EMBED_PARAM = "docsEmbed";
83
-
84
- /** Storybook's story container element in the preview iframe. */
85
- const STORYBOOK_ROOT_ID = "storybook-root";
86
-
87
- /** Storybook's preview iframe element inside the manager. */
88
- const STORYBOOK_PREVIEW_IFRAME_ID = "storybook-preview-iframe";
89
-
90
- /**
91
- * Breathing room the manager adds around the measured story so the embedded
92
- * frame does not clip against its own border.
93
- */
94
- const DEFAULT_PADDING = 64;
95
-
96
- export type DocsEmbedTheme = "light" | "dark";
97
-
98
- /** Every message name accepted and emitted for one leg of the contract. */
99
- export interface DocsEmbedMessageNames {
100
- /** Docs page → Storybook theme message. */
101
- theme: readonly string[];
102
- /** Preview → manager height message. */
103
- previewHeight: readonly string[];
104
- /** Storybook → docs page height message. */
105
- height: readonly string[];
106
- }
107
-
108
- /**
109
- * The message names in play, newest first.
110
- *
111
- * With no `legacyNamespace` that is one name per leg. With one — say `"acme"` —
112
- * each leg also accepts and emits `acme:theme`, `acme:preview-height` and
113
- * `acme:height`, which is the compat window described in the module docs.
114
- */
115
- export function docsEmbedMessageNames(legacyNamespace?: string): DocsEmbedMessageNames {
116
- const legacy = legacyNamespace?.trim();
117
-
118
- if (!legacy) {
119
- return {
120
- theme: [DOCS_EMBED_THEME_MESSAGE],
121
- previewHeight: [DOCS_EMBED_PREVIEW_HEIGHT_MESSAGE],
122
- height: [DOCS_EMBED_HEIGHT_MESSAGE],
123
- };
124
- }
125
-
126
- return {
127
- theme: [DOCS_EMBED_THEME_MESSAGE, `${legacy}:theme`],
128
- previewHeight: [DOCS_EMBED_PREVIEW_HEIGHT_MESSAGE, `${legacy}:preview-height`],
129
- height: [DOCS_EMBED_HEIGHT_MESSAGE, `${legacy}:height`],
130
- };
131
- }
132
-
133
- /**
134
- * Read a theme message, or `null` when the payload is not one of `names`.
135
- *
136
- * Anything that is not the string `"dark"` reads as `"light"`, matching how
137
- * every receiver in the contract has always coerced it: a malformed payload
138
- * lands on the default theme rather than throwing inside a message listener.
139
- */
140
- export function readDocsEmbedTheme(data: unknown, names: readonly string[]): DocsEmbedTheme | null {
141
- const message = asMessage(data);
142
- if (!message || !names.includes(message.type)) return null;
143
-
144
- return message.theme === "dark" ? "dark" : "light";
145
- }
146
-
147
- /**
148
- * Read a height message, or `null` when the payload is not one of `names` or
149
- * carries no usable height. Heights that are not finite positive numbers are
150
- * rejected rather than written to a style attribute.
151
- */
152
- export function readDocsEmbedHeight(data: unknown, names: readonly string[]): number | null {
153
- const message = asMessage(data);
154
- if (!message || !names.includes(message.type)) return null;
155
-
156
- const { height } = message;
157
- if (typeof height !== "number" || !Number.isFinite(height) || height <= 0) return null;
158
-
159
- return height;
160
- }
161
-
162
- /**
163
- * Height the manager reports for a story of `contentHeight`.
164
- *
165
- * `chromeHeight` is the manager UI wrapped around the preview — toolbar and
166
- * addon panel — and is `0` in `full` mode, where none of it is visible.
167
- */
168
- export function resolveDocsEmbedRelayHeight(options: {
169
- contentHeight: number;
170
- chromeHeight?: number;
171
- padding?: number;
172
- }): number {
173
- const { contentHeight, chromeHeight = 0, padding = DEFAULT_PADDING } = options;
174
-
175
- return Math.max(0, chromeHeight) + contentHeight + padding;
176
- }
177
-
178
- export interface DocsEmbedManagerBridgeOptions {
179
- /** Brand namespace to keep accepting and emitting during a migration. */
180
- legacyNamespace?: string;
181
- /** Padding added around the measured story. Defaults to 64. */
182
- padding?: number;
183
- /** Called with the theme the docs page is displaying. */
184
- onTheme?: (theme: DocsEmbedTheme) => void;
185
- }
186
-
187
- /**
188
- * Install the manager half: relay the docs page's theme down to the preview
189
- * iframe, and relay the preview's measured height up to the docs page.
190
- *
191
- * Only relays height when the manager is itself framed — a Storybook opened
192
- * directly has no docs page to report to. Returns a teardown function.
193
- */
194
- export function installDocsEmbedManagerBridge(
195
- options: DocsEmbedManagerBridgeOptions = {},
196
- ): () => void {
197
- const { legacyNamespace, padding = DEFAULT_PADDING, onTheme } = options;
198
-
199
- const names = docsEmbedMessageNames(legacyNamespace);
200
- const embedded = window !== window.parent;
201
- const isFullMode = new URLSearchParams(window.location.search).has("full");
202
-
203
- const onMessage = (event: MessageEvent) => {
204
- const theme = readDocsEmbedTheme(event.data, names.theme);
205
- if (theme) {
206
- onTheme?.(theme);
207
- const preview = previewIframe();
208
- if (preview?.contentWindow) {
209
- // Same document, so the origin is known — no reason to widen it to "*".
210
- postAll(preview.contentWindow, names.theme, { theme }, window.location.origin);
211
- }
212
- return;
213
- }
214
-
215
- if (!embedded) return;
216
-
217
- const contentHeight = readDocsEmbedHeight(event.data, names.previewHeight);
218
- if (contentHeight === null) return;
219
-
220
- let chromeHeight = 0;
221
- if (!isFullMode) {
222
- const preview = previewIframe();
223
- // Chrome is measured, not assumed: without the preview element there is
224
- // no honest number to send, so send nothing and keep the last height.
225
- if (!preview) return;
226
- chromeHeight = window.innerHeight - preview.offsetHeight;
227
- }
228
-
229
- const height = resolveDocsEmbedRelayHeight({ contentHeight, chromeHeight, padding });
230
- postAll(window.parent, names.height, { height }, "*");
231
- };
232
-
233
- window.addEventListener("message", onMessage);
234
-
235
- return () => window.removeEventListener("message", onMessage);
236
- }
237
-
238
- export interface DocsEmbedPreviewBridgeOptions {
239
- /** Brand namespace to keep accepting and emitting during a migration. */
240
- legacyNamespace?: string;
241
- /**
242
- * Extra height added when the docs page embeds this preview directly. Only
243
- * applies to that topology; the manager owns padding in the relayed one.
244
- */
245
- padding?: number;
246
- /** Called with the theme the docs page is displaying. */
247
- onTheme?: (theme: DocsEmbedTheme) => void;
248
- }
249
-
250
- /**
251
- * Install the preview half: receive the docs page's theme, and report the
252
- * story's height to whichever parent is listening.
253
- *
254
- * Reports to the manager by default, or straight to the docs page when the URL
255
- * carries {@link DOCS_EMBED_PARAM}. Returns a teardown function.
256
- */
257
- export function installDocsEmbedPreviewBridge(
258
- options: DocsEmbedPreviewBridgeOptions = {},
259
- ): () => void {
260
- const { legacyNamespace, padding = 0, onTheme } = options;
261
-
262
- const names = docsEmbedMessageNames(legacyNamespace);
263
- const teardown: Array<() => void> = [];
264
-
265
- const onMessage = (event: MessageEvent) => {
266
- const theme = readDocsEmbedTheme(event.data, names.theme);
267
- if (theme) onTheme?.(theme);
268
- };
269
- window.addEventListener("message", onMessage);
270
- teardown.push(() => window.removeEventListener("message", onMessage));
271
-
272
- if (window !== window.parent) {
273
- teardown.push(installHeightReporter(names, padding));
274
- }
275
-
276
- return () => {
277
- for (const stop of teardown) stop();
278
- };
279
- }
280
-
281
- /** Observe the story container and post its height whenever it changes. */
282
- function installHeightReporter(names: DocsEmbedMessageNames, padding: number): () => void {
283
- const direct = new URLSearchParams(window.location.search).has(DOCS_EMBED_PARAM);
284
- const target = direct ? names.height : names.previewHeight;
285
-
286
- let lastHeight = 0;
287
- const send = () => {
288
- const root = document.getElementById(STORYBOOK_ROOT_ID);
289
- if (!root) return;
290
-
291
- // Through the manager, the relay re-measures the chrome around this exact
292
- // box, so the story box is the right number. Embedded directly, the docs
293
- // iframe shows this whole document — body padding included — so measure it.
294
- const height = direct
295
- ? Math.max(root.offsetHeight, document.documentElement.scrollHeight) + padding
296
- : root.offsetHeight;
297
-
298
- if (height === lastHeight) return;
299
- lastHeight = height;
300
- postAll(window.parent, target, { height }, "*");
301
- };
302
-
303
- let frame: number | undefined;
304
- let resizeObserver: ResizeObserver | undefined;
305
- let mutationObserver: MutationObserver | undefined;
306
-
307
- const observe = () => {
308
- const root = document.getElementById(STORYBOOK_ROOT_ID);
309
- if (!root) {
310
- // Storybook mounts the root asynchronously, and there is no event for it.
311
- frame = requestAnimationFrame(observe);
312
- return;
313
- }
314
-
315
- resizeObserver = new ResizeObserver(send);
316
- resizeObserver.observe(root);
317
- // A re-render that swaps content of the same height still changes what the
318
- // story needs — the resize observer alone misses it.
319
- mutationObserver = new MutationObserver(send);
320
- mutationObserver.observe(root, { childList: true, subtree: true });
321
- send();
322
- };
323
-
324
- if (document.readyState === "complete") {
325
- observe();
326
- } else {
327
- window.addEventListener("load", observe);
328
- }
329
-
330
- return () => {
331
- if (frame !== undefined) cancelAnimationFrame(frame);
332
- window.removeEventListener("load", observe);
333
- resizeObserver?.disconnect();
334
- mutationObserver?.disconnect();
335
- };
336
- }
337
-
338
- /** Post the same payload under every accepted name (the compat window). */
339
- function postAll(
340
- target: Window,
341
- names: readonly string[],
342
- payload: Record<string, unknown>,
343
- targetOrigin: string,
344
- ): void {
345
- for (const type of names) {
346
- target.postMessage({ ...payload, type }, targetOrigin);
347
- }
348
- }
349
-
350
- /** Narrow an untrusted `MessageEvent.data` to a typed message, or `null`. */
351
- function asMessage(data: unknown): { type: string; theme?: unknown; height?: unknown } | null {
352
- if (typeof data !== "object" || data === null) return null;
353
-
354
- const message = data as { type?: unknown; theme?: unknown; height?: unknown };
355
- if (typeof message.type !== "string") return null;
356
-
357
- return { type: message.type, theme: message.theme, height: message.height };
358
- }
359
-
360
- function previewIframe(): HTMLIFrameElement | null {
361
- return document.getElementById(STORYBOOK_PREVIEW_IFRAME_ID) as HTMLIFrameElement | null;
362
- }