handzon-core 0.12.2 → 0.13.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/package.json +1 -1
- package/src/collections.ts +298 -61
- package/src/components/Sidebar.astro +8 -0
- package/src/components/StepHeroMedia.astro +77 -0
- package/src/components/TrackSelector.tsx +62 -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 +44 -0
- package/styles/components.css +1 -0
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { Image } from "astro:assets";
|
|
2
3
|
import BaseLayout from "../layouts/BaseLayout.astro";
|
|
3
4
|
import { withBase } from "../lib/base.ts";
|
|
4
5
|
import { getStepsForTutorial, parseStepId, sumDurations } from "../lib/content.ts";
|
|
@@ -35,10 +36,14 @@ const {
|
|
|
35
36
|
const steps = await getStepsForTutorial(tutorial.id);
|
|
36
37
|
const duration = tutorial.data.estimatedDuration ?? sumDurations(steps) ?? "";
|
|
37
38
|
const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
|
|
39
|
+
const iconText = typeof tutorial.data.icon === "string" ? tutorial.data.icon : undefined;
|
|
40
|
+
const iconImage = tutorial.data.icon && typeof tutorial.data.icon !== "string" ? tutorial.data.icon : undefined;
|
|
41
|
+
const coverUrl = tutorial.data.cover?.src;
|
|
38
42
|
---
|
|
39
43
|
<BaseLayout
|
|
40
44
|
title={tutorial.data.title}
|
|
41
45
|
description={tutorial.data.description}
|
|
46
|
+
ogImageUrl={coverUrl}
|
|
42
47
|
siteName={siteName}
|
|
43
48
|
tagline={tagline}
|
|
44
49
|
logoUrl={logoUrl}
|
|
@@ -54,38 +59,76 @@ const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
|
|
|
54
59
|
<div class="landing">
|
|
55
60
|
<a class="back" href={withBase("/")}>← All tutorials</a>
|
|
56
61
|
|
|
57
|
-
<header class="hero">
|
|
58
|
-
<div class="hero-
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
<header class:list={["hero", tutorial.data.cover && "hero-with-cover"]}>
|
|
63
|
+
<div class="hero-copy">
|
|
64
|
+
<div class="hero-meta">
|
|
65
|
+
<span class={`pill pill-${tutorial.data.difficulty}`}>{tutorial.data.difficulty}</span>
|
|
66
|
+
{duration && <span class="pill pill-neutral">⏱ {duration}</span>}
|
|
67
|
+
<span class="pill pill-neutral">{steps.length} {steps.length === 1 ? "step" : "steps"}</span>
|
|
68
|
+
</div>
|
|
63
69
|
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
<div class="hero-title-row">
|
|
71
|
+
{iconImage && (
|
|
72
|
+
<Image
|
|
73
|
+
src={iconImage}
|
|
74
|
+
alt=""
|
|
75
|
+
class="hero-icon hero-icon-image"
|
|
76
|
+
width={48}
|
|
77
|
+
height={48}
|
|
78
|
+
/>
|
|
79
|
+
)}
|
|
80
|
+
{iconText && <span class="hero-icon hero-icon-text" aria-hidden="true">{iconText}</span>}
|
|
81
|
+
<h1>{tutorial.data.title}</h1>
|
|
82
|
+
</div>
|
|
83
|
+
<p class="desc">{tutorial.data.description}</p>
|
|
66
84
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
<div class="hero-actions">
|
|
86
|
+
{firstStepSlug && (
|
|
87
|
+
<a class="cta" href={withBase(`/${tutorial.id}/${firstStepSlug}`)} data-tutorial-slug={tutorial.id}>
|
|
88
|
+
<span class="cta-label">Start tutorial</span>
|
|
89
|
+
<span class="cta-arrow" aria-hidden="true">→</span>
|
|
90
|
+
</a>
|
|
91
|
+
)}
|
|
92
|
+
{tutorial.data.author && (
|
|
93
|
+
<div class="author">
|
|
94
|
+
{tutorial.data.author.avatar && (
|
|
95
|
+
<Image
|
|
96
|
+
src={tutorial.data.author.avatar}
|
|
97
|
+
alt=""
|
|
98
|
+
class="author-avatar"
|
|
99
|
+
width={32}
|
|
100
|
+
height={32}
|
|
101
|
+
loading="lazy"
|
|
102
|
+
/>
|
|
103
|
+
)}
|
|
104
|
+
<span>
|
|
105
|
+
By <strong>{tutorial.data.author.name}</strong>
|
|
106
|
+
{tutorial.data.publishedAt && (
|
|
107
|
+
<> · {new Date(tutorial.data.publishedAt).toLocaleDateString()}</>
|
|
108
|
+
)}
|
|
109
|
+
</span>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{tutorial.data.tags.length > 0 && (
|
|
115
|
+
<div class="hero-tags" aria-label="Topics">
|
|
116
|
+
{tutorial.data.tags.map((tag: string) => (
|
|
117
|
+
<a class="hero-tag" href={withBase(`/?tag=${encodeURIComponent(tag)}`)}>#{tag}</a>
|
|
118
|
+
))}
|
|
80
119
|
</div>
|
|
81
120
|
)}
|
|
82
121
|
</div>
|
|
83
122
|
|
|
84
|
-
{tutorial.data.
|
|
85
|
-
<div class="hero-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
123
|
+
{tutorial.data.cover && (
|
|
124
|
+
<div class="hero-media">
|
|
125
|
+
<Image
|
|
126
|
+
src={tutorial.data.cover}
|
|
127
|
+
alt=""
|
|
128
|
+
class="hero-cover"
|
|
129
|
+
widths={[520, 760, 1040]}
|
|
130
|
+
sizes="(min-width: 900px) 38vw, 100vw"
|
|
131
|
+
/>
|
|
89
132
|
</div>
|
|
90
133
|
)}
|
|
91
134
|
</header>
|
|
@@ -167,6 +210,53 @@ const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
|
|
|
167
210
|
border-left-color: var(--color-accent);
|
|
168
211
|
margin-bottom: 3rem;
|
|
169
212
|
}
|
|
213
|
+
.hero-with-cover {
|
|
214
|
+
display: grid;
|
|
215
|
+
grid-template-columns: minmax(0, 1.1fr) minmax(18rem, 0.9fr);
|
|
216
|
+
gap: clamp(1.5rem, 4vw, 3rem);
|
|
217
|
+
align-items: stretch;
|
|
218
|
+
}
|
|
219
|
+
.hero-copy {
|
|
220
|
+
min-width: 0;
|
|
221
|
+
}
|
|
222
|
+
.hero-title-row {
|
|
223
|
+
display: flex;
|
|
224
|
+
align-items: flex-start;
|
|
225
|
+
gap: 0.85rem;
|
|
226
|
+
}
|
|
227
|
+
.hero-icon {
|
|
228
|
+
flex-shrink: 0;
|
|
229
|
+
width: 3rem;
|
|
230
|
+
height: 3rem;
|
|
231
|
+
border: var(--border-default, 2px) solid var(--color-border);
|
|
232
|
+
background: var(--color-bg);
|
|
233
|
+
margin-top: 0.25rem;
|
|
234
|
+
}
|
|
235
|
+
.hero-icon-image {
|
|
236
|
+
object-fit: cover;
|
|
237
|
+
display: block;
|
|
238
|
+
}
|
|
239
|
+
.hero-icon-text {
|
|
240
|
+
display: inline-grid;
|
|
241
|
+
place-items: center;
|
|
242
|
+
font-size: 1.45rem;
|
|
243
|
+
line-height: 1;
|
|
244
|
+
}
|
|
245
|
+
.hero-media {
|
|
246
|
+
min-height: 100%;
|
|
247
|
+
border: var(--border-default, 2px) solid var(--color-border);
|
|
248
|
+
background: var(--color-surface);
|
|
249
|
+
overflow: hidden;
|
|
250
|
+
box-shadow: 0.45rem 0.45rem 0 color-mix(in oklab, var(--color-accent) 35%, transparent);
|
|
251
|
+
}
|
|
252
|
+
.hero-cover {
|
|
253
|
+
width: 100%;
|
|
254
|
+
height: 100%;
|
|
255
|
+
min-height: 18rem;
|
|
256
|
+
object-fit: cover;
|
|
257
|
+
display: block;
|
|
258
|
+
filter: saturate(0.95) contrast(1.06);
|
|
259
|
+
}
|
|
170
260
|
.hero-meta {
|
|
171
261
|
display: flex;
|
|
172
262
|
flex-wrap: wrap;
|
|
@@ -234,7 +324,19 @@ const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
|
|
|
234
324
|
align-items: center;
|
|
235
325
|
gap: 1.25rem;
|
|
236
326
|
}
|
|
237
|
-
.author {
|
|
327
|
+
.author {
|
|
328
|
+
color: var(--color-muted);
|
|
329
|
+
font-size: 0.85em;
|
|
330
|
+
display: inline-flex;
|
|
331
|
+
align-items: center;
|
|
332
|
+
gap: 0.55rem;
|
|
333
|
+
}
|
|
334
|
+
.author-avatar {
|
|
335
|
+
width: 2rem;
|
|
336
|
+
height: 2rem;
|
|
337
|
+
border: 1px solid var(--color-border);
|
|
338
|
+
object-fit: cover;
|
|
339
|
+
}
|
|
238
340
|
.cta {
|
|
239
341
|
display: inline-flex;
|
|
240
342
|
align-items: center;
|
|
@@ -345,6 +447,9 @@ const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
|
|
|
345
447
|
}
|
|
346
448
|
@media (max-width: 640px) {
|
|
347
449
|
.hero { padding: 1.5rem 1.25rem 1.75rem; }
|
|
450
|
+
.hero-with-cover { grid-template-columns: 1fr; }
|
|
451
|
+
.hero-media { order: -1; }
|
|
452
|
+
.hero-cover { min-height: 12rem; }
|
|
348
453
|
.step-list a { grid-template-columns: auto 1fr auto; padding: 0.75rem; }
|
|
349
454
|
.step-chevron { display: none; }
|
|
350
455
|
}
|