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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handzon-core",
3
- "version": "0.9.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
- * crediting the framework + linking to the Handzon repo. Sized to
5
- * match the rest of the brutalist surface treatment.
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
- /** Public URL to the framework repo. Override to point elsewhere. */
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
- <div class="hz-footer-inner">
21
- <span class="hz-footer-credit">
22
- © {year} · Built with
23
- <a class="hz-footer-link" href={repoUrl} target="_blank" rel="noopener">
24
- Handzon
25
- <svg
26
- viewBox="0 0 24 24"
27
- width="11"
28
- height="11"
29
- fill="none"
30
- stroke="currentColor"
31
- stroke-width="2"
32
- stroke-linecap="round"
33
- stroke-linejoin="round"
34
- aria-hidden="true"
35
- >
36
- <path d="M7 17 17 7" />
37
- <path d="M7 7h10v10" />
38
- </svg>
39
- </a>
40
- </span>
41
- </div>
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
- {showFooter && <Footer repoUrl={repoUrl} />}
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">
@@ -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;
@@ -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} />