Skip to content

Commit eb4c9de

Browse files
committed
docs: support per-example playground import maps
Parse @import lines in ::: example blocks to declare extra dependencies for the @vue/repl playground. Auto-resolves to esm.sh or accepts explicit URL overrides.
1 parent 543bd9e commit eb4c9de

File tree

5 files changed

+100
-15
lines changed

5 files changed

+100
-15
lines changed

apps/docs/build/markdown.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export default async function MarkdownPlugin () {
8282
env._inExample = true
8383
env._exampleFilePaths = [] as string[]
8484
env._exampleFileOrders = [] as (number | undefined)[]
85+
env._exampleImports = {} as Record<string, string>
8586
env._exampleCollapse = isCollapse
8687
return '' // Defer opening tag until we know the file path(s)
8788
}
@@ -92,15 +93,20 @@ export default async function MarkdownPlugin () {
9293
const orders = env._exampleFileOrders as (number | undefined)[]
9394
const hasOrders = orders?.some(o => o !== undefined)
9495
const collapse = env._exampleCollapse
96+
const imports = env._exampleImports as Record<string, string>
9597

9698
delete env._inExample
9799
delete env._exampleFilePaths
98100
delete env._exampleFileOrders
101+
delete env._exampleImports
99102
delete env._exampleOpened
100103
delete env._examplePathPara
101104
delete env._exampleCollapse
102105

103106
const collapseAttr = collapse ? ' collapse' : ''
107+
const importsAttr = Object.keys(imports).length > 0
108+
? ` :imports="${JSON.stringify(imports).replace(/"/g, '\'')}"`
109+
: ''
104110

105111
// If opened with description, close the description template
106112
if (wasOpened) {
@@ -109,11 +115,11 @@ export default async function MarkdownPlugin () {
109115

110116
// If we have paths but no description content, emit simple peek version
111117
if (paths?.length === 1) {
112-
return `<DocsExample file-path="${paths[0]}"${collapse ? collapseAttr : ' peek'} />\n`
118+
return `<DocsExample file-path="${paths[0]}"${collapse ? collapseAttr : ' peek'}${importsAttr} />\n`
113119
} else if (paths?.length > 1) {
114120
const pathsJson = JSON.stringify(paths).replace(/"/g, '\'')
115121
const ordersAttr = hasOrders ? ` :file-orders="${JSON.stringify(orders)}"` : ''
116-
return `<DocsExample :file-paths="${pathsJson}"${ordersAttr}${collapseAttr} />\n`
122+
return `<DocsExample :file-paths="${pathsJson}"${ordersAttr}${collapseAttr}${importsAttr} />\n`
117123
}
118124

119125
return ''
@@ -148,16 +154,20 @@ export default async function MarkdownPlugin () {
148154
const hasOrders = orders?.some(o => o !== undefined)
149155
const collapse = env._exampleCollapse
150156
const collapseAttr = collapse ? ' collapse' : ''
157+
const imports = env._exampleImports as Record<string, string>
158+
const importsAttr = Object.keys(imports).length > 0
159+
? ` :imports="${JSON.stringify(imports).replace(/"/g, '\'')}"`
160+
: ''
151161
const defaultRender = defaultHeadingOpen
152162
? defaultHeadingOpen(tokens, index, options, env, self)
153163
: self.renderToken(tokens, index, options)
154164

155165
if (paths.length === 1) {
156-
return `<DocsExample file-path="${paths[0]}"${collapseAttr}>\n<template #description>\n${defaultRender}`
166+
return `<DocsExample file-path="${paths[0]}"${collapseAttr}${importsAttr}>\n<template #description>\n${defaultRender}`
157167
} else {
158168
const pathsJson = JSON.stringify(paths).replace(/"/g, '\'')
159169
const ordersAttr = hasOrders ? ` :file-orders="${JSON.stringify(orders)}"` : ''
160-
return `<DocsExample :file-paths="${pathsJson}"${ordersAttr}${collapseAttr}>\n<template #description>\n${defaultRender}`
170+
return `<DocsExample :file-paths="${pathsJson}"${ordersAttr}${collapseAttr}${importsAttr}>\n<template #description>\n${defaultRender}`
161171
}
162172
}
163173

@@ -175,8 +185,9 @@ export default async function MarkdownPlugin () {
175185

176186
// Handle example container: lines starting with / are file paths
177187
// Multiple paths may be in one paragraph separated by newlines
178-
if (env._inExample && inlineToken?.type === 'inline' && inlineToken.content?.startsWith('/')) {
179-
const lines = inlineToken.content.split('\n')
188+
const content = inlineToken?.content?.trim() ?? ''
189+
if (env._inExample && inlineToken?.type === 'inline' && (content.startsWith('/') || content.startsWith('@import'))) {
190+
const lines = inlineToken.content!.split('\n')
180191
for (const line of lines) {
181192
const trimmed = line.trim()
182193
if (trimmed.startsWith('/') && trimmed.length > 1) {
@@ -189,6 +200,14 @@ export default async function MarkdownPlugin () {
189200
;(env._exampleFilePaths as string[]).push(trimmed.slice(1))
190201
;(env._exampleFileOrders as (number | undefined)[]).push(undefined)
191202
}
203+
} else if (trimmed.startsWith('@import ')) {
204+
const rest = trimmed.slice(8).trim()
205+
const space = rest.indexOf(' ')
206+
if (space > 0) {
207+
;(env._exampleImports as Record<string, string>)[rest.slice(0, space)] = rest.slice(space + 1).trim()
208+
} else if (rest) {
209+
;(env._exampleImports as Record<string, string>)[rest] = `https://esm.sh/${rest}`
210+
}
192211
}
193212
}
194213
env._examplePathPara = true
@@ -205,12 +224,16 @@ export default async function MarkdownPlugin () {
205224
const hasOrders = orders?.some(o => o !== undefined)
206225
const collapse = env._exampleCollapse
207226
const collapseAttr = collapse ? ' collapse' : ''
227+
const imports = env._exampleImports as Record<string, string>
228+
const importsAttr = Object.keys(imports).length > 0
229+
? ` :imports="${JSON.stringify(imports).replace(/"/g, '\'')}"`
230+
: ''
208231
if (paths.length === 1) {
209-
return `<DocsExample file-path="${paths[0]}"${collapseAttr}>\n<template #description>\n` + (defaultParagraphOpen ? defaultParagraphOpen(tokens, index, options, env, self) : '<p>')
232+
return `<DocsExample file-path="${paths[0]}"${collapseAttr}${importsAttr}>\n<template #description>\n` + (defaultParagraphOpen ? defaultParagraphOpen(tokens, index, options, env, self) : '<p>')
210233
} else {
211234
const pathsJson = JSON.stringify(paths).replace(/"/g, '\'')
212235
const ordersAttr = hasOrders ? ` :file-orders="${JSON.stringify(orders)}"` : ''
213-
return `<DocsExample :file-paths="${pathsJson}"${ordersAttr}${collapseAttr}>\n<template #description>\n` + (defaultParagraphOpen ? defaultParagraphOpen(tokens, index, options, env, self) : '<p>')
236+
return `<DocsExample :file-paths="${pathsJson}"${ordersAttr}${collapseAttr}${importsAttr}>\n<template #description>\n` + (defaultParagraphOpen ? defaultParagraphOpen(tokens, index, options, env, self) : '<p>')
214237
}
215238
}
216239

apps/docs/src/components/docs/DocsExample.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
code?: string
3535
collapse?: boolean
3636
files?: ExampleFile[]
37+
imports?: Record<string, string>
3738
peek?: boolean
3839
peekLines?: number
3940
}>(), {
@@ -156,7 +157,7 @@
156157
async function openAllInPlayground () {
157158
if (!displayFiles.value?.length) return
158159
const files = displayFiles.value.map(f => ({ name: f.name, code: f.code }))
159-
const url = await usePlayground(files)
160+
const url = await usePlayground(files, undefined, props.imports)
160161
window.open(url, '_blank')
161162
}
162163

apps/docs/src/composables/usePlayground.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,15 @@ function buildPlaygroundFiles (inputFiles: PlaygroundFile[], dir?: string): Reco
101101
* Get editor URL for multiple files.
102102
* When dir is provided, files are nested under src/{dir}/.
103103
*/
104-
export async function usePlayground (inputFiles: PlaygroundFile[], dir?: string): Promise<string> {
104+
export async function usePlayground (
105+
inputFiles: PlaygroundFile[],
106+
dir?: string,
107+
imports?: Record<string, string>,
108+
): Promise<string> {
105109
const files = buildPlaygroundFiles(inputFiles, dir)
106-
const hash = await utoa(JSON.stringify(files))
110+
const data: PlaygroundHashData = { files }
111+
if (imports && Object.keys(imports).length > 0) data.imports = imports
112+
const hash = await encodePlaygroundHash(data)
107113
return `/playground#${hash}`
108114
}
109115

@@ -114,6 +120,7 @@ function isFileRecord (v: unknown): v is Record<string, string> {
114120
export interface PlaygroundHashData {
115121
files: Record<string, string>
116122
active?: string
123+
imports?: Record<string, string>
117124
}
118125

119126
/**
@@ -139,8 +146,12 @@ export async function decodePlaygroundHash (hash: string): Promise<PlaygroundHas
139146
&& 'files' in parsed
140147
&& isFileRecord((parsed as { files: unknown }).files)
141148
) {
142-
const { files, active } = parsed as { files: Record<string, string>, active?: unknown }
143-
return { files, active: isString(active) ? active : undefined }
149+
const { files, active, imports } = parsed as { files: Record<string, string>, active?: unknown, imports?: unknown }
150+
return {
151+
files,
152+
active: isString(active) ? active : undefined,
153+
imports: isFileRecord(imports) ? imports : undefined,
154+
}
144155
}
145156
return null
146157
} catch {

apps/docs/src/composables/usePlaygroundFiles.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,18 @@ export function usePlaygroundFiles () {
3636
const isReady = shallowRef(false)
3737

3838
const aliasMap = shallowRef(new Map<string, string>())
39+
const extraImports = shallowRef<Record<string, string>>()
3940

4041
onMounted(async () => {
4142
const hash = window.location.hash.slice(1)
4243
const decoded = hash ? await decodePlaygroundHash(hash) : null
4344

4445
if (decoded) {
4546
await loadExample(decoded.files, decoded.active)
47+
if (decoded.imports && Object.keys(decoded.imports).length > 0) {
48+
extraImports.value = decoded.imports
49+
store.setImportMap({ imports: decoded.imports }, true)
50+
}
4651
} else {
4752
const theme_ = theme.isDark.value ? 'dark' : 'light'
4853
await store.setFiles(
@@ -113,7 +118,7 @@ export function usePlaygroundFiles () {
113118

114119
const updateHash = debounce(async (files: Record<string, string>, active: string | undefined) => {
115120
if (Object.keys(files).length === 0) return
116-
const hash = await encodePlaygroundHash({ files, active })
121+
const hash = await encodePlaygroundHash({ files, active, imports: extraImports.value })
117122
history.replaceState(null, '', `#${hash}`)
118123
}, 500)
119124

apps/docs/src/pages/composables/selection/create-selection.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,52 @@ Selection state is **always reactive**. Collection methods follow the base `crea
7272
## Examples
7373

7474
::: example
75-
/composables/create-selection/file-picker
75+
/composables/create-selection/context.ts
76+
/composables/create-selection/BookmarkProvider.vue
77+
/composables/create-selection/BookmarkConsumer.vue
78+
/composables/create-selection/bookmark-manager.vue
79+
@import @mdi/js
80+
81+
### Bookmark Manager
82+
83+
A multi-select bookmark manager demonstrating `createSelection` with `createContext` for provider/consumer separation. The provider owns the registry and exposes domain methods; the consumer handles UI and filtering.
84+
85+
```mermaid "Data Flow"
86+
sequenceDiagram
87+
participant C as BookmarkConsumer
88+
participant P as BookmarkProvider
89+
participant S as createSelection
90+
91+
C->>P: add(title, url, tags)
92+
P->>S: register(ticket)
93+
S-->>P: selectedIds updated
94+
P-->>C: stats / pinned computed
95+
C->>S: toggle(id)
96+
S-->>C: isSelected ref updated
97+
```
98+
99+
**File breakdown:**
100+
101+
| File | Role |
102+
|------|------|
103+
| `context.ts` | Defines `BookmarkInput` (extending `SelectionTicketInput`) and `BookmarkContext`, then creates the `[useBookmarks, provideBookmarks]` tuple |
104+
| `BookmarkProvider.vue` | Creates the selection with `events: true`, wraps it with `useProxyRegistry` for reactive collection access, manages a separate `pinnedIds` Set, seeds initial bookmarks via `onboard`, and exposes mutation methods through context |
105+
| `BookmarkConsumer.vue` | Calls `useBookmarks()` for data and methods; wraps the context with `useProxyRegistry` for reactive `values`; owns local UI state (filters, inputs) and derives `filtered` and `allTags` as computed |
106+
| `bookmark-manager.vue` | Entry point—composes `BookmarkProvider` around `BookmarkConsumer` |
107+
108+
**Key patterns:**
109+
110+
- `events: true` + `useProxyRegistry` — enables reactive `proxy.values` so computeds like `filtered` and `allTags` update when bookmarks are added
111+
- `pinnedIds` — a separate `shallowReactive(Set)` for pin state, following the same pattern as `selectedIds`
112+
- `onboard()` — bulk-loads the initial bookmark set in a single batch
113+
- `selection.register()` — adds a bookmark with custom fields (`url`, `tags`)
114+
- `selection.toggle()` / `ticket.toggle()` — toggles selection from either the registry or the ticket
115+
- `disabled: true` — prevents selecting deprecated bookmarks
116+
- Tag-based filtering with `Select all` / `Clear` bulk actions
117+
- `Checkbox.Root` + `Checkbox.Indicator` — headless checkbox from `@vuetify/v0`
118+
119+
Add bookmarks, filter by tag, toggle selection with checkboxes, and pin favorites. Hover over a row to see the pin action.
120+
76121
:::
77122

78123
<DocsApi />

0 commit comments

Comments
 (0)