@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.
- package/CHANGELOG.md +45 -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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# @uxfront/layer-docs
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Republish the `0.4.0` code through automated release tooling. No code changes.
|
|
8
|
+
|
|
9
|
+
`0.4.0` and every version before it were published by hand from a maintainer's machine. Nothing linked a version on the registry back to the commit it was built from, and the release depended on a long-lived npm token.
|
|
10
|
+
|
|
11
|
+
`0.4.1` is the first version published from CI, by Changesets, over npm trusted publishing. No npm token takes part: the registry credential is minted for each run from a GitHub OIDC token. Every shipped file is byte-identical to `0.4.0` except this changelog entry and the `version` field.
|
|
12
|
+
|
|
13
|
+
This release carries **no provenance attestation**. npm does not generate provenance for a package built from a private source repository, and `uxfront` is private. Provenance becomes available if the repository becomes public. It is not a property of this version.
|
|
14
|
+
|
|
15
|
+
## 0.4.0
|
|
16
|
+
|
|
17
|
+
### Minor Changes
|
|
18
|
+
|
|
19
|
+
- a4caf06: Ship OG image support and the two satori card templates.
|
|
20
|
+
|
|
21
|
+
Every consumer was registering `nuxt-og-image` itself and carrying its own copy of the templates, which meant carrying a page override too — `defineOgImage` cannot be called from the layer if the module is not registered by it. The layer now registers the module beside `@nuxtjs/sitemap` and `@nuxtjs/robots` (same class of SEO plumbing it already turns on unasked) and calls `defineOgImage` from its own docs and landing pages, so a consumer that extends the layer gets branded cards without writing anything. Opt out with the module's own switch, `ogImage: { enabled: false }`.
|
|
22
|
+
|
|
23
|
+
**The accent is a brand token, not a hardcoded colour.** The existing copies hardcoded a Tailwind class, which was wrong in a way that only showed up on the rendered card: every consumer shadows stock Tailwind scale names with its own values in `@theme`, so `text-teal-500` renders Tailwind's teal, not the brand's. Custom properties are not an option either — satori has no CSS cascade, and `var(--ui-primary)` resolves to nothing. The accent is therefore resolved to a literal at build time by following `--ui-primary` to the `--color-<scale>` declaration it aliases in the consumer's registered CSS, the same two hops the browser does and the same pair `describeBrandPaletteCss` already asserts. Override it with a literal via `ogImage.accent` in `app.config.ts`.
|
|
24
|
+
|
|
25
|
+
Two parts of the card are components rather than props, because `defineOgImage` serialises its arguments and a Vue slot cannot cross that boundary. Shadow `app/AppOgDecoration.vue` (a neutral radial wash) or `app/AppOgLogo.vue` (a wordmark from `header.title`) at the same path to replace either.
|
|
26
|
+
|
|
27
|
+
Cards render at 1200×630 rather than the module's 1200×600 default — 1.91:1 is the ratio crawlers crop to.
|
|
28
|
+
|
|
29
|
+
New peer dependencies: `nuxt-og-image`, `satori`, and `@resvg/resvg-js`. Satori renders the template to SVG and needs a separate rasteriser for the PNG; without one, prerendering logs `renderer.createImage error` per card and emits nothing.
|
|
30
|
+
|
|
31
|
+
- 5cc11f2: Add `StorybookEmbed` and the `@uxfront/layer-docs/storybook` bridge.
|
|
32
|
+
|
|
33
|
+
`StorybookEmbed` embeds a deployed Storybook story in a docs page, in one of
|
|
34
|
+
three modes — `preview` (canvas only), `full` (manager without the sidebar), and
|
|
35
|
+
`panel` (manager with the controls panel). It mounts lazily, keeps the
|
|
36
|
+
Storybook's colour mode in sync with the page's, and grows to fit its story
|
|
37
|
+
without shifting layout. The Storybook host comes from
|
|
38
|
+
`NUXT_PUBLIC_STORYBOOK_BASE_URL`, with an optional `{framework}` placeholder for
|
|
39
|
+
per-framework deployments.
|
|
40
|
+
|
|
41
|
+
The `/storybook` subpath export ships the other half of that contract:
|
|
42
|
+
`installDocsEmbedPreviewBridge` and `installDocsEmbedManagerBridge`, which are
|
|
43
|
+
framework-free and import nothing from Nuxt. A Storybook that already speaks a
|
|
44
|
+
branded message namespace can name it via
|
|
45
|
+
`NUXT_PUBLIC_STORYBOOK_LEGACY_MESSAGE_NAMESPACE`, and both sides accept and emit
|
|
46
|
+
both spellings so the two can deploy in either order.
|
|
47
|
+
|
|
3
48
|
## 0.3.0
|
|
4
49
|
|
|
5
50
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -20,8 +20,9 @@ npm install -D @uxfront/layer-docs
|
|
|
20
20
|
Then install the peer dependencies the layer expects (the Nuxt documentation
|
|
21
21
|
stack — see [`package.json`](./package.json) `peerDependencies` for pinned
|
|
22
22
|
ranges): `nuxt`, `vue`, `@nuxt/ui`, `@nuxt/image`, `@nuxt/scripts`,
|
|
23
|
-
`@nuxtjs/robots`, `nuxt-og-image`, `
|
|
24
|
-
and translation — `@nuxt/content`,
|
|
23
|
+
`@nuxtjs/robots`, `nuxt-og-image`, `satori`, `@resvg/resvg-js`, `nuxt-llms`,
|
|
24
|
+
`tailwindcss`, and — for content and translation — `@nuxt/content`,
|
|
25
|
+
`@nuxtjs/i18n`, `@nuxtjs/mdc`.
|
|
25
26
|
|
|
26
27
|
## Usage
|
|
27
28
|
|
|
@@ -134,6 +135,112 @@ The second reads the built stylesheet and asserts every base utility is emitted
|
|
|
134
135
|
a CSS entry that never reached the bundle (no base utilities at all). Run it in
|
|
135
136
|
whichever CI job already builds the app; it needs `.output/` on disk.
|
|
136
137
|
|
|
138
|
+
## Storybook embeds
|
|
139
|
+
|
|
140
|
+
`StorybookEmbed` renders a deployed Storybook story inside a docs page. Point it
|
|
141
|
+
at your Storybook once, from env — the host is a deployment fact, not a code
|
|
142
|
+
fact, so it lives in runtime config rather than `app.config`:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
# a single Storybook
|
|
146
|
+
NUXT_PUBLIC_STORYBOOK_BASE_URL="https://storybook.example.com"
|
|
147
|
+
|
|
148
|
+
# or one per framework — `{framework}` is substituted per embed
|
|
149
|
+
NUXT_PUBLIC_STORYBOOK_BASE_URL="https://{framework}.storybook.example.com"
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Then use it from markdown:
|
|
153
|
+
|
|
154
|
+
```mdc
|
|
155
|
+
:storybook-embed{story="components-actions-button--default"}
|
|
156
|
+
|
|
157
|
+
:storybook-embed{story="components-actions-button--default" mode="panel"}
|
|
158
|
+
|
|
159
|
+
:storybook-embed{story="components-actions-button--default" title="Button"}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
| Prop | Default | Notes |
|
|
163
|
+
| ----------- | ---------------- | --------------------------------------------------------------------------------------------------- |
|
|
164
|
+
| `story` | — | Storybook story id, e.g. `components-actions-button--default`. |
|
|
165
|
+
| `framework` | `useFramework()` | Fills `{framework}` in the base URL. Follows the tab by default. |
|
|
166
|
+
| `mode` | `"preview"` | `preview` (canvas only) · `full` (manager, no sidebar) · `panel` (manager with the controls panel). |
|
|
167
|
+
| `height` | mode default | Number (px) or any CSS length. Set it and auto-height is off. |
|
|
168
|
+
| `title` | — | Wraps the embed in `BrowserFrame` with this title. |
|
|
169
|
+
|
|
170
|
+
The embed mounts its iframe only once it is 200px from the viewport, keeps the
|
|
171
|
+
Storybook's colour mode in sync with the page's, and grows to fit its story.
|
|
172
|
+
Height is always reserved, so none of that shifts layout.
|
|
173
|
+
|
|
174
|
+
### The Storybook side
|
|
175
|
+
|
|
176
|
+
Auto-height and theme sync are a two-way `postMessage` contract, so the
|
|
177
|
+
Storybook has to answer. Install the bridge — it is framework-free and imports
|
|
178
|
+
nothing from Nuxt:
|
|
179
|
+
|
|
180
|
+
```ts
|
|
181
|
+
// .storybook/preview.ts
|
|
182
|
+
import { installDocsEmbedPreviewBridge } from "@uxfront/layer-docs/storybook";
|
|
183
|
+
|
|
184
|
+
installDocsEmbedPreviewBridge({ onTheme: (theme) => applyTheme(theme) });
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
// .storybook/manager.ts — only needed for `full` / `panel`
|
|
189
|
+
import { installDocsEmbedManagerBridge } from "@uxfront/layer-docs/storybook";
|
|
190
|
+
|
|
191
|
+
installDocsEmbedManagerBridge({ onTheme: (theme) => applyTheme(theme) });
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Without the bridge the embed still renders; it just holds its default height and
|
|
195
|
+
does not follow the page's colour mode.
|
|
196
|
+
|
|
197
|
+
### Migrating an existing Storybook
|
|
198
|
+
|
|
199
|
+
If your Storybook already speaks a branded namespace (`<brand>:theme`,
|
|
200
|
+
`<brand>:height`), name it and both sides accept **and** emit both spellings, so
|
|
201
|
+
the docs site and the Storybook can deploy in either order:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
NUXT_PUBLIC_STORYBOOK_LEGACY_MESSAGE_NAMESPACE="acme"
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Unset it once both sides are on `@uxfront/layer-docs/storybook`.
|
|
208
|
+
|
|
209
|
+
## OG images
|
|
210
|
+
|
|
211
|
+
Social share cards are on by default — the layer registers `nuxt-og-image` and
|
|
212
|
+
calls `defineOgImage` from the docs and landing pages, so a consumer that sets up
|
|
213
|
+
the CSS entry above gets branded 1200×630 cards without writing anything. Opt out
|
|
214
|
+
with the module's own switch: `ogImage: { enabled: false }`.
|
|
215
|
+
|
|
216
|
+
The accent is resolved to a **literal colour at build time** by following
|
|
217
|
+
`--ui-primary` to the `--color-<scale>` value it aliases in your CSS entry.
|
|
218
|
+
Satori — the renderer behind `.satori.vue` templates — has no CSS cascade and no
|
|
219
|
+
custom properties, so `var(--ui-primary)` would render as nothing, and a utility
|
|
220
|
+
class like `text-teal-500` would render Tailwind's teal rather than yours (your
|
|
221
|
+
`@theme` shadows the stock scale name). Override it with a literal if you need
|
|
222
|
+
something other than the brand colour:
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
export default defineAppConfig({
|
|
226
|
+
ogImage: { accent: "#a78bfa" },
|
|
227
|
+
});
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Two pieces of the card are components rather than props, because `defineOgImage`
|
|
231
|
+
serialises its arguments and a Vue slot cannot cross that boundary. Shadow either
|
|
232
|
+
by creating a file at the same path in your own `app/components/`:
|
|
233
|
+
|
|
234
|
+
| Component | Default |
|
|
235
|
+
| ------------------------- | ----------------------------------- |
|
|
236
|
+
| `app/AppOgDecoration.vue` | A neutral radial-gradient flourish |
|
|
237
|
+
| `app/AppOgLogo.vue` | A text wordmark from `header.title` |
|
|
238
|
+
|
|
239
|
+
The wordmark is text, not `AppHeaderLogo`: that component renders
|
|
240
|
+
`UColorModeImage`, and satori has neither a colour mode nor a browser to resolve
|
|
241
|
+
relative image paths against. A brand that wants its mark on the card overrides
|
|
242
|
+
`AppOgLogo.vue` with an `<img>` pointing at an **absolute** URL.
|
|
243
|
+
|
|
137
244
|
## Compatibility
|
|
138
245
|
|
|
139
246
|
Pinned to the Nuxt 4 documentation stack (Nuxt 4.4, Nuxt UI 4.8, Content 3.14,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
/**
|
|
3
|
+
* Default OG card for docs, changelog and landing pages — `defineOgImage("DocsSatori", ...)`.
|
|
4
|
+
*
|
|
5
|
+
* Brandable without touching this package: the accent comes from the consumer's
|
|
6
|
+
* own `--ui-primary` token (resolved in `modules/config.ts`), and both the
|
|
7
|
+
* decoration and the wordmark are components a consumer can shadow. See
|
|
8
|
+
* README § OG images.
|
|
9
|
+
*/
|
|
10
|
+
withDefaults(defineProps<{ title?: string; description?: string; headline?: string }>(), {
|
|
11
|
+
title: "",
|
|
12
|
+
description: "",
|
|
13
|
+
headline: "",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const appConfig = useAppConfig();
|
|
17
|
+
const accent = computed(() => appConfig.ogImage?.accent);
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<div class="w-full h-full flex flex-col justify-center bg-neutral-900">
|
|
22
|
+
<AppOgDecoration />
|
|
23
|
+
|
|
24
|
+
<div class="pl-[100px]">
|
|
25
|
+
<p
|
|
26
|
+
v-if="headline"
|
|
27
|
+
class="uppercase text-[24px] mb-4 font-semibold"
|
|
28
|
+
:style="{ color: accent }"
|
|
29
|
+
>
|
|
30
|
+
{{ headline }}
|
|
31
|
+
</p>
|
|
32
|
+
<h1 v-if="title" class="m-0 text-[75px] font-semibold mb-4 text-white flex items-center">
|
|
33
|
+
<span>{{ title.slice(0, 60) }}</span>
|
|
34
|
+
</h1>
|
|
35
|
+
<p v-if="description" class="text-[32px] text-neutral-300 leading-tight w-[700px]">
|
|
36
|
+
{{ description.slice(0, 200) }}
|
|
37
|
+
</p>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</template>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
/**
|
|
3
|
+
* Centred OG card variant for landing pages — `defineOgImage("LandingSatori", ...)`.
|
|
4
|
+
*
|
|
5
|
+
* Same brand contract as {@link OgImageDocs}: accent from the consumer's token,
|
|
6
|
+
* decoration and wordmark as overridable components.
|
|
7
|
+
*/
|
|
8
|
+
withDefaults(defineProps<{ title?: string; description?: string; headline?: string }>(), {
|
|
9
|
+
title: "",
|
|
10
|
+
description: "",
|
|
11
|
+
headline: "",
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const appConfig = useAppConfig();
|
|
15
|
+
const accent = computed(() => appConfig.ogImage?.accent);
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<div class="w-full h-full flex items-center justify-center bg-neutral-900">
|
|
20
|
+
<AppOgDecoration />
|
|
21
|
+
|
|
22
|
+
<div class="flex flex-col justify-center p-8">
|
|
23
|
+
<div class="flex justify-center mb-8">
|
|
24
|
+
<AppOgLogo />
|
|
25
|
+
</div>
|
|
26
|
+
<p
|
|
27
|
+
v-if="headline"
|
|
28
|
+
class="flex justify-center uppercase text-[24px] mb-4 font-semibold"
|
|
29
|
+
:style="{ color: accent }"
|
|
30
|
+
>
|
|
31
|
+
{{ headline }}
|
|
32
|
+
</p>
|
|
33
|
+
<h1 v-if="title" class="flex justify-center m-0 text-5xl font-semibold mb-4 text-white">
|
|
34
|
+
<span>{{ title.slice(0, 60) }}</span>
|
|
35
|
+
</h1>
|
|
36
|
+
<p v-if="description" class="text-center text-2xl text-neutral-300 leading-tight">
|
|
37
|
+
{{ description.slice(0, 200) }}
|
|
38
|
+
</p>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</template>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
/**
|
|
3
|
+
* Decorative flourish behind the OG card — the "slot" of the OG templates.
|
|
4
|
+
*
|
|
5
|
+
* `defineOgImage(name, props)` serialises its props into the payload, so a real
|
|
6
|
+
* Vue slot cannot cross that boundary. The Nuxt-native equivalent is app-dir
|
|
7
|
+
* component precedence: a consumer shadows this file at the same path to supply
|
|
8
|
+
* its own decoration, or ships an empty template to render nothing. That is the
|
|
9
|
+
* same override mechanism consumers already use for `DocsAsideLeftBody`.
|
|
10
|
+
*
|
|
11
|
+
* Geometry only, no brand mark: a soft radial wash in the top-right corner.
|
|
12
|
+
* Deliberately not the source SVG blob — satori implements a subset of SVG and
|
|
13
|
+
* does not apply `filter` / `feGaussianBlur`, so the blurred shape would render
|
|
14
|
+
* as a hard-edged starburst. A `radial-gradient` background is satori-native
|
|
15
|
+
* and preserves what the blur was there for.
|
|
16
|
+
*
|
|
17
|
+
* Bound rather than written as a `style` attribute so it stays on one line:
|
|
18
|
+
* satori's gradient parser rejects a value containing newlines, and a formatter
|
|
19
|
+
* will wrap an attribute this long across lines given the chance.
|
|
20
|
+
*/
|
|
21
|
+
const backgroundImage =
|
|
22
|
+
"radial-gradient(circle at 75% 15%, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.1) 35%, rgba(255, 255, 255, 0) 70%)";
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<div class="absolute right-0 top-0 h-[593px] w-[629px]" :style="{ backgroundImage }" />
|
|
27
|
+
</template>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
/**
|
|
3
|
+
* Wordmark for the OG card. Overridable at the same path, like
|
|
4
|
+
* {@link AppOgDecoration}.
|
|
5
|
+
*
|
|
6
|
+
* Text, not `AppHeaderLogo`: that component renders `UColorModeImage`, which
|
|
7
|
+
* needs a colour mode and resolves relative image paths against the browser —
|
|
8
|
+
* satori has neither, so it needs an absolute URL and no colour mode at all. A
|
|
9
|
+
* brand that wants its mark here overrides this file with an `<img>` pointing
|
|
10
|
+
* at an absolute URL; stating that limit rather than half-solving it.
|
|
11
|
+
*/
|
|
12
|
+
const appConfig = useAppConfig();
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<template>
|
|
16
|
+
<span class="text-[32px] font-semibold text-white">
|
|
17
|
+
{{ appConfig.header?.title }}
|
|
18
|
+
</span>
|
|
19
|
+
</template>
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useIntersectionObserver } from "@vueuse/core";
|
|
3
|
+
// Resolved through `#components` rather than imported by path so a consumer
|
|
4
|
+
// that ships its own `BrowserFrame` overrides this one, as with any other
|
|
5
|
+
// component in the layer.
|
|
6
|
+
import { BrowserFrame } from "#components";
|
|
7
|
+
import { docsEmbedMessageNames, readDocsEmbedHeight } from "../../../storybook";
|
|
8
|
+
import {
|
|
9
|
+
buildStorybookEmbedUrl,
|
|
10
|
+
STORYBOOK_EMBED_DEFAULT_HEIGHTS,
|
|
11
|
+
storybookEmbedCssHeight,
|
|
12
|
+
type StorybookEmbedMode,
|
|
13
|
+
} from "../../utils/storybookEmbed";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Embeds a single story from the consumer's deployed Storybook.
|
|
17
|
+
*
|
|
18
|
+
* The Storybook host is a consumer fact, never a layer one: it comes from
|
|
19
|
+
* `runtimeConfig.public.storybookBaseUrl`, which is empty here and set per
|
|
20
|
+
* consumer — in `nuxt.config.ts` or, without a rebuild,
|
|
21
|
+
* `NUXT_PUBLIC_STORYBOOK_BASE_URL`.
|
|
22
|
+
*
|
|
23
|
+
* ```md
|
|
24
|
+
* :storybook-embed{story="components-actions-button--default"}
|
|
25
|
+
* :storybook-embed{story="components-forms-input--default" mode="panel"}
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* Three behaviours are always on, and all three degrade rather than fail:
|
|
29
|
+
*
|
|
30
|
+
* - **Lazy mount.** The iframe is not created until the embed comes near the
|
|
31
|
+
* viewport, so a page with twenty embeds loads one Storybook, not twenty.
|
|
32
|
+
* - **Theme sync.** The page's colour mode is posted to the story on load and
|
|
33
|
+
* on every change, so an embed never sits light inside a dark page.
|
|
34
|
+
* - **Auto-height.** The story reports what it needs and the frame follows.
|
|
35
|
+
*
|
|
36
|
+
* Theme and height both need `@uxfront/layer-docs/storybook` installed in the
|
|
37
|
+
* Storybook config. Without it the frame keeps `height` (or the mode's
|
|
38
|
+
* default) and the story renders in its own theme — degraded, not broken.
|
|
39
|
+
*/
|
|
40
|
+
interface Props {
|
|
41
|
+
/** Storybook story id, e.g. `components-actions-button--default`. */
|
|
42
|
+
story: string;
|
|
43
|
+
/**
|
|
44
|
+
* Framework substituted into the base URL's `{framework}` placeholder.
|
|
45
|
+
* Defaults to the reader's selected framework, so an embed inside a
|
|
46
|
+
* `FrameworkSwitcher` tab needs it and a standalone one does not.
|
|
47
|
+
*/
|
|
48
|
+
framework?: string;
|
|
49
|
+
/** Storybook surface to embed. */
|
|
50
|
+
mode?: StorybookEmbedMode;
|
|
51
|
+
/**
|
|
52
|
+
* Fixed height. Set this and auto-height is off — the story's own report is
|
|
53
|
+
* ignored, which is what you want for a story whose height oscillates.
|
|
54
|
+
*/
|
|
55
|
+
height?: number | string;
|
|
56
|
+
/** Caption for the browser frame. Omit to render the story unframed. */
|
|
57
|
+
title?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
61
|
+
framework: undefined,
|
|
62
|
+
mode: "preview",
|
|
63
|
+
height: undefined,
|
|
64
|
+
title: undefined,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const config = useRuntimeConfig();
|
|
68
|
+
const storybookBaseUrl = computed(() => (config.public.storybookBaseUrl as string) ?? "");
|
|
69
|
+
const messageNames = computed(() =>
|
|
70
|
+
docsEmbedMessageNames(config.public.storybookLegacyMessageNamespace as string),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const { framework: selectedFramework } = useFramework();
|
|
74
|
+
const framework = computed(() => props.framework ?? selectedFramework.value);
|
|
75
|
+
|
|
76
|
+
const colorMode = useColorMode();
|
|
77
|
+
const containerRef = useTemplateRef<HTMLElement>("container");
|
|
78
|
+
const iframeRef = useTemplateRef<HTMLIFrameElement>("iframe");
|
|
79
|
+
|
|
80
|
+
// Mount the iframe just before it is scrolled to, then stop observing — the
|
|
81
|
+
// answer cannot change back.
|
|
82
|
+
const visible = ref(false);
|
|
83
|
+
const { stop } = useIntersectionObserver(
|
|
84
|
+
containerRef,
|
|
85
|
+
([entry]) => {
|
|
86
|
+
if (!entry?.isIntersecting) return;
|
|
87
|
+
visible.value = true;
|
|
88
|
+
stop();
|
|
89
|
+
},
|
|
90
|
+
{ rootMargin: "200px" },
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const src = computed(() =>
|
|
94
|
+
buildStorybookEmbedUrl({
|
|
95
|
+
template: storybookBaseUrl.value,
|
|
96
|
+
story: props.story,
|
|
97
|
+
framework: framework.value,
|
|
98
|
+
mode: props.mode,
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const reportedHeight = ref<number | null>(null);
|
|
103
|
+
const cssHeight = computed(() =>
|
|
104
|
+
storybookEmbedCssHeight(
|
|
105
|
+
props.height ?? reportedHeight.value ?? STORYBOOK_EMBED_DEFAULT_HEIGHTS[props.mode],
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// An iframe is an unlabelled frame to a screen reader. The caption names it
|
|
110
|
+
// when there is one; otherwise say what it holds rather than leaving it silent.
|
|
111
|
+
const frameTitle = computed(
|
|
112
|
+
() => props.title ?? `Storybook preview: ${props.story.replace(/-+/g, " ")}`,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const wrapper = computed(() => (props.title ? BrowserFrame : "div"));
|
|
116
|
+
const wrapperProps = computed(() => (props.title ? { title: props.title } : {}));
|
|
117
|
+
|
|
118
|
+
function sendTheme() {
|
|
119
|
+
const target = iframeRef.value?.contentWindow;
|
|
120
|
+
if (!target) return;
|
|
121
|
+
|
|
122
|
+
const theme = colorMode.value === "dark" ? "dark" : "light";
|
|
123
|
+
for (const type of messageNames.value.theme) {
|
|
124
|
+
target.postMessage({ type, theme }, "*");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function onMessage(event: MessageEvent) {
|
|
129
|
+
// Scope by source, not by origin: the Storybook host is consumer-configured
|
|
130
|
+
// and may differ per framework, but only one window can be this iframe.
|
|
131
|
+
if (event.source !== iframeRef.value?.contentWindow) return;
|
|
132
|
+
|
|
133
|
+
const height = readDocsEmbedHeight(event.data, messageNames.value.height);
|
|
134
|
+
if (height !== null) reportedHeight.value = height;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
onMounted(() => window.addEventListener("message", onMessage));
|
|
138
|
+
onUnmounted(() => window.removeEventListener("message", onMessage));
|
|
139
|
+
|
|
140
|
+
watch(() => colorMode.value, sendTheme);
|
|
141
|
+
</script>
|
|
142
|
+
|
|
143
|
+
<template>
|
|
144
|
+
<component :is="wrapper" v-bind="wrapperProps">
|
|
145
|
+
<div
|
|
146
|
+
ref="container"
|
|
147
|
+
class="storybook-embed border border-default rounded overflow-hidden min-h-25 resize-y transition-[height] duration-150 ease-out motion-reduce:transition-none"
|
|
148
|
+
:style="{ height: cssHeight }"
|
|
149
|
+
>
|
|
150
|
+
<iframe
|
|
151
|
+
v-if="visible"
|
|
152
|
+
ref="iframe"
|
|
153
|
+
:src="src"
|
|
154
|
+
:title="frameTitle"
|
|
155
|
+
class="block w-full h-full border-0"
|
|
156
|
+
@load="sendTheme"
|
|
157
|
+
/>
|
|
158
|
+
</div>
|
|
159
|
+
</component>
|
|
160
|
+
</template>
|
|
@@ -35,11 +35,21 @@ useSeoMeta({
|
|
|
35
35
|
ogDescription: description,
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
const appConfig = useAppConfig();
|
|
39
|
+
|
|
40
|
+
// An explicit `seo.ogImage` in the page's front matter wins; otherwise the card
|
|
41
|
+
// is generated from the shared template.
|
|
38
42
|
if (page.value?.seo?.ogImage) {
|
|
39
43
|
useSeoMeta({
|
|
40
44
|
ogImage: page.value.seo.ogImage,
|
|
41
45
|
twitterImage: page.value.seo.ogImage,
|
|
42
46
|
});
|
|
47
|
+
} else {
|
|
48
|
+
defineOgImage("DocsSatori", {
|
|
49
|
+
headline: appConfig.header?.title,
|
|
50
|
+
title,
|
|
51
|
+
description,
|
|
52
|
+
});
|
|
43
53
|
}
|
|
44
54
|
</script>
|
|
45
55
|
|
|
@@ -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
|
+
});
|