@poveste/app 0.19.1 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/bundled/app.css +208 -7
  2. package/dist/bundled/components/HomeView.vue_vue_type_script_setup_true_lang.js +3 -3
  3. package/dist/bundled/components/app/AppLogo.vue_vue_type_script_setup_true_lang.js +4 -4
  4. package/dist/bundled/components/app/TopBarChipItem.js +10 -6
  5. package/dist/bundled/components/layout/LayoutItem.js +1 -0
  6. package/dist/bundled/components/layout/LayoutItem.vue_vue_type_script_setup_true_lang.js +20 -23
  7. package/dist/bundled/components/layout/LayoutModal.vue_vue_type_script_setup_true_lang.js +44 -5
  8. package/dist/bundled/components/panel/ComponentDocs.js +7 -0
  9. package/dist/bundled/components/panel/ComponentDocs.vue_vue_type_script_setup_true_lang.js +164 -0
  10. package/dist/bundled/components/panel/StoryDocs.vue_vue_type_script_setup_true_lang.js +57 -22
  11. package/dist/bundled/components/panel/StorySourceCode.js +1 -1
  12. package/dist/bundled/components/panel/StorySourceCode.vue_vue_type_script_setup_true_lang.js +22 -6
  13. package/dist/bundled/components/story/GenericMountStory.vue_vue_type_script_setup_true_lang.js +3 -7
  14. package/dist/bundled/components/story/GenericRenderStory.vue_vue_type_script_setup_true_lang.js +3 -10
  15. package/dist/bundled/components/story/StoryResponsivePreview.js +1 -1
  16. package/dist/bundled/components/story/StoryResponsivePreview.vue_vue_type_script_setup_true_lang.js +5 -3
  17. package/dist/bundled/components/story/StoryVariantGridItem.js +1 -1
  18. package/dist/bundled/components/story/StoryVariantGridItem.vue_vue_type_script_setup_true_lang.js +5 -3
  19. package/dist/bundled/components/story/StoryVariantSinglePreviewRemote.vue_vue_type_script_setup_true_lang.js +3 -2
  20. package/dist/bundled/components/toolbar/DevOnlyToolbarOpenInEditor.vue_vue_type_script_setup_true_lang.js +0 -1
  21. package/dist/bundled/index.js +5 -1
  22. package/dist/bundled/sandbox.js +2 -0
  23. package/dist/bundled/util/client-plugin.js +22 -0
  24. package/dist/bundled/util/component-docs-data.js +5 -0
  25. package/dist/bundled/util/component-docs.js +133 -0
  26. package/dist/bundled/util/dark.js +3 -1
  27. package/dist/bundled/util/docs.js +13 -3
  28. package/dist/bundled/util/highlighter.js +1 -1
  29. package/dist/bundled/util/icons.generated.js +3 -2
  30. package/dist/bundled/util/keyboard.js +5 -4
  31. package/dist/bundled/util/mapping.js +1 -5
  32. package/dist/style.css +321 -195
  33. package/package.json +17 -17
  34. package/src/app/components/HomeView.vue +2 -2
  35. package/src/app/components/app/AppLogo.vue +4 -6
  36. package/src/app/components/app/TopBarChipItem.vue +55 -6
  37. package/src/app/components/layout/LayoutItem.vue +56 -6
  38. package/src/app/components/layout/LayoutModal.vue +55 -2
  39. package/src/app/components/panel/ComponentDocs.vue +323 -0
  40. package/src/app/components/panel/StoryDocs.vue +38 -2
  41. package/src/app/components/panel/StorySourceCode.vue +29 -5
  42. package/src/app/components/story/GenericMountStory.vue +3 -5
  43. package/src/app/components/story/GenericRenderStory.vue +4 -12
  44. package/src/app/components/story/StoryResponsivePreview.vue +4 -1
  45. package/src/app/components/story/StoryVariantGridItem.vue +4 -1
  46. package/src/app/components/story/StoryVariantSinglePreviewRemote.vue +6 -4
  47. package/src/app/components/toolbar/DevOnlyToolbarOpenInEditor.vue +0 -1
  48. package/src/app/index.ts +12 -3
  49. package/src/app/sandbox.ts +5 -0
  50. package/src/app/util/client-plugin.ts +33 -0
  51. package/src/app/util/component-docs-data.ts +6 -0
  52. package/src/app/util/component-docs.ts +169 -0
  53. package/src/app/util/dark.ts +6 -0
  54. package/src/app/util/docs.ts +18 -2
  55. package/src/app/util/highlighter.ts +2 -1
  56. package/src/app/util/icons.generated.ts +2 -2
  57. package/src/app/util/keyboard.ts +8 -4
  58. package/src/app/util/mapping.ts +1 -5
