@uxfront/layer-docs 0.2.1 → 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 +51 -0
- package/README.md +183 -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 +51 -1
- package/nuxt.schema.ts +15 -0
- package/package.json +20 -2
- package/storybook/index.test.ts +110 -0
- package/storybook/index.ts +362 -0
- package/test/brand-palette.ts +235 -0
- package/test/no-brand-leakage.test.ts +124 -0
- package/utils/accent.ts +80 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# @uxfront/layer-docs
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a4caf06: Ship OG image support and the two satori card templates.
|
|
8
|
+
|
|
9
|
+
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 }`.
|
|
10
|
+
|
|
11
|
+
**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`.
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
Cards render at 1200×630 rather than the module's 1200×600 default — 1.91:1 is the ratio crawlers crop to.
|
|
16
|
+
|
|
17
|
+
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.
|
|
18
|
+
|
|
19
|
+
- 5cc11f2: Add `StorybookEmbed` and the `@uxfront/layer-docs/storybook` bridge.
|
|
20
|
+
|
|
21
|
+
`StorybookEmbed` embeds a deployed Storybook story in a docs page, in one of
|
|
22
|
+
three modes — `preview` (canvas only), `full` (manager without the sidebar), and
|
|
23
|
+
`panel` (manager with the controls panel). It mounts lazily, keeps the
|
|
24
|
+
Storybook's colour mode in sync with the page's, and grows to fit its story
|
|
25
|
+
without shifting layout. The Storybook host comes from
|
|
26
|
+
`NUXT_PUBLIC_STORYBOOK_BASE_URL`, with an optional `{framework}` placeholder for
|
|
27
|
+
per-framework deployments.
|
|
28
|
+
|
|
29
|
+
The `/storybook` subpath export ships the other half of that contract:
|
|
30
|
+
`installDocsEmbedPreviewBridge` and `installDocsEmbedManagerBridge`, which are
|
|
31
|
+
framework-free and import nothing from Nuxt. A Storybook that already speaks a
|
|
32
|
+
branded message namespace can name it via
|
|
33
|
+
`NUXT_PUBLIC_STORYBOOK_LEGACY_MESSAGE_NAMESPACE`, and both sides accept and emit
|
|
34
|
+
both spellings so the two can deploy in either order.
|
|
35
|
+
|
|
36
|
+
## 0.3.0
|
|
37
|
+
|
|
38
|
+
### Minor Changes
|
|
39
|
+
|
|
40
|
+
- 751810d: Stop registering the layer's own `main.css`, and ship the brand-palette guards consumers were each expected to write themselves.
|
|
41
|
+
|
|
42
|
+
**Breaking for any consumer that does not already register its own CSS entry.** The layer registered `app/assets/css/main.css` in `css` while every consumer also imported that same base from its brand CSS — because a brand `@theme` only compiles into real `:root` custom properties when it lives inside a Tailwind pass, which means importing the base rather than sitting beside it. Two entries, two Tailwind passes, and a byte-for-byte duplicate of every base utility in the shipped stylesheet: **+212 KB raw / +26.7 KB gzip (+94%)** on inkline's render-blocking `entry.css`, on every page, for every visitor (UXF-118).
|
|
43
|
+
|
|
44
|
+
The layer no longer registers it. Consumers register exactly one CSS entry of their own, whose first line imports the layer base — which is what all three consumers were already doing, one of them via a `modules:done` hook that un-registered what the layer had just registered. That hook can now be deleted.
|
|
45
|
+
|
|
46
|
+
**Migration.** If your app already has `css: ["./app/assets/css/main.css"]` pointing at a file that starts with `@import "@uxfront/layer-docs/app/assets/css/main.css"`, nothing changes except that your stylesheet halves; drop any hook that stripped the layer's entry. If it does not, add both — see README § Styling. There is no silent middle state: without a CSS entry the app ships no Tailwind utilities at all.
|
|
47
|
+
|
|
48
|
+
**What the duplication actually cost.** Payload, not layout. The duplicate pass measured on inkline was a complete superset of the first and was emitted wholly after it, so the last `sm:`/`lg:` variant still landed after the last conflicting base and won by source order — computed-style A/B across 4 pages × 4 viewports found zero rendering difference. That rescue was incidental rather than designed, which is why the cause is fixed rather than the symptom.
|
|
49
|
+
|
|
50
|
+
**New: `@uxfront/layer-docs/test`.** The two guards that would have caught this before ship, now parameterised by brand scale instead of copy-pasted per repo. `describeBrandPaletteCss` asserts, without a build, that your CSS entry imports the layer base before its `@theme` and defines the scale it maps onto `--ui-primary`. `describeSingleTailwindPass` reads the built stylesheet and asserts every base utility is emitted **exactly once** — catching a duplicate pass and a CSS entry that never reached the bundle with the same assertion. `vitest` is an optional peer dependency, needed only for these.
|
|
51
|
+
|
|
52
|
+
Also fixed: three code comments claimed the second pass "kills every `sm:`/`lg:` responsive variant". It does not, in the equal-superset case measured; the guards now describe the duplicate payload they actually guard.
|
|
53
|
+
|
|
3
54
|
## 0.2.1
|
|
4
55
|
|
|
5
56
|
### Patch 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
|
|
|
@@ -60,6 +61,186 @@ That renders `uxd by UXFront` — the wordmark still links home, `by` is plain
|
|
|
60
61
|
text, and `UXFront` links out. Omit `attribution` (or leave `label` empty) to
|
|
61
62
|
render the wordmark alone.
|
|
62
63
|
|
|
64
|
+
## Styling
|
|
65
|
+
|
|
66
|
+
**The consumer owns the single Tailwind entry.** The layer ships a palette-free
|
|
67
|
+
Tailwind base at `@uxfront/layer-docs/app/assets/css/main.css` and deliberately
|
|
68
|
+
does not register it itself. A brand `@theme` only compiles into real `:root`
|
|
69
|
+
custom properties when it lives inside a Tailwind pass, so your CSS file has to
|
|
70
|
+
_import_ the layer base rather than sit beside it as a second entry — two
|
|
71
|
+
entries each re-emit every base utility into the shipped stylesheet.
|
|
72
|
+
|
|
73
|
+
Register one CSS entry:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// nuxt.config.ts
|
|
77
|
+
export default defineNuxtConfig({
|
|
78
|
+
extends: ["@uxfront/layer-docs"],
|
|
79
|
+
css: ["./app/assets/css/main.css"],
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
whose first line imports the layer base, followed by your palette:
|
|
84
|
+
|
|
85
|
+
```css
|
|
86
|
+
/* app/assets/css/main.css */
|
|
87
|
+
@import "@uxfront/layer-docs/app/assets/css/main.css";
|
|
88
|
+
|
|
89
|
+
/* The layer's own `@source` paths are package-relative (they resolve inside
|
|
90
|
+
node_modules), so re-scan your content and config for utility classes. */
|
|
91
|
+
@source "../../../content/**/*";
|
|
92
|
+
@source "../../app.config.ts";
|
|
93
|
+
|
|
94
|
+
@theme static {
|
|
95
|
+
--color-teal: hsl(189, 53%, 41%);
|
|
96
|
+
/* … the rest of the scale … */
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
:root {
|
|
100
|
+
--ui-primary: var(--color-teal);
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The scale name must match `ui.colors.primary` in your `app.config.ts` for Nuxt
|
|
105
|
+
UI to resolve component variants onto it.
|
|
106
|
+
|
|
107
|
+
### Guardrail tests
|
|
108
|
+
|
|
109
|
+
Both halves of that contract fail silently, so the layer ships the guards.
|
|
110
|
+
`vitest` is an optional peer dependency, needed only for these.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
// test/brand-palette-css.test.ts — source-level, no build required
|
|
114
|
+
import { describeBrandPaletteCss } from "@uxfront/layer-docs/test";
|
|
115
|
+
|
|
116
|
+
describeBrandPaletteCss({
|
|
117
|
+
entry: new URL("../app/assets/css/main.css", import.meta.url),
|
|
118
|
+
scale: "teal",
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
// test/brand-palette-compiled.build.test.ts — requires a prior `nuxt build`
|
|
124
|
+
import { describeSingleTailwindPass } from "@uxfront/layer-docs/test";
|
|
125
|
+
|
|
126
|
+
describeSingleTailwindPass({
|
|
127
|
+
output: new URL("../.output/public/_nuxt", import.meta.url),
|
|
128
|
+
scale: "teal",
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The first asserts your CSS entry is a Tailwind entry and defines the palette.
|
|
133
|
+
The second reads the built stylesheet and asserts every base utility is emitted
|
|
134
|
+
**exactly once** — catching both a duplicate Tailwind pass (double payload) and
|
|
135
|
+
a CSS entry that never reached the bundle (no base utilities at all). Run it in
|
|
136
|
+
whichever CI job already builds the app; it needs `.output/` on disk.
|
|
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
|
+
|
|
63
244
|
## Compatibility
|
|
64
245
|
|
|
65
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
|
|