handzon-core 0.9.0 → 0.11.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/components/Footer.astro +67 -26
- package/src/components/Navbar.astro +3 -2
- package/src/components/Sidebar.astro +4 -3
- package/src/components/StepNav.astro +4 -3
- package/src/components/ai/ChatPanel.tsx +2 -1
- package/src/components/auth/UserMenu.tsx +7 -6
- package/src/components/home/Hero.astro +3 -1
- package/src/components/home/ResumeRail.tsx +2 -1
- package/src/components/home/TutorialCard.astro +2 -1
- package/src/layouts/BaseLayout.astro +14 -2
- package/src/layouts/TutorialLayout.astro +7 -0
- package/src/lib/base.ts +31 -0
- package/src/lib/progress/remote.ts +4 -3
- package/src/pages/Home.astro +9 -1
- package/src/pages/TutorialLanding.astro +14 -5
- package/src/pages/TutorialStep.astro +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handzon-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Core framework for Handzon — layouts, components, content + AI libs, and server runtime (handlers, DB, auth, migration runner) consumed by Handzon scaffolds.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -1,44 +1,72 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* Site footer. A single quiet line at the bottom of every page,
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Site footer. A single quiet line at the bottom of every page, sized
|
|
4
|
+
* to match the rest of the brutalist surface treatment.
|
|
5
|
+
*
|
|
6
|
+
* Two modes:
|
|
7
|
+
* - Default (no `siteUrl`): one centered "Built with Handzon" credit,
|
|
8
|
+
* preserving the framework's original footer for unbranded scaffolds.
|
|
9
|
+
* - Branded (`siteUrl` set): the site owner's credit leads on the left
|
|
10
|
+
* (linked to `siteUrl`), and "Built with Handzon" moves to a quieter
|
|
11
|
+
* secondary link on the right.
|
|
6
12
|
*/
|
|
7
13
|
interface Props {
|
|
8
|
-
/**
|
|
14
|
+
/** Primary credit label — the site owner. */
|
|
15
|
+
siteName?: string;
|
|
16
|
+
/** Primary credit link. When set, the footer leads with this credit
|
|
17
|
+
* and demotes the Handzon link to a secondary side link. */
|
|
18
|
+
siteUrl?: string;
|
|
19
|
+
/** Handzon project/package link (the secondary "Built with" credit). */
|
|
9
20
|
repoUrl?: string;
|
|
10
21
|
/** Year displayed in the credit line; defaults to the current year. */
|
|
11
22
|
year?: number;
|
|
12
23
|
}
|
|
13
24
|
|
|
14
25
|
const {
|
|
26
|
+
siteName = "Handzon",
|
|
27
|
+
siteUrl,
|
|
15
28
|
repoUrl = "https://github.com/R4ph-t/handzon",
|
|
16
29
|
year = new Date().getFullYear(),
|
|
17
30
|
} = Astro.props;
|
|
18
31
|
---
|
|
19
32
|
<footer class="hz-footer">
|
|
20
|
-
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
viewBox="0 0 24 24"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
33
|
+
{siteUrl ? (
|
|
34
|
+
<div class="hz-footer-inner hz-footer-split">
|
|
35
|
+
<span class="hz-footer-credit">
|
|
36
|
+
© {year}
|
|
37
|
+
<a class="hz-footer-link" href={siteUrl} target="_blank" rel="noopener">
|
|
38
|
+
{siteName}
|
|
39
|
+
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
40
|
+
<path d="M7 17 17 7" />
|
|
41
|
+
<path d="M7 7h10v10" />
|
|
42
|
+
</svg>
|
|
43
|
+
</a>
|
|
44
|
+
</span>
|
|
45
|
+
<span class="hz-footer-built">
|
|
46
|
+
Built with
|
|
47
|
+
<a class="hz-footer-link" href={repoUrl} target="_blank" rel="noopener">
|
|
48
|
+
Handzon
|
|
49
|
+
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
50
|
+
<path d="M7 17 17 7" />
|
|
51
|
+
<path d="M7 7h10v10" />
|
|
52
|
+
</svg>
|
|
53
|
+
</a>
|
|
54
|
+
</span>
|
|
55
|
+
</div>
|
|
56
|
+
) : (
|
|
57
|
+
<div class="hz-footer-inner">
|
|
58
|
+
<span class="hz-footer-credit">
|
|
59
|
+
© {year} · Built with
|
|
60
|
+
<a class="hz-footer-link" href={repoUrl} target="_blank" rel="noopener">
|
|
61
|
+
Handzon
|
|
62
|
+
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
63
|
+
<path d="M7 17 17 7" />
|
|
64
|
+
<path d="M7 7h10v10" />
|
|
65
|
+
</svg>
|
|
66
|
+
</a>
|
|
67
|
+
</span>
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
42
70
|
</footer>
|
|
43
71
|
|
|
44
72
|
<style is:global>
|
|
@@ -64,11 +92,24 @@ const {
|
|
|
64
92
|
font-family: var(--font-mono);
|
|
65
93
|
font-size: 0.75em;
|
|
66
94
|
}
|
|
95
|
+
/* Branded mode: site credit left, "Built with Handzon" right. Falls
|
|
96
|
+
* back to a stacked, centered layout on narrow viewports. */
|
|
97
|
+
.hz-footer-split {
|
|
98
|
+
justify-content: space-between;
|
|
99
|
+
gap: 0.75rem 1.5rem;
|
|
100
|
+
flex-wrap: wrap;
|
|
101
|
+
}
|
|
67
102
|
.hz-footer-credit {
|
|
68
103
|
display: inline-flex;
|
|
69
104
|
align-items: center;
|
|
70
105
|
gap: 0.45rem;
|
|
71
106
|
}
|
|
107
|
+
.hz-footer-built {
|
|
108
|
+
display: inline-flex;
|
|
109
|
+
align-items: center;
|
|
110
|
+
gap: 0.4rem;
|
|
111
|
+
opacity: 0.85;
|
|
112
|
+
}
|
|
72
113
|
.hz-footer-link {
|
|
73
114
|
display: inline-flex;
|
|
74
115
|
align-items: center;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* homepage. Sticky so it stays available as users scroll long
|
|
8
8
|
* tutorial steps.
|
|
9
9
|
*/
|
|
10
|
+
import { withBase } from "../lib/base";
|
|
10
11
|
import UserMenu from "./auth/UserMenu.astro";
|
|
11
12
|
|
|
12
13
|
interface Props {
|
|
@@ -33,10 +34,10 @@ const {
|
|
|
33
34
|
---
|
|
34
35
|
<header class="hz-nav">
|
|
35
36
|
<div class="hz-nav-inner">
|
|
36
|
-
<a href="/" class="hz-nav-brand" aria-label={`${siteName} home`}>
|
|
37
|
+
<a href={withBase("/")} class="hz-nav-brand" aria-label={`${siteName} home`}>
|
|
37
38
|
{logoUrl && (
|
|
38
39
|
<img
|
|
39
|
-
src={logoUrl}
|
|
40
|
+
src={withBase(logoUrl)}
|
|
40
41
|
alt=""
|
|
41
42
|
aria-hidden="true"
|
|
42
43
|
class="hz-nav-logo"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { withBase } from "../lib/base";
|
|
2
3
|
import type { TutorialEntry, StepEntry } from "../lib/content";
|
|
3
4
|
import { parseStepId } from "../lib/content";
|
|
4
5
|
import Progress from "./Progress.tsx";
|
|
@@ -17,9 +18,9 @@ const slug = tutorial.id;
|
|
|
17
18
|
* step list below scrolls when there are many steps. */}
|
|
18
19
|
<div class="sb-top">
|
|
19
20
|
<header class="sb-header">
|
|
20
|
-
<a class="sb-back" href="/">← All tutorials</a>
|
|
21
|
+
<a class="sb-back" href={withBase("/")}>← All tutorials</a>
|
|
21
22
|
<h2 class="sb-title">
|
|
22
|
-
<a href={`/${slug}`}>{tutorial.data.title}</a>
|
|
23
|
+
<a href={withBase(`/${slug}`)}>{tutorial.data.title}</a>
|
|
23
24
|
</h2>
|
|
24
25
|
{tutorial.data.estimatedDuration && (
|
|
25
26
|
<div class="sb-meta">{tutorial.data.estimatedDuration}</div>
|
|
@@ -37,7 +38,7 @@ const slug = tutorial.id;
|
|
|
37
38
|
const isCurrent = stepSlug === currentStepSlug;
|
|
38
39
|
return (
|
|
39
40
|
<li class={isCurrent ? "is-current" : ""} data-step-slug={stepSlug}>
|
|
40
|
-
<a href={`/${slug}/${stepSlug}`}>
|
|
41
|
+
<a href={withBase(`/${slug}/${stepSlug}`)}>
|
|
41
42
|
<span class="sb-check" data-step-key={`${slug}/${stepSlug}`}></span>
|
|
42
43
|
<span class="sb-name">{step.data.title}</span>
|
|
43
44
|
{step.data.duration && <span class="sb-dur">{step.data.duration}</span>}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { withBase } from "../lib/base";
|
|
2
3
|
import type { StepEntry } from "../lib/content";
|
|
3
4
|
import { parseStepId } from "../lib/content";
|
|
4
5
|
|
|
@@ -20,18 +21,18 @@ const nextSlug = next ? parseStepId(next.id).stepSlug : null;
|
|
|
20
21
|
<nav class="step-nav" data-gated={gated && hasCheckpoint ? "true" : "false"} data-step-key={`${tutorialSlug}/${currentStepSlug}`}>
|
|
21
22
|
<div>
|
|
22
23
|
{prev && (
|
|
23
|
-
<a class="sn-prev" href={`/${tutorialSlug}/${prevSlug}`}>
|
|
24
|
+
<a class="sn-prev" href={withBase(`/${tutorialSlug}/${prevSlug}`)}>
|
|
24
25
|
← {prev.data.title}
|
|
25
26
|
</a>
|
|
26
27
|
)}
|
|
27
28
|
</div>
|
|
28
29
|
<div>
|
|
29
30
|
{next ? (
|
|
30
|
-
<a class="sn-next" href={`/${tutorialSlug}/${nextSlug}`} data-next-link="true">
|
|
31
|
+
<a class="sn-next" href={withBase(`/${tutorialSlug}/${nextSlug}`)} data-next-link="true">
|
|
31
32
|
{hasCheckpoint ? "Continue" : "Next"}: {next.data.title} →
|
|
32
33
|
</a>
|
|
33
34
|
) : (
|
|
34
|
-
<a class="sn-next sn-done" href={`/${tutorialSlug}`}>
|
|
35
|
+
<a class="sn-next sn-done" href={withBase(`/${tutorialSlug}`)}>
|
|
35
36
|
Finish tutorial →
|
|
36
37
|
</a>
|
|
37
38
|
)}
|
|
@@ -5,6 +5,7 @@ import ReactMarkdown from "react-markdown";
|
|
|
5
5
|
import remarkGfm from "remark-gfm";
|
|
6
6
|
import { type ChatMessage, clearLearnerKey, loadLearnerKey, streamChat } from "../../lib/ai/client";
|
|
7
7
|
import type { AssistantContext } from "../../lib/ai/context";
|
|
8
|
+
import { withBase } from "../../lib/base";
|
|
8
9
|
import type { AiConfig } from "../../types/ai";
|
|
9
10
|
import ByokSetup from "./ByokSetup";
|
|
10
11
|
|
|
@@ -130,7 +131,7 @@ export default function ChatPanel({ open, onOpenChange, config, context, initial
|
|
|
130
131
|
inboxRef.current = true;
|
|
131
132
|
void (async () => {
|
|
132
133
|
try {
|
|
133
|
-
const res = await fetch("/api/help-inbox");
|
|
134
|
+
const res = await fetch(withBase("/api/help-inbox"));
|
|
134
135
|
if (!res.ok) return;
|
|
135
136
|
const data = (await res.json()) as {
|
|
136
137
|
requests?: Array<{ query: string; tutorialSlug: string; stepSlug: string }>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
|
+
import { withBase } from "../../lib/base";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Client-only auth menu. Fetches `/api/auth/session` + `/api/auth/csrf`
|
|
@@ -41,8 +42,8 @@ export default function UserMenu() {
|
|
|
41
42
|
(async () => {
|
|
42
43
|
try {
|
|
43
44
|
const [sessRes, csrfRes] = await Promise.all([
|
|
44
|
-
fetch("/api/auth/session", { credentials: "same-origin" }),
|
|
45
|
-
fetch("/api/auth/csrf", { credentials: "same-origin" }),
|
|
45
|
+
fetch(withBase("/api/auth/session"), { credentials: "same-origin" }),
|
|
46
|
+
fetch(withBase("/api/auth/csrf"), { credentials: "same-origin" }),
|
|
46
47
|
]);
|
|
47
48
|
if (cancelled) return;
|
|
48
49
|
// 404 → auth-astro integration not wired in this scaffold.
|
|
@@ -71,7 +72,7 @@ export default function UserMenu() {
|
|
|
71
72
|
if (session === undefined || !csrfToken) return null;
|
|
72
73
|
|
|
73
74
|
const user = session?.user;
|
|
74
|
-
const callbackUrl = typeof window !== "undefined" ? window.location.href : "/";
|
|
75
|
+
const callbackUrl = typeof window !== "undefined" ? window.location.href : withBase("/");
|
|
75
76
|
|
|
76
77
|
// Compact label for the topbar: first word of `name`, falling back
|
|
77
78
|
// to the local part of `email`, falling back to a generic. Full name
|
|
@@ -100,7 +101,7 @@ export default function UserMenu() {
|
|
|
100
101
|
</span>
|
|
101
102
|
<a
|
|
102
103
|
className="um-btn um-btn-icon"
|
|
103
|
-
href="/settings/tokens"
|
|
104
|
+
href={withBase("/settings/tokens")}
|
|
104
105
|
title="Access tokens for editor MCP"
|
|
105
106
|
>
|
|
106
107
|
<svg
|
|
@@ -119,7 +120,7 @@ export default function UserMenu() {
|
|
|
119
120
|
</svg>
|
|
120
121
|
<span className="sr-only">Access tokens</span>
|
|
121
122
|
</a>
|
|
122
|
-
<form method="post" action="/api/auth/signout">
|
|
123
|
+
<form method="post" action={withBase("/api/auth/signout")}>
|
|
123
124
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
|
124
125
|
<input type="hidden" name="callbackUrl" value={callbackUrl} />
|
|
125
126
|
<button type="submit" className="um-btn um-btn-icon" title="Sign out">
|
|
@@ -143,7 +144,7 @@ export default function UserMenu() {
|
|
|
143
144
|
</form>
|
|
144
145
|
</>
|
|
145
146
|
) : (
|
|
146
|
-
<form method="post" action="/api/auth/signin/github">
|
|
147
|
+
<form method="post" action={withBase("/api/auth/signin/github")}>
|
|
147
148
|
<input type="hidden" name="csrfToken" value={csrfToken} />
|
|
148
149
|
<input type="hidden" name="callbackUrl" value={callbackUrl} />
|
|
149
150
|
<button type="submit" className="um-btn">
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { withBase } from "../../lib/base";
|
|
3
|
+
|
|
2
4
|
interface Props {
|
|
3
5
|
title?: string;
|
|
4
6
|
subtitle?: string;
|
|
@@ -32,7 +34,7 @@ const {
|
|
|
32
34
|
{logoUrl && (
|
|
33
35
|
<img
|
|
34
36
|
class="hero-logo"
|
|
35
|
-
src={logoUrl}
|
|
37
|
+
src={withBase(logoUrl)}
|
|
36
38
|
alt=""
|
|
37
39
|
aria-hidden="true"
|
|
38
40
|
width={logoWidth}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
|
+
import { withBase } from "../../lib/base";
|
|
2
3
|
import { useProgressAfterMount } from "../../lib/progress/useProgress";
|
|
3
4
|
|
|
4
5
|
interface Tutorial {
|
|
@@ -40,7 +41,7 @@ export default function ResumeRail({ tutorials }: Props) {
|
|
|
40
41
|
if (!state || !mostRecent) return null;
|
|
41
42
|
|
|
42
43
|
return (
|
|
43
|
-
<a className="resume-rail" href={`/${mostRecent.slug}/${mostRecent.step}`}>
|
|
44
|
+
<a className="resume-rail" href={withBase(`/${mostRecent.slug}/${mostRecent.step}`)}>
|
|
44
45
|
<span className="rr-prefix">Continue</span>
|
|
45
46
|
<span className="rr-title">{mostRecent.title}</span>
|
|
46
47
|
<span className="rr-step">/ {mostRecent.step}</span>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { withBase } from "../../lib/base";
|
|
2
3
|
import type { TutorialEntry } from "../../lib/content";
|
|
3
4
|
|
|
4
5
|
interface Props {
|
|
@@ -23,7 +24,7 @@ const slug = tutorial.id;
|
|
|
23
24
|
data-card-slug={slug}
|
|
24
25
|
data-popularity="0"
|
|
25
26
|
>
|
|
26
|
-
<a href={`/${slug}`} class="card-link">
|
|
27
|
+
<a href={withBase(`/${slug}`)} class="card-link">
|
|
27
28
|
<div class="card-body">
|
|
28
29
|
<h3>{data.title}</h3>
|
|
29
30
|
<p>{data.description}</p>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import Footer from "../components/Footer.astro";
|
|
3
3
|
import Navbar from "../components/Navbar.astro";
|
|
4
4
|
import UserMenu from "../components/auth/UserMenu.astro";
|
|
5
|
+
import { withBase } from "../lib/base";
|
|
5
6
|
|
|
6
7
|
interface Props {
|
|
7
8
|
title?: string;
|
|
@@ -33,6 +34,10 @@ interface Props {
|
|
|
33
34
|
showFooter?: boolean;
|
|
34
35
|
/** Footer link URL; defaults to the Handzon repo. */
|
|
35
36
|
repoUrl?: string;
|
|
37
|
+
/** Primary footer credit link (the site owner, e.g. https://render.com).
|
|
38
|
+
* When set, the footer leads with the site credit and demotes the
|
|
39
|
+
* Handzon link to a secondary side link. */
|
|
40
|
+
siteUrl?: string;
|
|
36
41
|
/**
|
|
37
42
|
* Width of the page's content column. Drives the navbar + footer
|
|
38
43
|
* alignment via the --hz-page-max-width CSS custom property so the
|
|
@@ -62,6 +67,7 @@ const {
|
|
|
62
67
|
faviconUrl = "/favicon.svg",
|
|
63
68
|
showFooter = true,
|
|
64
69
|
repoUrl,
|
|
70
|
+
siteUrl,
|
|
65
71
|
// Default to a full-width navbar + footer with a small consistent
|
|
66
72
|
// inset, matching the tutorial step page. Pages with a centred
|
|
67
73
|
// column (home grid, tutorial landing) keep their own content
|
|
@@ -80,7 +86,7 @@ const desc = description ?? tagline;
|
|
|
80
86
|
<head>
|
|
81
87
|
<meta charset="utf-8" />
|
|
82
88
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
83
|
-
<link rel="icon" type="image/svg+xml" href={faviconUrl} />
|
|
89
|
+
<link rel="icon" type="image/svg+xml" href={withBase(faviconUrl)} />
|
|
84
90
|
<title>{pageTitle}</title>
|
|
85
91
|
<meta name="description" content={desc} />
|
|
86
92
|
<meta property="og:title" content={pageTitle} />
|
|
@@ -112,7 +118,13 @@ const desc = description ?? tagline;
|
|
|
112
118
|
<div class="hz-page">
|
|
113
119
|
<slot />
|
|
114
120
|
</div>
|
|
115
|
-
{
|
|
121
|
+
{/* Footer is fully overridable: pass `slot="footer"` from any page
|
|
122
|
+
wrapper to replace it entirely. With no override, the default
|
|
123
|
+
Footer renders, configurable via siteName/siteUrl/repoUrl. */}
|
|
124
|
+
{/* Footer content is configurable via siteName/siteUrl/repoUrl. For
|
|
125
|
+
a fully custom footer, a scaffold can pass showFooter={false} and
|
|
126
|
+
render its own markup in the page body. */}
|
|
127
|
+
{showFooter && <Footer siteName={siteName} siteUrl={siteUrl} repoUrl={repoUrl} />}
|
|
116
128
|
<script>
|
|
117
129
|
// Render any <pre class="mermaid"> blocks emitted by rehype-mermaid.
|
|
118
130
|
if (document.querySelector("pre.mermaid")) {
|
|
@@ -18,6 +18,9 @@ interface Props {
|
|
|
18
18
|
logoHeight?: number;
|
|
19
19
|
faviconUrl?: string;
|
|
20
20
|
repoUrl?: string;
|
|
21
|
+
siteUrl?: string;
|
|
22
|
+
/** Set false to drop the built-in footer and supply your own. */
|
|
23
|
+
showFooter?: boolean;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
const {
|
|
@@ -33,6 +36,8 @@ const {
|
|
|
33
36
|
logoHeight,
|
|
34
37
|
faviconUrl,
|
|
35
38
|
repoUrl,
|
|
39
|
+
siteUrl,
|
|
40
|
+
showFooter = true,
|
|
36
41
|
} = Astro.props;
|
|
37
42
|
|
|
38
43
|
const stepSlugs = steps.map((s) => parseStepId(s.id).stepSlug);
|
|
@@ -47,6 +52,8 @@ const stepSlugs = steps.map((s) => parseStepId(s.id).stepSlug);
|
|
|
47
52
|
logoHeight={logoHeight}
|
|
48
53
|
faviconUrl={faviconUrl}
|
|
49
54
|
repoUrl={repoUrl}
|
|
55
|
+
siteUrl={siteUrl}
|
|
56
|
+
showFooter={showFooter}
|
|
50
57
|
>
|
|
51
58
|
<slot name="head" slot="head" />
|
|
52
59
|
<div class="layout">
|
package/src/lib/base.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefix an app-internal path with Astro's configured base path.
|
|
3
|
+
*
|
|
4
|
+
* Handzon emits a lot of root-absolute URLs in hand-written markup and
|
|
5
|
+
* client code (nav links, `/api/...` fetches, asset defaults). Astro
|
|
6
|
+
* auto-prefixes its own framework routes and `_astro/*` assets with
|
|
7
|
+
* `base`, but it can't rewrite strings we build by hand. Run every such
|
|
8
|
+
* path through `withBase` so the whole app honors a base path like
|
|
9
|
+
* `/tutorials` when one is set.
|
|
10
|
+
*
|
|
11
|
+
* For the default root deploy (`base` unset), `import.meta.env.BASE_URL`
|
|
12
|
+
* is `"/"`, so this is an identity transform — output is unchanged.
|
|
13
|
+
*
|
|
14
|
+
* `import.meta.env.BASE_URL` is resolved by the consumer's Astro build
|
|
15
|
+
* and is available in `.astro` frontmatter, `.tsx` components, and
|
|
16
|
+
* inline `<script>` tags, so one helper covers every call site.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* withBase("/react-todo") // "/react-todo" (root)
|
|
20
|
+
* withBase("/react-todo") // "/tutorials/react-todo" (base "/tutorials")
|
|
21
|
+
* withBase("/api/progress") // base-aware fetch target
|
|
22
|
+
* withBase("https://cdn/x.svg") // unchanged (absolute)
|
|
23
|
+
*/
|
|
24
|
+
export function withBase(path: string): string {
|
|
25
|
+
// Leave absolute, protocol-relative, and data/mailto/tel URLs alone so
|
|
26
|
+
// CDN-hosted logos and external links aren't mangled.
|
|
27
|
+
if (/^([a-z][a-z0-9+.-]*:|\/\/)/i.test(path)) return path;
|
|
28
|
+
const base = import.meta.env.BASE_URL; // "/" or e.g. "/tutorials/"
|
|
29
|
+
const rel = path.startsWith("/") ? path.slice(1) : path;
|
|
30
|
+
return base.endsWith("/") ? base + rel : `${base}/${rel}`;
|
|
31
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withBase } from "../base";
|
|
1
2
|
import {
|
|
2
3
|
CHANNEL_NAME,
|
|
3
4
|
emptyState,
|
|
@@ -181,7 +182,7 @@ export function createRemoteStore(): ProgressStore {
|
|
|
181
182
|
const pending = readPending();
|
|
182
183
|
if (pending.length === 0) return;
|
|
183
184
|
try {
|
|
184
|
-
const res = await fetch("/api/progress", {
|
|
185
|
+
const res = await fetch(withBase("/api/progress"), {
|
|
185
186
|
method: "POST",
|
|
186
187
|
headers: { "Content-Type": "application/json" },
|
|
187
188
|
body: JSON.stringify(pending),
|
|
@@ -204,7 +205,7 @@ export function createRemoteStore(): ProgressStore {
|
|
|
204
205
|
// On first mount, pull the server's snapshot and merge in.
|
|
205
206
|
void (async () => {
|
|
206
207
|
try {
|
|
207
|
-
const res = await fetch("/api/progress", { credentials: "same-origin" });
|
|
208
|
+
const res = await fetch(withBase("/api/progress"), { credentials: "same-origin" });
|
|
208
209
|
if (!res.ok) return;
|
|
209
210
|
const { entries } = (await res.json()) as {
|
|
210
211
|
entries: Array<{ kind: string; scope: string; key: string; value: unknown }>;
|
|
@@ -224,7 +225,7 @@ export function createRemoteStore(): ProgressStore {
|
|
|
224
225
|
// standard EventSource auto-reconnects on transient drops.
|
|
225
226
|
if (typeof EventSource !== "undefined") {
|
|
226
227
|
try {
|
|
227
|
-
const es = new EventSource("/api/progress/events", { withCredentials: true });
|
|
228
|
+
const es = new EventSource(withBase("/api/progress/events"), { withCredentials: true });
|
|
228
229
|
es.addEventListener("message", (ev) => {
|
|
229
230
|
try {
|
|
230
231
|
const entry = JSON.parse(ev.data) as ProgressEntry;
|
package/src/pages/Home.astro
CHANGED
|
@@ -16,6 +16,9 @@ interface Props {
|
|
|
16
16
|
logoHeight?: number;
|
|
17
17
|
faviconUrl?: string;
|
|
18
18
|
repoUrl?: string;
|
|
19
|
+
siteUrl?: string;
|
|
20
|
+
/** Set false to drop the built-in footer and supply your own. */
|
|
21
|
+
showFooter?: boolean;
|
|
19
22
|
showResumeRail?: boolean;
|
|
20
23
|
emptyStateCommand?: string;
|
|
21
24
|
/** Tutorials per page on the grid. */
|
|
@@ -31,6 +34,8 @@ const {
|
|
|
31
34
|
logoHeight,
|
|
32
35
|
faviconUrl,
|
|
33
36
|
repoUrl,
|
|
37
|
+
siteUrl,
|
|
38
|
+
showFooter = true,
|
|
34
39
|
showResumeRail = true,
|
|
35
40
|
emptyStateCommand = "pnpm handzon:new",
|
|
36
41
|
pageSize = 9,
|
|
@@ -64,6 +69,8 @@ for (const t of tutorials) {
|
|
|
64
69
|
logoHeight={logoHeight}
|
|
65
70
|
faviconUrl={faviconUrl}
|
|
66
71
|
repoUrl={repoUrl}
|
|
72
|
+
siteUrl={siteUrl}
|
|
73
|
+
showFooter={showFooter}
|
|
67
74
|
nav="userMenu"
|
|
68
75
|
>
|
|
69
76
|
<slot name="head" slot="head" />
|
|
@@ -164,6 +171,7 @@ for (const t of tutorials) {
|
|
|
164
171
|
// mount; Cache-Control: max-age=60 on the response handles SPA-like
|
|
165
172
|
// navigations. Failures are silent — cards just show no numbers,
|
|
166
173
|
// which is the desired state on Tier 1 anyway.
|
|
174
|
+
import { withBase } from "../lib/base.ts";
|
|
167
175
|
type Stat = { slug: string; started: number; completed: number };
|
|
168
176
|
function fmt(n: number): string {
|
|
169
177
|
if (n < 1000) return String(n);
|
|
@@ -178,7 +186,7 @@ for (const t of tutorials) {
|
|
|
178
186
|
}
|
|
179
187
|
async function hydrateStats() {
|
|
180
188
|
try {
|
|
181
|
-
const res = await fetch("/api/tutorials/stats", { credentials: "same-origin" });
|
|
189
|
+
const res = await fetch(withBase("/api/tutorials/stats"), { credentials: "same-origin" });
|
|
182
190
|
if (!res.ok) return;
|
|
183
191
|
const data = (await res.json()) as { stats: Stat[] };
|
|
184
192
|
for (const stat of data.stats) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
import BaseLayout from "../layouts/BaseLayout.astro";
|
|
3
|
+
import { withBase } from "../lib/base.ts";
|
|
3
4
|
import { getStepsForTutorial, parseStepId, sumDurations } from "../lib/content.ts";
|
|
4
5
|
import type { TutorialEntry } from "../lib/content.ts";
|
|
5
6
|
|
|
@@ -12,6 +13,9 @@ interface Props {
|
|
|
12
13
|
logoHeight?: number;
|
|
13
14
|
faviconUrl?: string;
|
|
14
15
|
repoUrl?: string;
|
|
16
|
+
siteUrl?: string;
|
|
17
|
+
/** Set false to drop the built-in footer and supply your own. */
|
|
18
|
+
showFooter?: boolean;
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
const {
|
|
@@ -23,6 +27,8 @@ const {
|
|
|
23
27
|
logoHeight,
|
|
24
28
|
faviconUrl,
|
|
25
29
|
repoUrl,
|
|
30
|
+
siteUrl,
|
|
31
|
+
showFooter = true,
|
|
26
32
|
} = Astro.props;
|
|
27
33
|
const steps = await getStepsForTutorial(tutorial.id);
|
|
28
34
|
const duration = tutorial.data.estimatedDuration ?? sumDurations(steps) ?? "";
|
|
@@ -38,10 +44,12 @@ const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
|
|
|
38
44
|
logoHeight={logoHeight}
|
|
39
45
|
faviconUrl={faviconUrl}
|
|
40
46
|
repoUrl={repoUrl}
|
|
47
|
+
siteUrl={siteUrl}
|
|
48
|
+
showFooter={showFooter}
|
|
41
49
|
>
|
|
42
50
|
<slot name="head" slot="head" />
|
|
43
51
|
<div class="landing">
|
|
44
|
-
<a class="back" href="/">← All tutorials</a>
|
|
52
|
+
<a class="back" href={withBase("/")}>← All tutorials</a>
|
|
45
53
|
|
|
46
54
|
<header class="hero">
|
|
47
55
|
<div class="hero-meta">
|
|
@@ -55,7 +63,7 @@ const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
|
|
|
55
63
|
|
|
56
64
|
<div class="hero-actions">
|
|
57
65
|
{firstStepSlug && (
|
|
58
|
-
<a class="cta" href={`/${tutorial.id}/${firstStepSlug}`} data-tutorial-slug={tutorial.id}>
|
|
66
|
+
<a class="cta" href={withBase(`/${tutorial.id}/${firstStepSlug}`)} data-tutorial-slug={tutorial.id}>
|
|
59
67
|
<span class="cta-label">Start tutorial</span>
|
|
60
68
|
<span class="cta-arrow" aria-hidden="true">→</span>
|
|
61
69
|
</a>
|
|
@@ -73,7 +81,7 @@ const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
|
|
|
73
81
|
{tutorial.data.tags.length > 0 && (
|
|
74
82
|
<div class="hero-tags" aria-label="Topics">
|
|
75
83
|
{tutorial.data.tags.map((tag: string) => (
|
|
76
|
-
<a class="hero-tag" href={`/?tag=${encodeURIComponent(tag)}`}>#{tag}</a>
|
|
84
|
+
<a class="hero-tag" href={withBase(`/?tag=${encodeURIComponent(tag)}`)}>#{tag}</a>
|
|
77
85
|
))}
|
|
78
86
|
</div>
|
|
79
87
|
)}
|
|
@@ -93,7 +101,7 @@ const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
|
|
|
93
101
|
const { stepSlug } = parseStepId(s.id);
|
|
94
102
|
return (
|
|
95
103
|
<li>
|
|
96
|
-
<a href={`/${tutorial.id}/${stepSlug}`}>
|
|
104
|
+
<a href={withBase(`/${tutorial.id}/${stepSlug}`)}>
|
|
97
105
|
<span class="step-num">{String(i + 1).padStart(2, "0")}</span>
|
|
98
106
|
<span class="step-body">
|
|
99
107
|
<strong>{s.data.title}</strong>
|
|
@@ -111,6 +119,7 @@ const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
|
|
|
111
119
|
|
|
112
120
|
<script>
|
|
113
121
|
// Resume from last visited if we have one.
|
|
122
|
+
import { withBase } from "../lib/base.ts";
|
|
114
123
|
import { getStore } from "../lib/progress/local.ts";
|
|
115
124
|
const cta = document.querySelector<HTMLAnchorElement>("[data-tutorial-slug]");
|
|
116
125
|
if (cta) {
|
|
@@ -119,7 +128,7 @@ const firstStepSlug = steps[0] ? parseStepId(steps[0].id).stepSlug : null;
|
|
|
119
128
|
const step = typeof last === "string" ? last : last?.step;
|
|
120
129
|
if (step) {
|
|
121
130
|
cta.textContent = `Continue from "${step}" →`;
|
|
122
|
-
cta.setAttribute("href", `/${slug}/${step}`);
|
|
131
|
+
cta.setAttribute("href", withBase(`/${slug}/${step}`));
|
|
123
132
|
}
|
|
124
133
|
}
|
|
125
134
|
</script>
|
|
@@ -24,6 +24,9 @@ interface Props {
|
|
|
24
24
|
logoHeight?: number;
|
|
25
25
|
faviconUrl?: string;
|
|
26
26
|
repoUrl?: string;
|
|
27
|
+
siteUrl?: string;
|
|
28
|
+
/** Set false to drop the built-in footer and supply your own. */
|
|
29
|
+
showFooter?: boolean;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
const {
|
|
@@ -38,6 +41,8 @@ const {
|
|
|
38
41
|
logoHeight,
|
|
39
42
|
faviconUrl,
|
|
40
43
|
repoUrl,
|
|
44
|
+
siteUrl,
|
|
45
|
+
showFooter = true,
|
|
41
46
|
} = Astro.props;
|
|
42
47
|
const { stepSlug } = parseStepId(currentStep.id);
|
|
43
48
|
const { Content } = await render(currentStep);
|
|
@@ -68,6 +73,8 @@ const initialContext = buildContext({
|
|
|
68
73
|
logoHeight={logoHeight}
|
|
69
74
|
faviconUrl={faviconUrl}
|
|
70
75
|
repoUrl={repoUrl}
|
|
76
|
+
siteUrl={siteUrl}
|
|
77
|
+
showFooter={showFooter}
|
|
71
78
|
>
|
|
72
79
|
<slot name="head" slot="head" />
|
|
73
80
|
<Content components={components} />
|