handzon-core 0.12.2 → 0.13.2
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/package.json +2 -1
- package/src/collections.ts +298 -61
- package/src/components/Sidebar.astro +26 -0
- package/src/components/StepHeroMedia.astro +77 -0
- package/src/components/TrackSelector.tsx +130 -0
- package/src/components/ai/ChatButton.tsx +28 -4
- package/src/components/ai/ChatPanel.tsx +10 -0
- package/src/components/home/TutorialCard.astro +77 -3
- package/src/components/mdx/Track.astro +11 -0
- package/src/layouts/BaseLayout.astro +7 -0
- package/src/layouts/TutorialLayout.astro +34 -0
- package/src/lib/ai/context.ts +12 -2
- package/src/lib/ai/prompts.ts +8 -1
- package/src/lib/heroMedia.ts +37 -0
- package/src/lib/mdx-components.ts +2 -0
- package/src/lib/progress/types.ts +1 -0
- package/src/lib/track-scoped.ts +24 -0
- package/src/lib/track-source.ts +10 -0
- package/src/lib/tracks.ts +32 -0
- package/src/pages/TutorialLanding.astro +132 -27
- package/src/server/mcp/startTutorial.ts +70 -12
- package/src/server/mcp/tools.ts +36 -3
- package/src/server/mcp/tracks.ts +43 -0
- package/src/server/mcp/writeTools.ts +34 -4
- package/styles/components/track-selector.css +105 -0
- package/styles/components.css +1 -0
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { Sparkles } from "lucide-react";
|
|
2
|
-
import { useEffect, useState } from "react";
|
|
2
|
+
import { useEffect, useMemo, useState } from "react";
|
|
3
3
|
import { ASSIST_EVENT, ASSIST_READY_EVENT, type AssistEventDetail } from "../../lib/ai/assist";
|
|
4
4
|
import type { ChatMessage } from "../../lib/ai/client";
|
|
5
5
|
import type { AssistantContext } from "../../lib/ai/context";
|
|
6
6
|
import { buildAssistantPrompt } from "../../lib/ai/prompts";
|
|
7
|
+
import { useProgress } from "../../lib/progress/useProgress";
|
|
8
|
+
import { stripInactiveTrackBlocks } from "../../lib/track-source";
|
|
9
|
+
import { resolveActiveTrack } from "../../lib/tracks";
|
|
7
10
|
import type { AiConfig } from "../../types/ai";
|
|
8
11
|
import ChatPanel from "./ChatPanel";
|
|
9
12
|
|
|
@@ -13,12 +16,33 @@ interface Props {
|
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
export default function ChatButton({ config, context }: Props) {
|
|
19
|
+
const { state } = useProgress();
|
|
16
20
|
const [open, setOpen] = useState(false);
|
|
17
21
|
const [seed, setSeed] = useState<ChatMessage[] | undefined>(undefined);
|
|
18
22
|
// Bumped on each new assist seed so ChatPanel remounts with the
|
|
19
23
|
// new initialMessages even if the panel is already open from a
|
|
20
24
|
// previous intent or FAB click.
|
|
21
25
|
const [seedToken, setSeedToken] = useState(0);
|
|
26
|
+
const trackContext = useMemo<AssistantContext>(() => {
|
|
27
|
+
const activeTrack = resolveActiveTrack({
|
|
28
|
+
tracks: context.tutorial.tracks,
|
|
29
|
+
preferredTrack: state.prefs.track,
|
|
30
|
+
defaultTrack: context.tutorial.defaultTrack,
|
|
31
|
+
});
|
|
32
|
+
if (!activeTrack) return context;
|
|
33
|
+
return {
|
|
34
|
+
...context,
|
|
35
|
+
tutorial: { ...context.tutorial, track: activeTrack },
|
|
36
|
+
currentStep: {
|
|
37
|
+
...context.currentStep,
|
|
38
|
+
source: stripInactiveTrackBlocks(context.currentStep.source, activeTrack),
|
|
39
|
+
},
|
|
40
|
+
priorSteps: context.priorSteps.map((step) => ({
|
|
41
|
+
...step,
|
|
42
|
+
source: stripInactiveTrackBlocks(step.source, activeTrack),
|
|
43
|
+
})),
|
|
44
|
+
};
|
|
45
|
+
}, [context, state.prefs.track]);
|
|
22
46
|
|
|
23
47
|
// Tell Family A islands (HelpMe, Checkpoint nudge, …) that the tutor
|
|
24
48
|
// is mounted on this page. Set the dataset flag for islands that
|
|
@@ -40,14 +64,14 @@ export default function ChatButton({ config, context }: Props) {
|
|
|
40
64
|
function onAssist(e: Event) {
|
|
41
65
|
const detail = (e as CustomEvent<AssistEventDetail>).detail;
|
|
42
66
|
if (!detail?.intent) return;
|
|
43
|
-
const { seedMessages } = buildAssistantPrompt(
|
|
67
|
+
const { seedMessages } = buildAssistantPrompt(trackContext, detail.intent);
|
|
44
68
|
setSeed(seedMessages);
|
|
45
69
|
setSeedToken((t) => t + 1);
|
|
46
70
|
setOpen(true);
|
|
47
71
|
}
|
|
48
72
|
document.addEventListener(ASSIST_EVENT, onAssist);
|
|
49
73
|
return () => document.removeEventListener(ASSIST_EVENT, onAssist);
|
|
50
|
-
}, [
|
|
74
|
+
}, [trackContext]);
|
|
51
75
|
|
|
52
76
|
if (!config.enabled) return null;
|
|
53
77
|
|
|
@@ -70,7 +94,7 @@ export default function ChatButton({ config, context }: Props) {
|
|
|
70
94
|
open={open}
|
|
71
95
|
onOpenChange={setOpen}
|
|
72
96
|
config={config}
|
|
73
|
-
context={
|
|
97
|
+
context={trackContext}
|
|
74
98
|
initialMessages={seed}
|
|
75
99
|
/>
|
|
76
100
|
</>
|
|
@@ -224,6 +224,16 @@ export default function ChatPanel({ open, onOpenChange, config, context, initial
|
|
|
224
224
|
|
|
225
225
|
<div className="chat-meta">
|
|
226
226
|
On: <strong>{context.currentStep.title}</strong>
|
|
227
|
+
{context.tutorial.track && (
|
|
228
|
+
<>
|
|
229
|
+
{" "}
|
|
230
|
+
· Track:{" "}
|
|
231
|
+
<strong>
|
|
232
|
+
{context.tutorial.tracks.find((track) => track.id === context.tutorial.track)
|
|
233
|
+
?.label ?? context.tutorial.track}
|
|
234
|
+
</strong>
|
|
235
|
+
</>
|
|
236
|
+
)}
|
|
227
237
|
</div>
|
|
228
238
|
|
|
229
239
|
{needsKey ? (
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { Image } from "astro:assets";
|
|
2
3
|
import { withBase } from "../../lib/base";
|
|
3
4
|
import type { TutorialEntry } from "../../lib/content";
|
|
4
5
|
|
|
@@ -15,6 +16,8 @@ interface Props {
|
|
|
15
16
|
const { tutorial, duration, stepCount } = Astro.props;
|
|
16
17
|
const data = tutorial.data;
|
|
17
18
|
const slug = tutorial.id;
|
|
19
|
+
const iconText = typeof data.icon === "string" ? data.icon : undefined;
|
|
20
|
+
const iconImage = data.icon && typeof data.icon !== "string" ? data.icon : undefined;
|
|
18
21
|
---
|
|
19
22
|
<article
|
|
20
23
|
class="card"
|
|
@@ -25,8 +28,33 @@ const slug = tutorial.id;
|
|
|
25
28
|
data-popularity="0"
|
|
26
29
|
>
|
|
27
30
|
<a href={withBase(`/${slug}`)} class="card-link">
|
|
31
|
+
{data.cover && (
|
|
32
|
+
<div class="card-media">
|
|
33
|
+
<Image
|
|
34
|
+
src={data.cover}
|
|
35
|
+
alt=""
|
|
36
|
+
class="card-cover"
|
|
37
|
+
loading="lazy"
|
|
38
|
+
widths={[360, 540, 720]}
|
|
39
|
+
sizes="(min-width: 960px) 33vw, (min-width: 640px) 50vw, 100vw"
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
42
|
+
)}
|
|
28
43
|
<div class="card-body">
|
|
29
|
-
<
|
|
44
|
+
<div class="card-title-row">
|
|
45
|
+
{iconImage && (
|
|
46
|
+
<Image
|
|
47
|
+
src={iconImage}
|
|
48
|
+
alt=""
|
|
49
|
+
class="card-icon card-icon-image"
|
|
50
|
+
loading="lazy"
|
|
51
|
+
width={32}
|
|
52
|
+
height={32}
|
|
53
|
+
/>
|
|
54
|
+
)}
|
|
55
|
+
{iconText && <span class="card-icon card-icon-text" aria-hidden="true">{iconText}</span>}
|
|
56
|
+
<h3>{data.title}</h3>
|
|
57
|
+
</div>
|
|
30
58
|
<p>{data.description}</p>
|
|
31
59
|
<div class="badges">
|
|
32
60
|
<span class={`badge badge-${data.difficulty}`}>{data.difficulty}</span>
|
|
@@ -57,7 +85,7 @@ const slug = tutorial.id;
|
|
|
57
85
|
</div>
|
|
58
86
|
{data.tags.length > 0 && (
|
|
59
87
|
<div class="tags">
|
|
60
|
-
{data.tags.map((tag) => <span class="tag">#{tag}</span>)}
|
|
88
|
+
{data.tags.map((tag: string) => <span class="tag">#{tag}</span>)}
|
|
61
89
|
</div>
|
|
62
90
|
)}
|
|
63
91
|
<div
|
|
@@ -87,12 +115,58 @@ const slug = tutorial.id;
|
|
|
87
115
|
flex-direction: column;
|
|
88
116
|
width: 100%;
|
|
89
117
|
}
|
|
118
|
+
.card-media {
|
|
119
|
+
aspect-ratio: 16 / 9;
|
|
120
|
+
border-bottom: 1px solid var(--color-border);
|
|
121
|
+
background:
|
|
122
|
+
linear-gradient(
|
|
123
|
+
135deg,
|
|
124
|
+
color-mix(in oklab, var(--color-accent) 10%, transparent),
|
|
125
|
+
var(--color-surface)
|
|
126
|
+
);
|
|
127
|
+
overflow: hidden;
|
|
128
|
+
}
|
|
129
|
+
.card-cover {
|
|
130
|
+
width: 100%;
|
|
131
|
+
height: 100%;
|
|
132
|
+
object-fit: cover;
|
|
133
|
+
display: block;
|
|
134
|
+
filter: saturate(0.92) contrast(1.05);
|
|
135
|
+
transition: transform 0.18s ease, filter 0.18s ease;
|
|
136
|
+
}
|
|
137
|
+
.card:hover .card-cover {
|
|
138
|
+
transform: scale(1.025);
|
|
139
|
+
filter: saturate(1) contrast(1.08);
|
|
140
|
+
}
|
|
90
141
|
.card-body {
|
|
91
142
|
padding: 1.1rem 1.2rem 1.2rem;
|
|
92
143
|
display: flex;
|
|
93
144
|
flex-direction: column;
|
|
94
145
|
flex: 1;
|
|
95
146
|
}
|
|
147
|
+
.card-title-row {
|
|
148
|
+
display: flex;
|
|
149
|
+
align-items: flex-start;
|
|
150
|
+
gap: 0.65rem;
|
|
151
|
+
margin-bottom: 0.5rem;
|
|
152
|
+
}
|
|
153
|
+
.card-icon {
|
|
154
|
+
flex-shrink: 0;
|
|
155
|
+
width: 2rem;
|
|
156
|
+
height: 2rem;
|
|
157
|
+
border: 1px solid var(--color-border);
|
|
158
|
+
background: var(--color-surface);
|
|
159
|
+
}
|
|
160
|
+
.card-icon-image {
|
|
161
|
+
object-fit: cover;
|
|
162
|
+
display: block;
|
|
163
|
+
}
|
|
164
|
+
.card-icon-text {
|
|
165
|
+
display: inline-grid;
|
|
166
|
+
place-items: center;
|
|
167
|
+
font-size: 1rem;
|
|
168
|
+
line-height: 1;
|
|
169
|
+
}
|
|
96
170
|
|
|
97
171
|
/* Metadata row: difficulty + duration with a divider beneath it.
|
|
98
172
|
* `margin-top: auto` pins this row (and everything below it — tags,
|
|
@@ -140,7 +214,7 @@ const slug = tutorial.id;
|
|
|
140
214
|
h3 {
|
|
141
215
|
font-size: 1.05rem;
|
|
142
216
|
font-weight: 700;
|
|
143
|
-
margin: 0
|
|
217
|
+
margin: 0;
|
|
144
218
|
letter-spacing: -0.015em;
|
|
145
219
|
line-height: 1.3;
|
|
146
220
|
}
|
|
@@ -30,6 +30,8 @@ interface Props {
|
|
|
30
30
|
logoHeight?: number;
|
|
31
31
|
/** Favicon URL injected into <head>. */
|
|
32
32
|
faviconUrl?: string;
|
|
33
|
+
/** Optional social preview image for Open Graph and Twitter cards. */
|
|
34
|
+
ogImageUrl?: string;
|
|
33
35
|
/** Show the site footer ("Built with Handzon" + repo link). */
|
|
34
36
|
showFooter?: boolean;
|
|
35
37
|
/** Footer link URL; defaults to the Handzon repo. */
|
|
@@ -67,6 +69,7 @@ const {
|
|
|
67
69
|
logoWidth = 76,
|
|
68
70
|
logoHeight = 58,
|
|
69
71
|
faviconUrl = "/favicon.svg",
|
|
72
|
+
ogImageUrl,
|
|
70
73
|
showFooter = true,
|
|
71
74
|
repoUrl,
|
|
72
75
|
siteUrl,
|
|
@@ -83,6 +86,7 @@ const {
|
|
|
83
86
|
const resolvedMaxWidth = pageMaxWidth === "none" ? "none" : pageMaxWidth;
|
|
84
87
|
const pageTitle = title ? `${title} — ${siteName}` : siteName;
|
|
85
88
|
const desc = description ?? tagline;
|
|
89
|
+
const socialImageUrl = ogImageUrl ? withBase(ogImageUrl) : undefined;
|
|
86
90
|
---
|
|
87
91
|
<!doctype html>
|
|
88
92
|
<html lang="en">
|
|
@@ -95,6 +99,9 @@ const desc = description ?? tagline;
|
|
|
95
99
|
<meta property="og:title" content={pageTitle} />
|
|
96
100
|
<meta property="og:description" content={desc} />
|
|
97
101
|
<meta property="og:type" content="website" />
|
|
102
|
+
{socialImageUrl && <meta property="og:image" content={socialImageUrl} />}
|
|
103
|
+
{socialImageUrl && <meta name="twitter:card" content="summary_large_image" />}
|
|
104
|
+
{socialImageUrl && <meta name="twitter:image" content={socialImageUrl} />}
|
|
98
105
|
{/* Optional consumer-side <head> injections. Forwarded by every
|
|
99
106
|
* page wrapper (Home, TutorialLanding, TutorialStep / TutorialLayout)
|
|
100
107
|
* so scaffolds can preload fonts, attach per-page OG images, add
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import BaseLayout from "./BaseLayout.astro";
|
|
3
3
|
import Sidebar from "../components/Sidebar.astro";
|
|
4
|
+
import StepHeroMedia from "../components/StepHeroMedia.astro";
|
|
4
5
|
import StepNav from "../components/StepNav.astro";
|
|
5
6
|
import { parseStepId } from "../lib/content.ts";
|
|
6
7
|
import type { TutorialEntry, StepEntry } from "../lib/content";
|
|
@@ -43,6 +44,34 @@ const {
|
|
|
43
44
|
} = Astro.props;
|
|
44
45
|
|
|
45
46
|
const stepSlugs = steps.map((s) => parseStepId(s.id).stepSlug);
|
|
47
|
+
const trackBootstrap =
|
|
48
|
+
tutorial.data.tracks.length > 0
|
|
49
|
+
? `(() => {
|
|
50
|
+
try {
|
|
51
|
+
const tracks = ${JSON.stringify(tutorial.data.tracks).replace(/</g, "\\u003c")};
|
|
52
|
+
const defaultTrack = ${JSON.stringify(tutorial.data.defaultTrack ?? null)};
|
|
53
|
+
const ids = tracks.map((track) => track.id);
|
|
54
|
+
const raw = window.localStorage.getItem("handzon:v1");
|
|
55
|
+
const preferred = raw ? JSON.parse(raw)?.prefs?.track : null;
|
|
56
|
+
const active = ids.includes(preferred)
|
|
57
|
+
? preferred
|
|
58
|
+
: defaultTrack && ids.includes(defaultTrack)
|
|
59
|
+
? defaultTrack
|
|
60
|
+
: ids[0];
|
|
61
|
+
if (!active) return;
|
|
62
|
+
document.documentElement.dataset.track = active;
|
|
63
|
+
let style = document.getElementById("handzon-track-style");
|
|
64
|
+
if (!style) {
|
|
65
|
+
style = document.createElement("style");
|
|
66
|
+
style.id = "handzon-track-style";
|
|
67
|
+
document.head.appendChild(style);
|
|
68
|
+
}
|
|
69
|
+
style.textContent = '[data-track-panel]:not([data-track-panel="' + active + '"]) { display: none !important; }';
|
|
70
|
+
} catch {
|
|
71
|
+
// Track selection is progressive enhancement; leave all content visible on failure.
|
|
72
|
+
}
|
|
73
|
+
})();`
|
|
74
|
+
: null;
|
|
46
75
|
---
|
|
47
76
|
<BaseLayout
|
|
48
77
|
title={`${currentStep.data.title} — ${tutorial.data.title}`}
|
|
@@ -59,6 +88,7 @@ const stepSlugs = steps.map((s) => parseStepId(s.id).stepSlug);
|
|
|
59
88
|
showFooter={showFooter}
|
|
60
89
|
>
|
|
61
90
|
<slot name="head" slot="head" />
|
|
91
|
+
{trackBootstrap && <script is:inline set:html={trackBootstrap} />}
|
|
62
92
|
<div class="layout">
|
|
63
93
|
<div class="sidebar-wrap">
|
|
64
94
|
<Sidebar tutorial={tutorial} steps={steps} currentStepSlug={currentStepSlug} />
|
|
@@ -73,6 +103,10 @@ const stepSlugs = steps.map((s) => parseStepId(s.id).stepSlug);
|
|
|
73
103
|
)}
|
|
74
104
|
</header>
|
|
75
105
|
|
|
106
|
+
{currentStep.data.heroMedia && (
|
|
107
|
+
<StepHeroMedia media={currentStep.data.heroMedia} />
|
|
108
|
+
)}
|
|
109
|
+
|
|
76
110
|
<article class="prose">
|
|
77
111
|
<slot />
|
|
78
112
|
</article>
|
package/src/lib/ai/context.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { StepEntry, TutorialEntry } from "../content";
|
|
2
2
|
import { parseStepId } from "../content";
|
|
3
3
|
import type { ProgressState } from "../progress/types";
|
|
4
|
+
import { stripInactiveTrackBlocks } from "../track-source";
|
|
5
|
+
import type { TrackOption } from "../tracks";
|
|
4
6
|
|
|
5
7
|
export interface AssistantContext {
|
|
6
8
|
tutorial: {
|
|
@@ -9,6 +11,9 @@ export interface AssistantContext {
|
|
|
9
11
|
description: string;
|
|
10
12
|
difficulty: string;
|
|
11
13
|
tags: string[];
|
|
14
|
+
tracks: TrackOption[];
|
|
15
|
+
defaultTrack?: string;
|
|
16
|
+
track?: string | null;
|
|
12
17
|
};
|
|
13
18
|
outline: Array<{ slug: string; title: string; completed: boolean; current: boolean }>;
|
|
14
19
|
currentStep: {
|
|
@@ -32,6 +37,7 @@ interface BuildOptions {
|
|
|
32
37
|
progress: ProgressState;
|
|
33
38
|
references?: Array<{ source: string; content: string }>;
|
|
34
39
|
includeFutureSteps?: boolean;
|
|
40
|
+
activeTrack?: string;
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
/**
|
|
@@ -46,6 +52,7 @@ export function buildContext({
|
|
|
46
52
|
progress,
|
|
47
53
|
references = [],
|
|
48
54
|
includeFutureSteps = false,
|
|
55
|
+
activeTrack,
|
|
49
56
|
}: BuildOptions): AssistantContext {
|
|
50
57
|
const slug = tutorial.id;
|
|
51
58
|
const currentIdx = steps.findIndex((s) => s.id === currentStep.id);
|
|
@@ -67,7 +74,7 @@ export function buildContext({
|
|
|
67
74
|
.map((s) => ({
|
|
68
75
|
slug: parseStepId(s.id).stepSlug,
|
|
69
76
|
title: s.data.title,
|
|
70
|
-
source: s.body ?? "",
|
|
77
|
+
source: stripInactiveTrackBlocks(s.body ?? "", activeTrack),
|
|
71
78
|
}));
|
|
72
79
|
|
|
73
80
|
return {
|
|
@@ -77,12 +84,15 @@ export function buildContext({
|
|
|
77
84
|
description: tutorial.data.description,
|
|
78
85
|
difficulty: tutorial.data.difficulty,
|
|
79
86
|
tags: tutorial.data.tags,
|
|
87
|
+
tracks: tutorial.data.tracks,
|
|
88
|
+
defaultTrack: tutorial.data.defaultTrack,
|
|
89
|
+
track: activeTrack ?? null,
|
|
80
90
|
},
|
|
81
91
|
outline,
|
|
82
92
|
currentStep: {
|
|
83
93
|
slug: currentSlug,
|
|
84
94
|
title: currentStep.data.title,
|
|
85
|
-
source: currentStep.body ?? "",
|
|
95
|
+
source: stripInactiveTrackBlocks(currentStep.body ?? "", activeTrack),
|
|
86
96
|
},
|
|
87
97
|
priorSteps,
|
|
88
98
|
progress: {
|
package/src/lib/ai/prompts.ts
CHANGED
|
@@ -30,7 +30,14 @@ export interface AssistantPrompt {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function header(ctx: AssistantContext): string {
|
|
33
|
-
|
|
33
|
+
const lines = [`Tutorial: ${ctx.tutorial.title}`, `Step: ${ctx.currentStep.title}`];
|
|
34
|
+
if (ctx.tutorial.track) {
|
|
35
|
+
const label =
|
|
36
|
+
ctx.tutorial.tracks.find((track) => track.id === ctx.tutorial.track)?.label ??
|
|
37
|
+
ctx.tutorial.track;
|
|
38
|
+
lines.push(`Track: ${label}`);
|
|
39
|
+
}
|
|
40
|
+
return lines.join("\n");
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
function renderIntent(ctx: AssistantContext, intent: AssistantIntent): string {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z as defaultZ } from "zod";
|
|
2
|
+
|
|
3
|
+
type SchemaBuilder = typeof defaultZ;
|
|
4
|
+
type ImageSchemaFactory = () => unknown;
|
|
5
|
+
|
|
6
|
+
export function createHeroMediaSchema(
|
|
7
|
+
schema: SchemaBuilder = defaultZ,
|
|
8
|
+
image?: ImageSchemaFactory,
|
|
9
|
+
) {
|
|
10
|
+
const imageSrc = image
|
|
11
|
+
? schema.union([image() as never, schema.string().min(1)])
|
|
12
|
+
: schema.string().min(1);
|
|
13
|
+
|
|
14
|
+
return schema.discriminatedUnion("kind", [
|
|
15
|
+
schema
|
|
16
|
+
.object({
|
|
17
|
+
kind: schema.literal("image"),
|
|
18
|
+
src: imageSrc,
|
|
19
|
+
alt: schema.string().min(1),
|
|
20
|
+
caption: schema.string().min(1).optional(),
|
|
21
|
+
})
|
|
22
|
+
.strict(),
|
|
23
|
+
schema
|
|
24
|
+
.object({
|
|
25
|
+
kind: schema.literal("video"),
|
|
26
|
+
src: schema.string().min(1),
|
|
27
|
+
title: schema.string().min(1),
|
|
28
|
+
aspect: schema.string().min(1).default("16/9"),
|
|
29
|
+
type: schema.enum(["iframe", "video"]).default("iframe"),
|
|
30
|
+
caption: schema.string().min(1).optional(),
|
|
31
|
+
})
|
|
32
|
+
.strict(),
|
|
33
|
+
]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const heroMediaSchema = createHeroMediaSchema();
|
|
37
|
+
export type HeroMedia = defaultZ.infer<typeof heroMediaSchema>;
|
|
@@ -18,6 +18,7 @@ import StepsCmp from "../components/mdx/Steps.astro";
|
|
|
18
18
|
import Tab from "../components/mdx/Tab.astro";
|
|
19
19
|
import Tabs from "../components/mdx/Tabs.astro";
|
|
20
20
|
import Terminal from "../components/mdx/Terminal.astro";
|
|
21
|
+
import Track from "../components/mdx/Track.astro";
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* The components map passed to <Content components={...} />. Every React
|
|
@@ -37,6 +38,7 @@ export function mdxComponents() {
|
|
|
37
38
|
Download,
|
|
38
39
|
Tabs,
|
|
39
40
|
Tab,
|
|
41
|
+
Track,
|
|
40
42
|
FileTree,
|
|
41
43
|
Reveal,
|
|
42
44
|
Terminal,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type TrackMap<T> = Record<string, T>;
|
|
2
|
+
export type TrackScoped<T> = T | TrackMap<T>;
|
|
3
|
+
|
|
4
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
5
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function isStarterSpec(value: unknown): value is { kind: string } {
|
|
9
|
+
return isRecord(value) && typeof value.kind === "string";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isVerifySpec(value: unknown): value is { id: string; checks: unknown[] } {
|
|
13
|
+
return isRecord(value) && typeof value.id === "string" && Array.isArray(value.checks);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function resolveForTrack<T>(
|
|
17
|
+
value: TrackScoped<T> | undefined,
|
|
18
|
+
trackId: string | undefined,
|
|
19
|
+
isShared: (v: unknown) => v is T,
|
|
20
|
+
): T | undefined {
|
|
21
|
+
if (!value) return undefined;
|
|
22
|
+
if (isShared(value)) return value;
|
|
23
|
+
return trackId ? value[trackId] : undefined;
|
|
24
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const TRACK_BLOCK =
|
|
2
|
+
/<Track\b[^>]*\bid\s*=\s*(?:"([^"]+)"|'([^']+)'|\{`([^`]+)`\})[^>]*>([\s\S]*?)<\/Track>/g;
|
|
3
|
+
|
|
4
|
+
export function stripInactiveTrackBlocks(source: string, activeTrack: string | undefined): string {
|
|
5
|
+
if (!activeTrack) return source;
|
|
6
|
+
return source.replace(TRACK_BLOCK, (_full, doubleId, singleId, templateId, body) => {
|
|
7
|
+
const id = doubleId ?? singleId ?? templateId;
|
|
8
|
+
return id === activeTrack ? String(body).trim() : "";
|
|
9
|
+
});
|
|
10
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface TrackOption {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface ResolveTrackInput {
|
|
7
|
+
tracks?: TrackOption[];
|
|
8
|
+
explicitTrack?: string | null;
|
|
9
|
+
preferredTrack?: string | null;
|
|
10
|
+
defaultTrack?: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function resolveActiveTrack({
|
|
14
|
+
tracks = [],
|
|
15
|
+
explicitTrack,
|
|
16
|
+
preferredTrack,
|
|
17
|
+
defaultTrack,
|
|
18
|
+
}: ResolveTrackInput): string | undefined {
|
|
19
|
+
if (tracks.length === 0) return undefined;
|
|
20
|
+
const ids = new Set(tracks.map((track) => track.id));
|
|
21
|
+
if (explicitTrack && ids.has(explicitTrack)) return explicitTrack;
|
|
22
|
+
if (preferredTrack && ids.has(preferredTrack)) return preferredTrack;
|
|
23
|
+
if (defaultTrack && ids.has(defaultTrack)) return defaultTrack;
|
|
24
|
+
return tracks[0]?.id;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function trackStyleText(trackId: string | undefined): string {
|
|
28
|
+
if (!trackId) return "";
|
|
29
|
+
// Track ids are schema-limited to [a-z0-9_-], so a quoted attribute
|
|
30
|
+
// selector is safe without relying on CSS.escape in older browsers.
|
|
31
|
+
return `[data-track-panel]:not([data-track-panel="${trackId}"]) { display: none !important; }`;
|
|
32
|
+
}
|