@@ -5,9 +5,11 @@ import { Icon } from '@iconify/vue'
5
5
  import { markdownFiles } from 'virtual:$poveste-markdown-files'
6
6
  import { computed, nextTick, ref, toRefs, watch, watchEffect } from 'vue'
7
7
  import { useRoute, useRouter } from 'vue-router'
8
+ import { onlySections, useComponentDocs } from '../../util/component-docs.js'
8
9
  import { povesteConfig } from '../../util/config.js'
9
10
  import BaseEmpty from '../base/BaseEmpty.vue'
10
11
  import DevOnlyToolbarOpenInEditor from '../toolbar/DevOnlyToolbarOpenInEditor.vue'
12
+ import ComponentDocs from './ComponentDocs.vue'
11
13
 
12
14
  export function useStoryDoc(story: Ref<Story>) {
13
15
  const renderedDoc = ref('')
@@ -76,6 +78,10 @@ const { story } = toRefs(props)
76
78
 
77
79
  const { renderedDoc } = useStoryDoc(story)
78
80
 
81
+ // Opening the docs is what starts auto-docs extraction (#1159).
82
+ const { components: allComponents, pending, off } = useComponentDocs(computed(() => story.value?.id))
83
+ const components = computed(() => allComponents.value && onlySections(allComponents.value, story.value?.autoDocs))
84
+
79
85
  // Markdown links to other stories
80
86
  const router = useRouter()
81
87
 
@@ -184,8 +190,33 @@ const filePath = computed(() => story.value.file?.docsFilePath ?? (props.standal
184
190
  />
185
191
  </div>
186
192
 
193
+ <!--
194
+ The same empty panel said "still reading" and "off, for a reason" alike (#1400).
195
+ A built book's data is a chunk away, so it shows nothing until then, not a spinner.
196
+ -->
197
+ <BaseEmpty
198
+ v-if="__POVESTE_DEV__ && !renderedDoc && !components?.length && pending"
199
+ data-testid="docs-pending"
200
+ >
201
+ <Icon
202
+ icon="carbon:circle-dash"
203
+ class="w-8 h-8 opacity-50 mb-6 animate-spin"
204
+ />
205
+ Reading component types…
206
+ <span class="text-sm opacity-70 mt-2">The first time can take a while.</span>
207
+ </BaseEmpty>
208
+ <BaseEmpty
209
+ v-else-if="!pending && !renderedDoc && !components?.length && off"
210
+ data-testid="docs-off"
211
+ >
212
+ <Icon
213
+ icon="carbon:document-unknown"
214
+ class="w-8 h-8 opacity-50 mb-6"
215
+ />
216
+ {{ off }}
217
+ </BaseEmpty>
187
218
  <BaseEmpty
188
- v-if="!renderedDoc"
219
+ v-else-if="!pending && !renderedDoc && !components?.length"
189
220
  >
190
221
  <Icon
191
222
  icon="carbon:document-unknown"
@@ -194,11 +225,16 @@ const filePath = computed(() => story.value.file?.docsFilePath ?? (props.standal
194
225
  No documentation available
195
226
  </BaseEmpty>
196
227
  <div
197
- v-else
228
+ v-if="renderedDoc"
198
229
  ref="renderedEl"
199
230
  class="prose dark:prose-invert p-4 max-w-none"
200
231
  data-testid="story-docs"
201
232
  v-html="renderedDoc"
202
233
  />
234
+ <ComponentDocs
235
+ v-if="components?.length"
236
+ :components="components"
237
+ :story-file="story.file?.filePath"
238
+ />
203
239
  </div>
204
240
  </template>
@@ -6,7 +6,7 @@ import { Icon } from '@iconify/vue'
6
6
  import { HstCopyIcon, HstTooltip } from '@poveste/controls'
7
7
  import { computed, markRaw, nextTick, onMounted, ref, shallowRef, watch, watchEffect } from 'vue'
8
8
  import { isDark } from '../../util/dark'
9
- import { getDynamicSourceCode, getStaticSourceCode, loadGenerateSourceCode } from '../../util/docs'
9
+ import { getDynamicSourceCode, getStaticSourceCode, loadGenerateSourceCode, sourceLang } from '../../util/docs'
10
10
  import { getHighlighter } from '../../util/highlighter'
11
11
  import BaseEmpty from '../base/BaseEmpty.vue'
12
12
 
@@ -19,8 +19,21 @@ const props = defineProps<{
19
19
  // down were both errors about a type nobody meant to declare.
20
20
  const generateSourceCodeFn = shallowRef<GenerateSourceCode | null>(null)
21
21
 
22
+ // Until a source has settled, an empty one means "not yet", not "none": a copy in
23
+ // that window said "Nothing to copy" with the source a moment away (#1314).
24
+ const dynamicSettled = ref(false)
25
+ const staticSettled = ref(false)
26
+
22
27
  watchEffect(async () => {
23
- const generate = await loadGenerateSourceCode(props.story)
28
+ let generate: GenerateSourceCode | undefined
29
+ try {
30
+ generate = await loadGenerateSourceCode(props.story)
31
+ }
32
+ finally {
33
+ if (!generate) {
34
+ dynamicSettled.value = true
35
+ }
36
+ }
24
37
  if (generate) {
25
38
  generateSourceCodeFn.value = markRaw(generate)
26
39
  }
@@ -40,6 +53,7 @@ watch(() => [props.variant, generateSourceCodeFn.value], async () => {
40
53
  if (!generateSourceCodeFn.value) return
41
54
  error.value = null
42
55
  dynamicSourceCode.value = ''
56
+ dynamicSettled.value = false
43
57
  try {
44
58
  dynamicSourceCode.value = (await getDynamicSourceCode(props.variant, generateSourceCodeFn.value)) ?? ''
45
59
  }
@@ -47,6 +61,7 @@ watch(() => [props.variant, generateSourceCodeFn.value], async () => {
47
61
  console.error(e)
48
62
  error.value = e instanceof Error ? e.message : String(e)
49
63
  }
64
+ dynamicSettled.value = true
50
65
 
51
66
  // Auto-switch
52
67
  if (!dynamicSourceCode.value) {
@@ -62,11 +77,19 @@ watch(() => [props.variant, generateSourceCodeFn.value], async () => {
62
77
  const staticSourceCode = ref('')
63
78
  watch(() => [props.story, props.story?.file?.source], async () => {
64
79
  staticSourceCode.value = ''
65
- staticSourceCode.value = (await getStaticSourceCode(props.story)) ?? ''
80
+ staticSettled.value = false
81
+ try {
82
+ staticSourceCode.value = (await getStaticSourceCode(props.story)) ?? ''
83
+ }
84
+ finally {
85
+ staticSettled.value = true
86
+ }
66
87
  }, {
67
88
  immediate: true,
68
89
  })
69
90
 
91
+ const loading = computed(() => displayedSource.value === 'dynamic' ? !dynamicSettled.value : !staticSettled.value)
92
+
70
93
  const displayedSourceCode = computed(() => {
71
94
  if (displayedSource.value === 'dynamic') {
72
95
  return dynamicSourceCode.value
@@ -89,7 +112,7 @@ onMounted(async () => {
89
112
 
90
113
  const sourceHtml = computed(() => displayedSourceCode.value
91
114
  ? highlighter.value?.codeToHtml(displayedSourceCode.value, {
92
- lang: 'html',
115
+ lang: sourceLang(props.story),
93
116
  theme: isDark.value ? 'github-dark' : 'github-light-high-contrast',
94
117
  })
95
118
  : '')
@@ -189,6 +212,7 @@ watch(sourceHtml, async () => {
189
212
 
190
213
  <HstCopyIcon
191
214
  :content="displayedSourceCode"
215
+ :loading="loading"
192
216
  class="flex-none"
193
217
  />
194
218
  </div>
@@ -205,7 +229,7 @@ watch(sourceHtml, async () => {
205
229
  icon="carbon:code-hide"
206
230
  class="w-8 h-8 opacity-50 mb-6"
207
231
  />
208
- <span>Not available</span>
232
+ <span>{{ loading ? 'Loading' : 'Not available' }}</span>
209
233
  </BaseEmpty>
210
234
 
211
235
  <textarea
@@ -7,8 +7,8 @@ export default {
7
7
  <script lang="ts" setup>
8
8
  import type { Component } from 'vue'
9
9
  import type { Story } from '../../types'
10
- import { clientSupportPlugins } from 'virtual:$poveste-support-plugins-client'
11
10
  import { markRaw, ref, watchEffect } from 'vue'
11
+ import { loadClientPlugin } from '../../util/client-plugin.js'
12
12
 
13
13
  const props = defineProps<{
14
14
  story: Story
@@ -17,10 +17,8 @@ const props = defineProps<{
17
17
  const mountComponent = ref<Component | null>(null)
18
18
 
19
19
  watchEffect(async () => {
20
- const supportPluginId = props.story.file?.supportPluginId
21
- const clientPlugin = supportPluginId ? clientSupportPlugins[supportPluginId] : undefined
22
- if (clientPlugin) {
23
- const pluginModule = await clientPlugin()
20
+ const pluginModule = await loadClientPlugin(props.story.file?.supportPluginId)
21
+ if (pluginModule) {
24
22
  mountComponent.value = markRaw(pluginModule.MountStory)
25
23
  }
26
24
  })
@@ -7,8 +7,8 @@ export default {
7
7
  <script lang="ts" setup>
8
8
  import type { Component } from 'vue'
9
9
  import type { Story } from '../../types'
10
- import { clientSupportPlugins } from 'virtual:$poveste-support-plugins-client'
11
10
  import { markRaw, ref, watchEffect } from 'vue'
11
+ import { loadClientPlugin } from '../../util/client-plugin.js'
12
12
 
13
13
  const props = defineProps<{
14
14
  story: Story
@@ -17,17 +17,9 @@ const props = defineProps<{
17
17
  const mountComponent = ref<Component | null>(null)
18
18
 
19
19
  watchEffect(async () => {
20
- const supportPluginId = props.story.file?.supportPluginId
21
- const clientPlugin = supportPluginId ? clientSupportPlugins[supportPluginId] : undefined
22
- if (clientPlugin) {
23
- try {
24
- const pluginModule = await clientPlugin()
25
- mountComponent.value = markRaw(pluginModule.RenderStory)
26
- }
27
- catch (e) {
28
- console.error(e)
29
- throw e
30
- }
20
+ const pluginModule = await loadClientPlugin(props.story.file?.supportPluginId)
21
+ if (pluginModule) {
22
+ mountComponent.value = markRaw(pluginModule.RenderStory)
31
23
  }
32
24
  })
33
25
  </script>
@@ -6,6 +6,7 @@ import { HstTooltip } from '@poveste/controls'
6
6
  import { useEventListener } from '@vueuse/core'
7
7
  import { computed, onUnmounted, ref } from 'vue'
8
8
  import { usePreviewSettingsStore } from '../../stores/preview-settings'
9
+ import { usePreviewDark } from '../../util/color-scheme'
9
10
  import CheckerboardPattern from '../misc/CheckerboardPattern.vue'
10
11
  import HatchedPattern from '../misc/HatchedPattern.vue'
11
12
 
@@ -21,6 +22,7 @@ const props = withDefaults(defineProps<{
21
22
  })
22
23
 
23
24
  const settings = usePreviewSettingsStore().currentSettings
25
+ const previewDark = usePreviewDark(settings)
24
26
 
25
27
  // Resize
26
28
 
@@ -183,7 +185,8 @@ const sizeTooltip = computed(() => `${responsiveWidth.value ?? 'Auto'} × ${resp
183
185
  // padding (#264). Gated on `responsive`, not `isResponsiveEnabled`:
184
186
  // a single-view story with `responsiveDisabled` still needs its own
185
187
  // chrome, it just loses the draggers.
186
- responsive ? 'bg-white dark:bg-gray-700' : '',
188
+ // The preview's scheme, not the chrome's, which `dark:` would follow (#1449).
189
+ responsive ? (previewDark ? 'bg-gray-700' : 'bg-white') : '',
187
190
  isResponsiveEnabled ? {
188
191
  'w-fit': !!finalWidth,
189
192
  'h-fit': !!finalHeight,
@@ -148,8 +148,11 @@ const useIframe = computed(() => {
148
148
 
149
149
  <!-- Body -->
150
150
  <div
151
- class="border bg-white dark:bg-gray-700 rounded flex-1 p-4 relative"
151
+ class="border rounded flex-1 p-4 relative"
152
152
  :class="{
153
+ // The preview's scheme, not the chrome's, which `dark:` would follow (#1449).
154
+ 'bg-gray-700': previewDark,
155
+ 'bg-white': !previewDark,
153
156
  'border-gray-100 dark:border-gray-800': !isActive,
154
157
  'border-primary-200 dark:border-primary-900': isActive,
155
158
  }"
@@ -50,7 +50,7 @@ function postState(changes: Record<string, any>) {
50
50
  iframe.value?.contentWindow?.postMessage({
51
51
  type: STATE_SYNC,
52
52
  state: changes,
53
- })
53
+ }, window.location.origin)
54
54
  }
55
55
 
56
56
  let bridge = createStateBridge(postState)
@@ -105,9 +105,11 @@ function fromCurrentOccupant(data: { storyId?: string, variantId?: string }) {
105
105
  useEventListener(window, 'message', (event) => {
106
106
  // With many grid iframes mounted, every sandbox postMessage hits every
107
107
  // parent listener. Skip events that didn't originate from this iframe.
108
- // Paired with the origin check in `sandbox.ts`, which guards the other
109
- // direction (#379).
110
108
  if (event.source !== iframe.value?.contentWindow) return
109
+ // And from the book's own origin: the frame keeps its window when a story
110
+ // navigates it off-site, and the site it lands on could post here (#1494).
111
+ // The other direction is `sandbox.ts`'s check (#379).
112
+ if (event.origin !== window.location.origin) return
111
113
  switch (event.data?.type) {
112
114
  case STATE_SYNC:
113
115
  if (!fromCurrentOccupant(event.data)) break
@@ -279,7 +281,7 @@ function syncSettings() {
279
281
  iframe.value.contentWindow.postMessage({
280
282
  type: PREVIEW_SETTINGS_SYNC,
281
283
  settings: toRaw(settings),
282
- })
284
+ }, window.location.origin)
283
285
  }
284
286
  }
285
287
 
@@ -14,7 +14,6 @@ defineProps<{
14
14
  <HstTooltip :content="tooltip">
15
15
  <TopBarChipItem
16
16
  aria-label="Open story in editor"
17
- target="_blank"
18
17
  class="poveste-toolbar-open-in-editor"
19
18
  @click="openInEditor(file)"
20
19
  >
package/src/app/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { addCollection } from '@iconify/vue'
2
2
  import { createPinia } from 'pinia'
3
- import { generation } from 'virtual:$poveste-stories'
3
+ import { generation, onUpdate, icons as storyIcons } from 'virtual:$poveste-stories'
4
4
  import { createApp, watch } from 'vue'
5
5
  import App from './App.vue'
6
6
  import { setupPluginApi } from './plugin.js'
@@ -13,11 +13,20 @@ import 'virtual:$poveste-theme'
13
13
  export async function mountMainApp() {
14
14
  // The chrome's icon data ships with the app. Without this every Icon fetched
15
15
  // its data from api.iconify.design at runtime — a handful of CDN requests per
16
- // boot, and no toolbar offline (#219). Story icons a user sets still resolve
17
- // through the runtime lookup.
16
+ // boot, and no toolbar offline (#219).
18
17
  for (const collection of iconCollections) {
19
18
  addCollection(collection)
20
19
  }
20
+ // And the icons a book's stories name, where the book has the collection
21
+ // installed; the rest still resolve through the runtime lookup (#255).
22
+ for (const collection of storyIcons) {
23
+ addCollection(collection)
24
+ }
25
+ onUpdate((_files: unknown, _tree: unknown, icons: typeof storyIcons = []) => {
26
+ for (const collection of icons) {
27
+ addCollection(collection)
28
+ }
29
+ })
21
30
 
22
31
  // The chrome's own scheme, for the browser: it paints the chrome's native
23
32
  // controls and scrollbars, and reads no class (#991). Here rather than in the
@@ -18,6 +18,8 @@ import { applyPreviewSettings, loadStoredPreviewSettings, receivedSettings } fro
18
18
  import { createStateBridge } from './util/state-bridge.js'
19
19
  import { toRawDeep } from './util/state.js'
20
20
  import { isBenignResizeObserverEvent } from './util/uncaught.js'
21
+ // The book's `theme.colors`, under the private names the controls' tokens read (#1443).
22
+ import 'virtual:$poveste-theme'
21
23
 
22
24
  const query = parseQuery(window.location.search)
23
25
 
@@ -115,6 +117,9 @@ watch(previewDark, (value) => {
115
117
  el.classList.toggle(className, value)
116
118
  }
117
119
  }
120
+ // A builtin control in the story keys its dark rules on `.ptw-dark`, so it follows
121
+ // the preview it sits in, not the chrome around it (#1443).
122
+ document.documentElement.classList.toggle('ptw-dark', value)
118
123
  }, {
119
124
  immediate: true,
120
125
  })
@@ -0,0 +1,33 @@
1
+ import type { ClientSupportPlugin } from '@poveste/shared'
2
+ import type { Component } from 'vue'
3
+ import { reportStoryError, toStoryError } from '@poveste/shared'
4
+ import { clientSupportPlugins } from 'virtual:$poveste-support-plugins-client'
5
+ import { occupant } from './occupant.js'
6
+
7
+ /*
8
+ * A framework's client imports the book's setup file, so a setup file that throws
9
+ * when it loads fails here, for every story, before any story code runs. In a
10
+ * built book nothing else reported it: Vue's production build only logs an async
11
+ * effect's rejection, and every preview stayed blank with the error in the console
12
+ * alone (#1323). Not rethrown, because Vue's development build would rethrow it as
13
+ * an unhandled rejection, whose report would replace this one.
14
+ */
15
+ export async function loadClientPlugin(supportPluginId: string | undefined): Promise<ClientSupportPlugin<Component> | undefined> {
16
+ const load = supportPluginId ? clientSupportPlugins[supportPluginId] : undefined
17
+ if (!load) {
18
+ return undefined
19
+ }
20
+ try {
21
+ return await load()
22
+ }
23
+ catch (error) {
24
+ const { message, stack } = toStoryError(error)
25
+ const reported = Object.assign(new Error(`The story renderer failed to load, and it imports the setup file: ${message}`), { stack })
26
+ reportStoryError(reported, {
27
+ storyId: occupant.storyId ?? undefined,
28
+ variantId: occupant.variantId ?? undefined,
29
+ })
30
+ console.error(error)
31
+ return undefined
32
+ }
33
+ }
@@ -0,0 +1,6 @@
1
+ import type { ComponentDoc } from '@poveste/shared'
2
+ import { componentDocs as data } from 'virtual:$poveste-component-docs'
3
+
4
+ // What `poveste build` extracted: a small map from each story to its components, each
5
+ // in a chunk of its own, so a reader downloads only the tables of the stories they open (#1429).
6
+ export const componentDocs: Record<string, Record<string, () => Promise<{ default: { doc: ComponentDoc } }>>> = data
@@ -0,0 +1,169 @@
1
+ import type { AutoDocsSection, ComponentDoc, DocTag } from '@poveste/shared'
2
+ import type { Ref } from 'vue'
3
+ import { onBeforeUnmount, ref, watch } from 'vue'
4
+
5
+ export interface ComponentDocsEntry {
6
+ /** Relative to the book's root. */
7
+ file: string
8
+ /** The component's own name, else the file name without its extension. */
9
+ name: string
10
+ doc?: ComponentDoc
11
+ error?: string
12
+ }
13
+
14
+ interface StoryDocsResult {
15
+ storyId: string
16
+ components: Record<string, { doc?: ComponentDoc, error?: string, name?: string }>
17
+ /** Why auto-docs is off for this story, as the terminal says it. */
18
+ off?: string
19
+ }
20
+
21
+ export function toEntries(result: StoryDocsResult): ComponentDocsEntry[] {
22
+ return Object.entries(result.components).map(([file, { doc, error, name }]) => ({
23
+ file,
24
+ // A JSX story's result is keyed by the story file, so the extractor names the component,
25
+ // and an unnamed one, such as an inline function, goes by the story's file name.
26
+ name: doc?.name ?? name ?? file.split('/').pop()!.replace(/(?:\.story)?\.[^.]+$/, ''),
27
+ ...doc ? { doc } : {},
28
+ ...error ? { error } : {},
29
+ }))
30
+ }
31
+
32
+ /**
33
+ * Only the sections a story's `autoDocs` names, in the table's own order. A component
34
+ * left with nothing to show is dropped; one that failed keeps its error (#1398).
35
+ */
36
+ export function onlySections(entries: ComponentDocsEntry[], autoDocs: boolean | AutoDocsSection[] | undefined): ComponentDocsEntry[] {
37
+ if (!Array.isArray(autoDocs)) {
38
+ return entries
39
+ }
40
+ return entries.flatMap((entry) => {
41
+ if (!entry.doc) {
42
+ return [entry]
43
+ }
44
+ const doc = {
45
+ ...entry.doc,
46
+ props: autoDocs.includes('props') ? entry.doc.props : [],
47
+ slots: autoDocs.includes('slots') ? entry.doc.slots : [],
48
+ events: autoDocs.includes('events') ? entry.doc.events : [],
49
+ }
50
+ return doc.props.length || doc.slots.length || doc.events.length ? [{ ...entry, doc }] : []
51
+ })
52
+ }
53
+
54
+ /** Above this many, a story's components after the first start collapsed: a compound story can name 27 parts (#1432). */
55
+ export const OPEN_PARTS = 3
56
+
57
+ /**
58
+ * A compound component's root first, `SelectRoot` before `SelectTrigger`, else the order
59
+ * they came in. A story rendering two families, such as a Select inside a Calendar, opens
60
+ * the root its own folder or file is named for: `Calendar/story/CalendarSelect.story.vue`
61
+ * opens `CalendarRoot`, not the `SelectRoot` it imported first.
62
+ */
63
+ export function rootFirst(entries: ComponentDocsEntry[], storyFile?: string): ComponentDocsEntry[] {
64
+ const roots = entries.filter(entry => entry.name.endsWith('Root'))
65
+ const family = (entry: ComponentDocsEntry) => entry.name.slice(0, -'Root'.length)
66
+ const segments = storyFile?.split(/[\\/]/) ?? []
67
+ const fileName = segments.at(-1) ?? ''
68
+ const root = roots.find(entry => segments.slice(0, -1).includes(family(entry)))
69
+ ?? roots.find(entry => fileName.startsWith(family(entry)))
70
+ ?? roots[0]
71
+ return root ? [root, ...entries.filter(entry => entry !== root)] : entries
72
+ }
73
+
74
+ /**
75
+ * The `v-model` a prop and its `update:` event make together, as a template writes it:
76
+ * `v-model` for `modelValue`, `v-model:open` for `open` (#1436). Asked with either name,
77
+ * so the two rows of a pair, one under Props and one under Events, carry the same mark.
78
+ */
79
+ export function modelLabel(doc: ComponentDoc, name: string): string | undefined {
80
+ const prop = name.startsWith('update:') ? name.slice('update:'.length) : name
81
+ const paired = doc.props.some(candidate => candidate.name === prop) && doc.events.some(event => event.name === `update:${prop}`)
82
+ if (!paired) {
83
+ return undefined
84
+ }
85
+ return prop === 'modelValue' ? 'v-model' : `v-model:${prop}`
86
+ }
87
+
88
+ export function tagText(tags: DocTag[], name: string): string | undefined {
89
+ const tag = tags.find(tag => tag.name === name)
90
+ return tag ? (tag.text ?? '') : undefined
91
+ }
92
+
93
+ /** A `@see` or `@link` points somewhere only when it carries a URL. */
94
+ export function tagHref(text: string): string | undefined {
95
+ return /https?:\/\/\S+/.exec(text)?.[0]
96
+ }
97
+
98
+ /**
99
+ * The components a story imports, documented. In dev they are asked of the server
100
+ * when this is first used, which is what starts extraction at all (#1159); in a
101
+ * built book they come from what `poveste build` extracted.
102
+ */
103
+ export function useComponentDocs(storyId: Ref<string | undefined>) {
104
+ const components = ref<ComponentDocsEntry[]>()
105
+ // Asked and not yet answered: the first answer can take many seconds (#1400).
106
+ const pending = ref(false)
107
+ const off = ref<string>()
108
+ const hot = import.meta.hot
109
+ if (!hot) {
110
+ // The chunk loads on first use, and until then the tab said there were no docs (#1400).
111
+ watch(storyId, async (id, _, onCleanup) => {
112
+ // Another story opened while these loaded.
113
+ let stale = false
114
+ onCleanup(() => stale = true)
115
+ pending.value = true
116
+ try {
117
+ const { componentDocs } = await import('./component-docs-data')
118
+ const loaders = id ? componentDocs[id] : undefined
119
+ const docs = loaders && Object.fromEntries(await Promise.all(Object.entries(loaders).map(async ([file, load]) => [file, (await load()).default])))
120
+ if (!stale) {
121
+ components.value = id && docs ? toEntries({ storyId: id, components: docs }) : undefined
122
+ }
123
+ }
124
+ catch (error) {
125
+ // A chunk the server no longer has, as in a tab left open across a deploy:
126
+ // no table, rather than a tab that waits forever.
127
+ if (!stale) {
128
+ components.value = undefined
129
+ console.error('[poveste] could not load this story\'s component docs:', error)
130
+ }
131
+ }
132
+ finally {
133
+ if (!stale) {
134
+ pending.value = false
135
+ }
136
+ }
137
+ }, { immediate: true })
138
+ return { components, pending, off }
139
+ }
140
+
141
+ function ask() {
142
+ if (storyId.value) {
143
+ pending.value = true
144
+ hot!.send('poveste:docgen-request', { storyId: storyId.value })
145
+ }
146
+ }
147
+ function onResult(result: StoryDocsResult) {
148
+ if (result.storyId === storyId.value) {
149
+ components.value = toEntries(result)
150
+ off.value = result.off
151
+ pending.value = false
152
+ }
153
+ }
154
+
155
+ hot.on('poveste:docgen-result', onResult)
156
+ hot.on('poveste:docgen-stale', ask)
157
+ onBeforeUnmount(() => {
158
+ hot.off('poveste:docgen-result', onResult)
159
+ hot.off('poveste:docgen-stale', ask)
160
+ })
161
+ watch(storyId, () => {
162
+ components.value = undefined
163
+ off.value = undefined
164
+ pending.value = false
165
+ ask()
166
+ }, { immediate: true })
167
+
168
+ return { components, pending, off }
169
+ }
@@ -2,11 +2,17 @@ import { useDark, useToggle } from '@vueuse/core'
2
2
  import { watch } from 'vue'
3
3
  import { povesteConfig } from './config.js'
4
4
 
5
+ // A sandboxed story's realm reads the chrome's setting but shows the story preview's
6
+ // dark, which `sandbox.ts` puts on its `<html>` for the controls; applying this one
7
+ // there too would fight it (#1443).
8
+ const inSandbox = location.href.includes('__sandbox')
9
+
5
10
  export const isDark = useDark({
6
11
  valueDark: 'ptw-dark',
7
12
  ...povesteConfig.theme.defaultColorScheme ? { initialValue: povesteConfig.theme.defaultColorScheme } : {},
8
13
  storageKey: 'poveste-color-scheme',
9
14
  storage: povesteConfig.theme.storeColorScheme ? localStorage : sessionStorage,
15
+ ...inSandbox ? { onChanged: () => {} } : {},
10
16
  })
11
17
  export const toggleDark = useToggle(isDark)
12
18
 
@@ -7,8 +7,15 @@ export type GenerateSourceCode = ClientSupportPlugin['generateSourceCode']
7
7
 
8
8
  /** What the variant declares, or what its framework generates from it; `undefined` when neither has anything. */
9
9
  export async function getDynamicSourceCode(variant: Variant, generate: GenerateSourceCode | undefined): Promise<string | undefined> {
10
- if (variant.source) {
11
- return variant.source
10
+ // A Svelte story's `source` can be a function of the variant's state, so the
11
+ // panel follows the controls; handed on as it was, it reached the highlighter and
12
+ // threw (#1511).
13
+ const source: unknown = variant.source
14
+ if (typeof source === 'function') {
15
+ return source(variant.state) || undefined
16
+ }
17
+ if (source) {
18
+ return source as string
12
19
  }
13
20
  const sourceSlot = variant.slots?.().source
14
21
  if (sourceSlot) {
@@ -18,6 +25,15 @@ export async function getDynamicSourceCode(variant: Variant, generate: GenerateS
18
25
  return (await generate?.(variant)) || undefined
19
26
  }
20
27
 
28
+ /**
29
+ * The grammar a story's source reads in: a Vue or Svelte story is HTML with script
30
+ * inside, and a Solid or React story file is TypeScript with JSX, which the HTML
31
+ * grammar left plain (#1521). A variant's own source follows its story's.
32
+ */
33
+ export function sourceLang(story: Story | undefined): 'html' | 'tsx' {
34
+ return /\.[cm]?[jt]sx$/.test(story?.file?.filePath ?? '') ? 'tsx' : 'html'
35
+ }
36
+
21
37
  export async function getStaticSourceCode(story: Story): Promise<string | undefined> {
22
38
  const sourceLoader = story.file?.source
23
39
  return sourceLoader ? (await sourceLoader()).default : undefined
@@ -19,9 +19,10 @@ let highlighterPromise: Promise<HighlighterCore> | undefined
19
19
  */
20
20
  export function getHighlighter(): Promise<HighlighterCore> {
21
21
  highlighterPromise ??= createHighlighterCore({
22
+ // `tsx` reads JSX too, and a `.story.tsx` file's types besides (#1521).
22
23
  langs: [
23
24
  import('shiki/langs/html.mjs'),
24
- import('shiki/langs/jsx.mjs'),
25
+ import('shiki/langs/tsx.mjs'),
25
26
  ],
26
27
  /*
27
28
  * `github-light-high-contrast`, not `github-light`. The plain one paints