@uxfront/layer-docs 0.4.1 → 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.
- package/README.md +31 -220
- package/app/app.config.ts +8 -92
- package/app/components/content/FrameworkSwitcher.vue +66 -40
- package/app/components/docs/DocsAsideLeftTop.vue +9 -15
- package/app/components/docs/DocsFrameworkSelect.vue +4 -7
- package/app/composables/useFramework.ts +30 -38
- package/nuxt.config.ts +14 -170
- package/package.json +10 -77
- package/CHANGELOG.md +0 -192
- package/LICENSE +0 -21
- package/app/app.vue +0 -138
- package/app/assets/css/main.css +0 -15
- package/app/components/IconMenuToggle.vue +0 -92
- package/app/components/LanguageSelect.vue +0 -73
- package/app/components/MorphingGradientBackground.vue +0 -261
- package/app/components/OgImage/OgImageDocs.satori.vue +0 -40
- package/app/components/OgImage/OgImageLanding.satori.vue +0 -41
- package/app/components/app/AppFooter.vue +0 -13
- package/app/components/app/AppFooterCenter.vue +0 -17
- package/app/components/app/AppFooterLeft.vue +0 -21
- package/app/components/app/AppFooterRight.vue +0 -33
- package/app/components/app/AppHeader.vue +0 -123
- package/app/components/app/AppHeaderAttribution.vue +0 -45
- package/app/components/app/AppHeaderBody.vue +0 -14
- package/app/components/app/AppHeaderCTA.vue +0 -31
- package/app/components/app/AppHeaderCenter.vue +0 -10
- package/app/components/app/AppHeaderLogo.vue +0 -16
- package/app/components/app/AppOgDecoration.vue +0 -27
- package/app/components/app/AppOgLogo.vue +0 -19
- package/app/components/app/AppSearch.vue +0 -59
- package/app/components/app/AppSubHeader.vue +0 -21
- package/app/components/content/BrowserFrame.vue +0 -28
- package/app/components/content/GradientPageHero.vue +0 -35
- package/app/components/content/StorybookEmbed.vue +0 -160
- package/app/components/content/Video.vue +0 -103
- package/app/components/docs/DocsAsideLeftBody.vue +0 -20
- package/app/components/docs/DocsAsideRightBottom.vue +0 -15
- package/app/components/docs/DocsPageHeaderLinks.vue +0 -75
- package/app/composables/useDocsSections.ts +0 -57
- package/app/composables/useDocusI18n.ts +0 -49
- package/app/constants/sections.ts +0 -25
- package/app/error.vue +0 -140
- package/app/layouts/default.vue +0 -24
- package/app/pages/[[lang]]/[...slug].vue +0 -58
- package/app/pages/[[lang]]/docs/[section]/[...slug].vue +0 -180
- package/app/plugins/i18n.ts +0 -21
- package/app/plugins/posthog.client.ts +0 -56
- package/app/types/non-route-categories.ts +0 -12
- package/app/utils/flattenNavigation.ts +0 -22
- package/app/utils/foldNonRouteCategories.ts +0 -47
- package/app/utils/prerender.ts +0 -9
- package/app/utils/storybookEmbed.test.ts +0 -98
- package/app/utils/storybookEmbed.ts +0 -93
- package/i18n/locales/ar.json +0 -24
- package/i18n/locales/be.json +0 -24
- package/i18n/locales/bn.json +0 -24
- package/i18n/locales/ca.json +0 -24
- package/i18n/locales/ckb.json +0 -24
- package/i18n/locales/cs.json +0 -24
- package/i18n/locales/da.json +0 -24
- package/i18n/locales/de.json +0 -24
- package/i18n/locales/el.json +0 -24
- package/i18n/locales/en.json +0 -24
- package/i18n/locales/et.json +0 -24
- package/i18n/locales/fr.json +0 -24
- package/i18n/locales/he.json +0 -24
- package/i18n/locales/hi.json +0 -24
- package/i18n/locales/hy.json +0 -24
- package/i18n/locales/it.json +0 -24
- package/i18n/locales/ja.json +0 -24
- package/i18n/locales/kk.json +0 -24
- package/i18n/locales/km.json +0 -24
- package/i18n/locales/ko.json +0 -24
- package/i18n/locales/ky.json +0 -24
- package/i18n/locales/lb.json +0 -24
- package/i18n/locales/ms.json +0 -24
- package/i18n/locales/nb.json +0 -24
- package/i18n/locales/pl.json +0 -24
- package/i18n/locales/ru.json +0 -24
- package/i18n/locales/sl.json +0 -24
- package/i18n/locales/sv.json +0 -24
- package/i18n/locales/uk.json +0 -24
- package/i18n/locales/ur.json +0 -24
- package/i18n/locales/vi.json +0 -24
- package/modules/config.ts +0 -144
- package/modules/optimizeDeps.ts +0 -45
- package/modules/routing.ts +0 -20
- package/nuxt.schema.ts +0 -374
- package/server/plugins/llms-redirect.ts +0 -60
- package/server/routes/raw/[...slug].md.get.ts +0 -74
- package/storybook/index.test.ts +0 -110
- package/storybook/index.ts +0 -362
- package/test/brand-palette.ts +0 -235
- package/test/no-brand-leakage.test.ts +0 -124
- package/tsconfig.json +0 -17
- package/utils/accent.ts +0 -80
- package/utils/content.ts +0 -193
- package/utils/git.ts +0 -114
- 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
|
-
});
|
package/storybook/index.test.ts
DELETED
|
@@ -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
|
-
});
|
package/storybook/index.ts
DELETED
|
@@ -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
|
-
}
|