create-grist-widget 0.3.25 → 0.3.27

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.
@@ -1,13 +1,12 @@
1
1
  /**
2
- * Mirrors `main.tsx`'s real wiring (`GristWidgetProvider` -> `GristBoundary`
3
- * -> the widget) without importing `main.tsx` itself, so this test exercises
4
- * the same gate logic the embedded widget actually runs under. See
5
- * `packages/core/tests/sdk/template-app.test.tsx` in the SDK repo for the
6
- * same pattern applied to a bare shell.
2
+ * Renders the widget the way `main.tsx` mounts it inside Grist
3
+ * (`GristWidgetApp`: provider, boundary gated on the mapped columns, alerts),
4
+ * against an emulated Grist, so this test exercises what the embedded widget
5
+ * actually runs under.
7
6
  */
8
7
  import "@testing-library/jest-dom/vitest"
9
8
  import { afterEach, describe, expect, it } from "vitest"
10
- import { GristBoundary, GristWidgetProvider } from "grist-widget-sdk"
9
+ import { GristWidgetApp } from "grist-widget-sdk/app"
11
10
  import {
12
11
  act,
13
12
  actionsOf,
@@ -28,13 +27,7 @@ import App, { GRIST_OPTIONS } from "./App"
28
27
  afterEach(() => cleanup())
29
28
 
30
29
  function Wrapped() {
31
- return (
32
- <GristWidgetProvider options={GRIST_OPTIONS}>
33
- <GristBoundary gate={GRIST_OPTIONS.columns?.length ? "canRender" : "ready"}>
34
- <App />
35
- </GristBoundary>
36
- </GristWidgetProvider>
37
- )
30
+ return <GristWidgetApp App={App} options={GRIST_OPTIONS} />
38
31
  }
39
32
 
40
33
  describe("App", () => {
@@ -1,229 +1,5 @@
1
- import * as React from "react"
2
-
3
- type Theme = "dark" | "light" | "system"
4
- type ResolvedTheme = "dark" | "light"
5
-
6
- type ThemeProviderProps = {
7
- children: React.ReactNode
8
- defaultTheme?: Theme
9
- storageKey?: string
10
- disableTransitionOnChange?: boolean
11
- }
12
-
13
- type ThemeProviderState = {
14
- theme: Theme
15
- setTheme: (theme: Theme) => void
16
- }
17
-
18
- const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)"
19
- const THEME_VALUES: Theme[] = ["dark", "light", "system"]
20
-
21
- const ThemeProviderContext = React.createContext<
22
- ThemeProviderState | undefined
23
- >(undefined)
24
-
25
- function isTheme(value: string | null): value is Theme {
26
- if (value === null) {
27
- return false
28
- }
29
-
30
- return THEME_VALUES.includes(value as Theme)
31
- }
32
-
33
- function getSystemTheme(): ResolvedTheme {
34
- if (window.matchMedia(COLOR_SCHEME_QUERY).matches) {
35
- return "dark"
36
- }
37
-
38
- return "light"
39
- }
40
-
41
- function disableTransitionsTemporarily() {
42
- const style = document.createElement("style")
43
- style.appendChild(
44
- document.createTextNode(
45
- "*,*::before,*::after{-webkit-transition:none!important;transition:none!important}"
46
- )
47
- )
48
- document.head.appendChild(style)
49
-
50
- return () => {
51
- window.getComputedStyle(document.body)
52
- requestAnimationFrame(() => {
53
- requestAnimationFrame(() => {
54
- style.remove()
55
- })
56
- })
57
- }
58
- }
59
-
60
- function isEditableTarget(target: EventTarget | null) {
61
- if (!(target instanceof HTMLElement)) {
62
- return false
63
- }
64
-
65
- if (target.isContentEditable) {
66
- return true
67
- }
68
-
69
- const editableParent = target.closest(
70
- "input, textarea, select, [contenteditable='true']"
71
- )
72
- if (editableParent) {
73
- return true
74
- }
75
-
76
- return false
77
- }
78
-
79
- export function ThemeProvider({
80
- children,
81
- defaultTheme = "system",
82
- storageKey = "theme",
83
- disableTransitionOnChange = true,
84
- ...props
85
- }: ThemeProviderProps) {
86
- const [theme, setThemeState] = React.useState<Theme>(() => {
87
- const storedTheme = localStorage.getItem(storageKey)
88
- if (isTheme(storedTheme)) {
89
- return storedTheme
90
- }
91
-
92
- return defaultTheme
93
- })
94
-
95
- const setTheme = React.useCallback(
96
- (nextTheme: Theme) => {
97
- localStorage.setItem(storageKey, nextTheme)
98
- setThemeState(nextTheme)
99
- },
100
- [storageKey]
101
- )
102
-
103
- const applyTheme = React.useCallback(
104
- (nextTheme: Theme) => {
105
- const root = document.documentElement
106
- const resolvedTheme =
107
- nextTheme === "system" ? getSystemTheme() : nextTheme
108
- const restoreTransitions = disableTransitionOnChange
109
- ? disableTransitionsTemporarily()
110
- : null
111
-
112
- root.classList.remove("light", "dark")
113
- root.classList.add(resolvedTheme)
114
-
115
- if (restoreTransitions) {
116
- restoreTransitions()
117
- }
118
- },
119
- [disableTransitionOnChange]
120
- )
121
-
122
- React.useEffect(() => {
123
- applyTheme(theme)
124
-
125
- if (theme !== "system") {
126
- return undefined
127
- }
128
-
129
- const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY)
130
- const handleChange = () => {
131
- applyTheme("system")
132
- }
133
-
134
- mediaQuery.addEventListener("change", handleChange)
135
-
136
- return () => {
137
- mediaQuery.removeEventListener("change", handleChange)
138
- }
139
- }, [theme, applyTheme])
140
-
141
- React.useEffect(() => {
142
- const handleKeyDown = (event: KeyboardEvent) => {
143
- if (event.repeat) {
144
- return
145
- }
146
-
147
- if (event.metaKey || event.ctrlKey || event.altKey) {
148
- return
149
- }
150
-
151
- if (isEditableTarget(event.target)) {
152
- return
153
- }
154
-
155
- if (event.key.toLowerCase() !== "d") {
156
- return
157
- }
158
-
159
- setThemeState((currentTheme) => {
160
- const nextTheme =
161
- currentTheme === "dark"
162
- ? "light"
163
- : currentTheme === "light"
164
- ? "dark"
165
- : getSystemTheme() === "dark"
166
- ? "light"
167
- : "dark"
168
-
169
- localStorage.setItem(storageKey, nextTheme)
170
- return nextTheme
171
- })
172
- }
173
-
174
- window.addEventListener("keydown", handleKeyDown)
175
-
176
- return () => {
177
- window.removeEventListener("keydown", handleKeyDown)
178
- }
179
- }, [storageKey])
180
-
181
- React.useEffect(() => {
182
- const handleStorageChange = (event: StorageEvent) => {
183
- if (event.storageArea !== localStorage) {
184
- return
185
- }
186
-
187
- if (event.key !== storageKey) {
188
- return
189
- }
190
-
191
- if (isTheme(event.newValue)) {
192
- setThemeState(event.newValue)
193
- return
194
- }
195
-
196
- setThemeState(defaultTheme)
197
- }
198
-
199
- window.addEventListener("storage", handleStorageChange)
200
-
201
- return () => {
202
- window.removeEventListener("storage", handleStorageChange)
203
- }
204
- }, [defaultTheme, storageKey])
205
-
206
- const value = React.useMemo(
207
- () => ({
208
- theme,
209
- setTheme,
210
- }),
211
- [theme, setTheme]
212
- )
213
-
214
- return (
215
- <ThemeProviderContext.Provider {...props} value={value}>
216
- {children}
217
- </ThemeProviderContext.Provider>
218
- )
219
- }
220
-
221
- export const useTheme = () => {
222
- const context = React.useContext(ThemeProviderContext)
223
-
224
- if (context === undefined) {
225
- throw new Error("useTheme must be used within a ThemeProvider")
226
- }
227
-
228
- return context
229
- }
1
+ // shadcn components (a toaster, a mode toggle) import the theme from here.
2
+ // grist-widget-sdk/app owns it: inside Grist the widget follows Grist's
3
+ // light/dark theme; on the dev server and the release page, the OS setting,
4
+ // a saved choice and the `d` key.
5
+ export { useTheme } from "grist-widget-sdk/app"
@@ -1,57 +1,11 @@
1
- import { StrictMode } from "react"
2
- import { createRoot } from "react-dom/client"
3
- import { GristBoundary, GristWidgetProvider } from "grist-widget-sdk"
1
+ import { mountGristWidget } from "grist-widget-sdk/app"
4
2
 
5
- import { GristSdkAlerts } from "@/components/grist-sdk-alerts"
6
- import { LocalDevNotice } from "@/components/local-dev-notice"
7
- import { ReleaseInfo } from "@/components/release-info"
8
- import { ThemeProvider } from "@/components/theme-provider.tsx"
9
- import { parseShowcasePath } from "@/lib/showcase-routing"
10
3
  import "./index.css"
11
4
  import App, { GRIST_OPTIONS } from "./App.tsx"
12
5
 
13
- // A Grist custom widget only ever runs embedded in Grist's own iframe.
14
- // Opened directly in a browser tab (window.self === window.top), there's no
15
- // Grist to connect to -- two different non-embedded experiences, told apart
16
- // by import.meta.env.DEV (a real embed always wins over either, checked
17
- // first):
18
- // - `pnpm dev` (DEV) -> LocalDevNotice: a bare shell pointing at the real
19
- // verification loop (push to `dev`, open it in a real Grist doc) --
20
- // deliberately not a live/seeded preview. An emulator can't guarantee
21
- // parity with real Grist, so pretending otherwise here would teach the
22
- // wrong lesson; `pnpm test` (renderWithGrist, headless) is the loop
23
- // worth trusting locally. See AGENTS.md.
24
- // - any built/deployed URL (root, /latest/, /dev/, /v<version>/, all
25
- // carrying the same build per scripts/deploy.mjs) -> ReleaseInfo: which
26
- // build this is, chips to jump to any other version/channel, and a
27
- // copy-this-URL helper for pasting into Grist's custom widget field.
28
- const isEmbedded = window.self !== window.top
29
- const { channel } = parseShowcasePath(window.location.pathname)
30
-
31
- createRoot(document.getElementById("root")!).render(
32
- <StrictMode>
33
- <ThemeProvider>
34
- {isEmbedded ? (
35
- <GristWidgetProvider options={GRIST_OPTIONS}>
36
- <GristBoundary
37
- gate={GRIST_OPTIONS.columns?.length ? "canRender" : "ready"}
38
- >
39
- <div className="min-h-full w-full bg-background text-foreground">
40
- <GristSdkAlerts>
41
- <App />
42
- </GristSdkAlerts>
43
- </div>
44
- </GristBoundary>
45
- </GristWidgetProvider>
46
- ) : import.meta.env.DEV ? (
47
- <div className="min-h-full w-full bg-background text-foreground">
48
- <LocalDevNotice />
49
- </div>
50
- ) : (
51
- <div className="min-h-full w-full bg-background text-foreground">
52
- <ReleaseInfo channel={channel} />
53
- </div>
54
- )}
55
- </ThemeProvider>
56
- </StrictMode>
57
- )
6
+ // Inside Grist: the widget, behind the SDK's connection screens and alerts,
7
+ // in Grist's light/dark theme. Opened anywhere else (`pnpm dev`, a deployed
8
+ // URL): a page saying where to see it, never a fake preview, since an
9
+ // emulator can't stand in for real Grist (see AGENTS.md). `pnpm test` is the
10
+ // local loop to trust.
11
+ mountGristWidget({ App, options: GRIST_OPTIONS, dev: import.meta.env.DEV })
@@ -98,7 +98,7 @@ export function createComposer({ templateDir, examplesDir, uiDir, manifestPath }
98
98
  // only remove files this function itself may have added, so a stale
99
99
  // sibling from a *different* previous example doesn't linger.
100
100
  for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
101
- if (entry.name === "App.tsx" || entry.name === "App.test.tsx") continue
101
+ if (entry.name === "App.tsx" || entry.name === "App.test.tsx" || entry.name === "App.smoke.test.tsx") continue
102
102
  if (["components", "lib", "assets", "index.css", "vite-env.d.ts", "main.tsx"].includes(entry.name)) {
103
103
  continue
104
104
  }
@@ -175,11 +175,14 @@ export function createComposer({ templateDir, examplesDir, uiDir, manifestPath }
175
175
  * `workspace:^` (meaningless outside the pnpm workspace) — a `file:`
176
176
  * reference to either `packages/core` (workspace source, already built), a
177
177
  * packed tarball, or (for the CLI's `create`) a real published semver range.
178
+ * `cliDependency` does the same for the template's `create-grist-widget`
179
+ * devDependency (its deploy bin); without one, the devDependency is dropped,
180
+ * since a composed example is only typechecked, built and tested here.
178
181
  *
179
182
  * For composing *many* examples against one shared `pnpm install`, use
180
183
  * {@link overlayExampleSrc} directly instead (see its own doc comment).
181
184
  */
182
- function composeExample({ exampleId, destDir, sdkDependency }) {
185
+ function composeExample({ exampleId, destDir, sdkDependency, cliDependency }) {
183
186
  rmSync(destDir, { recursive: true, force: true })
184
187
  cpSync(templateDir, destDir, {
185
188
  recursive: true,
@@ -192,6 +195,8 @@ export function createComposer({ templateDir, examplesDir, uiDir, manifestPath }
192
195
  const pkg = JSON.parse(readFileSync(pkgPath, "utf8"))
193
196
  pkg.name = exampleId
194
197
  pkg.dependencies["grist-widget-sdk"] = sdkDependency
198
+ if (cliDependency) pkg.devDependencies["create-grist-widget"] = cliDependency
199
+ else delete pkg.devDependencies["create-grist-widget"]
195
200
  Object.assign(pkg.dependencies, EXTRA_DEPENDENCIES[exampleId] ?? {})
196
201
  writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`)
197
202
 
@@ -1,80 +0,0 @@
1
- import { useMemo, type ReactNode } from "react"
2
- import { AlertTriangle, Link2 } from "lucide-react"
3
-
4
- import {
5
- useGristSdkAlertDescriptors,
6
- useGristOptional,
7
- type GetGristSdkAlertDescriptorsOptions,
8
- type GristSdkAlertSeverity,
9
- type UseGristResult,
10
- } from "grist-widget-sdk"
11
-
12
- import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
13
- import { cn } from "@/lib/utils"
14
-
15
- export type GristSdkAlertsProps = {
16
- /** Defaults to the nearest `GristWidgetProvider`. */
17
- widget?: UseGristResult
18
- children: ReactNode
19
- className?: string
20
- }
21
-
22
- const GRIST_ALERT_OPTIONS: GetGristSdkAlertDescriptorsOptions = {
23
- sectionNotLinkedHint:
24
- "In Grist, select this widget section on the page, then use Link to connect a table, card, or chart view as the selector.",
25
- }
26
-
27
- function alertSeverityClass(severity: GristSdkAlertSeverity): string | undefined {
28
- switch (severity) {
29
- case "warning":
30
- return "border-amber-500/40 bg-amber-500/10 text-amber-950 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100"
31
- case "info":
32
- return "border-muted-foreground/30 bg-muted/50"
33
- default:
34
- return undefined
35
- }
36
- }
37
-
38
- /**
39
- * Maps {@link getGristSdkAlertDescriptors} from `grist-widget-sdk` to
40
- * shadcn {@link Alert} (styling follows this app’s `components.json`).
41
- */
42
- export function GristSdkAlerts({ widget, children, className }: GristSdkAlertsProps) {
43
- const fromContext = useGristOptional()
44
- const w = widget ?? fromContext
45
-
46
- if (!w) {
47
- throw new Error(
48
- "GristSdkAlerts requires a `widget` prop or <GristWidgetProvider>"
49
- )
50
- }
51
-
52
- const alertOptions = useMemo(() => GRIST_ALERT_OPTIONS, [])
53
- const alerts = useGristSdkAlertDescriptors(w, alertOptions)
54
-
55
- if (alerts.length === 0) {
56
- return <>{children}</>
57
- }
58
-
59
- return (
60
- <div className={cn("flex flex-col gap-3", className)}>
61
- {alerts.map((a) => (
62
- <Alert
63
- key={a.id}
64
- role={a.ariaRole}
65
- variant={a.severity === "error" ? "destructive" : "default"}
66
- className={alertSeverityClass(a.severity)}
67
- >
68
- {a.kind === "section-not-linked" || a.kind === "source-not-wired" ? (
69
- <Link2 className="size-4" aria-hidden />
70
- ) : (
71
- <AlertTriangle className="size-4" aria-hidden />
72
- )}
73
- <AlertTitle>{a.title}</AlertTitle>
74
- <AlertDescription>{a.message}</AlertDescription>
75
- </Alert>
76
- ))}
77
- {children}
78
- </div>
79
- )
80
- }
@@ -1,55 +0,0 @@
1
- import {
2
- Card,
3
- CardDescription,
4
- CardHeader,
5
- CardTitle,
6
- } from "@/components/ui/card"
7
-
8
- // Shown at the root of a local `pnpm dev` server, never inside Grist.
9
- // Deliberately a bare shell, not a seeded live preview: an emulator can't
10
- // guarantee parity with real Grist, so the loop worth trusting here is
11
- // `pnpm test` (renderWithGrist, headless) plus the real `dev`-branch loop
12
- // this points at below -- see AGENTS.md's note on why this isn't a fake
13
- // preview instead.
14
- export function LocalDevNotice() {
15
- return (
16
- <div className="mx-auto flex min-h-full max-w-md flex-col justify-center px-6 py-16">
17
- <p className="mb-2 text-center text-sm font-medium text-muted-foreground">
18
- grist-widget-template · local dev
19
- </p>
20
- <h1 className="text-center font-heading text-2xl font-semibold tracking-tight">
21
- Not embedded in Grist
22
- </h1>
23
- <p className="mx-auto mt-3 mb-8 max-w-sm text-center text-sm text-muted-foreground">
24
- This is a local dev server — there's no Grist here to render against.
25
- </p>
26
-
27
- <Card className="mb-4">
28
- <CardHeader>
29
- <CardTitle>Verify against real Grist</CardTitle>
30
- <CardDescription>
31
- Commit and push to <code className="text-foreground">dev</code>,
32
- then open the printed{" "}
33
- <code className="text-foreground">/dev/</code> URL in a real
34
- Grist document — every later push self-reloads inside it within
35
- seconds. See the README's Deployment section for the full loop.
36
- </CardDescription>
37
- </CardHeader>
38
- </Card>
39
-
40
- <Card>
41
- <CardHeader>
42
- <CardTitle>Not building this widget?</CardTitle>
43
- <CardDescription>
44
- <code className="text-foreground">npx grist-widget list</code> to
45
- see other starting points,{" "}
46
- <code className="text-foreground">
47
- npx grist-widget use &lt;example&gt;
48
- </code>{" "}
49
- to swap this one out.
50
- </CardDescription>
51
- </CardHeader>
52
- </Card>
53
- </div>
54
- )
55
- }
@@ -1,111 +0,0 @@
1
- import { useState } from "react"
2
-
3
- import { Button } from "@/components/ui/button"
4
- import {
5
- Card,
6
- CardContent,
7
- CardDescription,
8
- CardHeader,
9
- CardTitle,
10
- } from "@/components/ui/card"
11
- import { Input } from "@/components/ui/input"
12
- import { channelLabel, type Channel } from "@/lib/showcase-routing"
13
- import { devUrl, formatDate, useVersions, versionUrl } from "@/lib/showcase-versions"
14
- import { cn } from "@/lib/utils"
15
-
16
- // Shown on every non-embedded, built/deployed URL of this widget -- root,
17
- // /latest/, /dev/, /v<version>/, all carrying the same build per
18
- // scripts/deploy.mjs's plan() -- when opened outside Grist. Local `pnpm dev`
19
- // renders `LocalDevNotice` instead (see src/main.tsx): this component's
20
- // "paste this URL into Grist" instruction only makes sense for a real,
21
- // publicly reachable URL, never a bare localhost one.
22
- function VersionChips({ current }: { current: Channel }) {
23
- const { versions } = useVersions()
24
-
25
- function chipClass(active: boolean) {
26
- return cn(
27
- "rounded-full border px-3 py-1 text-xs font-medium transition-colors",
28
- active
29
- ? "border-primary bg-primary text-primary-foreground"
30
- : "border-border text-muted-foreground hover:text-foreground",
31
- )
32
- }
33
-
34
- return (
35
- <div className="mb-8 flex flex-wrap justify-center gap-2">
36
- {versions !== null && versions.length > 0 && (
37
- <a
38
- href={versionUrl(versions[0].version)}
39
- title={formatDate(versions[0].publishedAt)}
40
- className={chipClass(
41
- current.kind === "version" && current.version === versions[0].version,
42
- )}
43
- >
44
- latest
45
- </a>
46
- )}
47
- <a href={devUrl()} className={chipClass(current.kind === "dev")}>
48
- dev
49
- </a>
50
- {versions?.map((v) => (
51
- <a
52
- key={v.version}
53
- href={versionUrl(v.version)}
54
- title={formatDate(v.publishedAt)}
55
- className={chipClass(
56
- current.kind === "version" && current.version === v.version,
57
- )}
58
- >
59
- v{v.version}
60
- </a>
61
- ))}
62
- </div>
63
- )
64
- }
65
-
66
- export function ReleaseInfo({ channel }: { channel: Channel }) {
67
- const [copied, setCopied] = useState(false)
68
- const url = window.location.href
69
-
70
- function copyUrl() {
71
- navigator.clipboard.writeText(url).then(() => {
72
- setCopied(true)
73
- setTimeout(() => setCopied(false), 1500)
74
- })
75
- }
76
-
77
- return (
78
- <div className="mx-auto flex min-h-full max-w-md flex-col justify-center px-6 py-16">
79
- <p className="mb-2 text-center text-sm font-medium text-muted-foreground">
80
- grist-widget-template · {channelLabel(channel)}
81
- </p>
82
- <h1 className="text-center font-heading text-2xl font-semibold tracking-tight">
83
- Grist isn't loaded here
84
- </h1>
85
- <p className="mx-auto mt-3 mb-8 max-w-sm text-center text-sm text-muted-foreground">
86
- You're viewing the <span className="font-mono">{channelLabel(channel)}</span>{" "}
87
- build directly, outside of Grist. Hover a chip below to see when it
88
- shipped.
89
- </p>
90
-
91
- <VersionChips current={channel} />
92
-
93
- <Card>
94
- <CardHeader>
95
- <CardTitle>Use this as a Grist custom widget</CardTitle>
96
- <CardDescription>
97
- Paste this URL into Grist's custom widget URL field.
98
- </CardDescription>
99
- </CardHeader>
100
- <CardContent>
101
- <div className="flex gap-2">
102
- <Input readOnly value={url} onFocus={(e) => e.target.select()} />
103
- <Button type="button" onClick={copyUrl}>
104
- {copied ? "Copied" : "Copy"}
105
- </Button>
106
- </div>
107
- </CardContent>
108
- </Card>
109
- </div>
110
- )
111
- }
@@ -1,59 +0,0 @@
1
- // Parses the deployed URL shape every channel of this template (or a real
2
- // scaffolded widget) uses -- /latest/, /dev/, /v<version>/ -- so main.tsx and
3
- // ReleaseInfo can label the current build using nothing but
4
- // window.location.pathname. No router needed.
5
- export type Channel =
6
- | { kind: "latest" }
7
- | { kind: "dev" }
8
- | { kind: "version"; version: string }
9
-
10
- export function channelLabel(channel: Channel): string {
11
- switch (channel.kind) {
12
- case "latest":
13
- return "latest"
14
- case "dev":
15
- return "dev"
16
- case "version":
17
- return `v${channel.version}`
18
- }
19
- }
20
-
21
- /**
22
- * `hubPath` is the current path with a recognized trailing channel segment
23
- * stripped, e.g. "/grist-widget-sdk/template/latest/" ->
24
- * "/grist-widget-sdk/template/" -- used to build root-relative URLs
25
- * (versions.json, /dev/, /v<version>/) regardless of how deep this deploy's
26
- * own base path is (see showcase-versions.ts).
27
- *
28
- * `channel` always resolves to something: `scripts/deploy.mjs` places the
29
- * same build at the bare deployed root as at `/latest/`, so a path with no
30
- * recognized suffix (the root itself, or a local `pnpm dev` server) defaults
31
- * to `{ kind: "latest" }` -- that's genuinely what's there.
32
- */
33
- export function parseShowcasePath(pathname: string): {
34
- channel: Channel
35
- hubPath: string
36
- } {
37
- const trimmed = pathname.replace(/\/+$/, "")
38
- const segments = trimmed.split("/")
39
- const last = segments[segments.length - 1] ?? ""
40
-
41
- let channel: Channel = { kind: "latest" }
42
- let matchedSuffix = false
43
- if (last === "latest") {
44
- channel = { kind: "latest" }
45
- matchedSuffix = true
46
- } else if (last === "dev") {
47
- channel = { kind: "dev" }
48
- matchedSuffix = true
49
- } else {
50
- const match = /^v(\d+\.\d+\.\d+(?:-[\w.]+)?)$/.exec(last)
51
- if (match) {
52
- channel = { kind: "version", version: match[1] }
53
- matchedSuffix = true
54
- }
55
- }
56
-
57
- const hubPath = matchedSuffix ? segments.slice(0, -1).join("/") + "/" : pathname
58
- return { channel, hubPath }
59
- }