create-astroid 0.1.2 → 0.3.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/README.md +16 -7
- package/index.mjs +249 -16
- package/package.json +5 -3
- package/template/README.md +10 -0
- package/template/_env.example +36 -0
- package/template/astro.config.mjs +29 -12
- package/template/package.json +5 -2
- package/template/pnpm-workspace.yaml +38 -0
- package/template/scripts/seed-editors.mjs +2 -2
- package/template/seed/home.seed.sql +48 -1
- package/template/src/auth.ts +24 -14
- package/template/src/components/LouiseEdit.astro +46 -3
- package/template/src/env.d.ts +54 -5
- package/template/src/layouts/Site.astro +84 -4
- package/template/src/pages/contact.astro +122 -0
- package/template/src/pages/index.astro +78 -5
- package/template/src/pages/login.astro +52 -2
- package/template/src/pages/robots.txt.ts +36 -0
- package/template/src/pages/sitemap.xml.ts +40 -0
- package/template/src/styles/site.css +9 -0
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// Louise client turns into inline editors; saves stage a draft (versionedPageId),
|
|
6
6
|
// and Publish promotes it. Seed the row once with `seed/home.seed.sql`.
|
|
7
7
|
import Editable from "astroidjs/components/Editable.astro";
|
|
8
|
+
import Sections from "astroidjs/components/Sections.astro";
|
|
8
9
|
import { env } from "cloudflare:workers";
|
|
9
10
|
import Hero from "../components/Hero.astro";
|
|
10
11
|
import Site from "../layouts/Site.astro";
|
|
@@ -13,15 +14,63 @@ export const prerender = false;
|
|
|
13
14
|
|
|
14
15
|
const { editMode } = Astro.locals;
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
// Edge caching (ADR 0004). Opting in emits `Cloudflare-CDN-Cache-Control`, which
|
|
18
|
+
// the worker's `withEdgeCache` layer reads as "store this", then strips.
|
|
19
|
+
//
|
|
20
|
+
// Both conditions matter. Edit mode must NEVER opt in — a cached editor render
|
|
21
|
+
// would carry draft content and the inline-edit hooks into the shared public
|
|
22
|
+
// entry. And the var is off by default because `caches.default` survives
|
|
23
|
+
// Cloudflare Dev Mode and Purge Everything; walk the activation runbook in
|
|
24
|
+
// docs/adr/0004-edge-caching.md on a preview deploy before turning it on.
|
|
25
|
+
//
|
|
26
|
+
// `set(false)` is the safe state, not merely the "off" one: it emits `no-store`,
|
|
27
|
+
// so nothing downstream caches this HTML either.
|
|
28
|
+
if (editMode || env.ASTROID_EDGE_CACHE !== "true") {
|
|
29
|
+
Astro.cache.set(false);
|
|
30
|
+
} else {
|
|
31
|
+
// Short on purpose: `caches.default` is per-colo with no global purge, so this
|
|
32
|
+
// max-age is the real worldwide freshness floor after a publish.
|
|
33
|
+
Astro.cache.set({ maxAge: 60 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// `seo_*` are the page's SEO overrides, deliberately separate from `title` —
|
|
37
|
+
// which is the on-page H1. The home page passes NO title to <Site>, so <title>
|
|
38
|
+
// is the site-wide default ("Acme Coffee", not "Acme Coffee | Acme Coffee");
|
|
39
|
+
// set `seo_title` on the row to override just the head.
|
|
40
|
+
interface HomeRow {
|
|
41
|
+
id: number;
|
|
42
|
+
title: string;
|
|
43
|
+
body: string | null;
|
|
44
|
+
/** The page-builder array, stored as a JSON string in D1. */
|
|
45
|
+
sections: string | null;
|
|
46
|
+
seo_title: string | null;
|
|
47
|
+
seo_description: string | null;
|
|
48
|
+
og_image: string | null;
|
|
49
|
+
noindex: number | null;
|
|
50
|
+
}
|
|
51
|
+
let page: HomeRow | null = null;
|
|
17
52
|
try {
|
|
18
|
-
page = await env.DB.prepare(
|
|
53
|
+
page = await env.DB.prepare(
|
|
54
|
+
"SELECT id, title, body, sections, seo_title, seo_description, og_image, noindex FROM pages WHERE slug = 'home'",
|
|
55
|
+
).first<HomeRow>();
|
|
19
56
|
} catch {
|
|
20
57
|
// No DB binding yet (pre-provision) — fall through to the seed-me prompt.
|
|
21
58
|
}
|
|
22
59
|
|
|
23
60
|
let title = page?.title ?? "__BRAND_NAME__";
|
|
24
61
|
let body = page?.body ?? "";
|
|
62
|
+
// `sections` is a JSON string in D1. Bad JSON is a render-time non-event — the
|
|
63
|
+
// page falls back to its prose body rather than 500ing on one malformed row.
|
|
64
|
+
function parseSections(raw: string | null | undefined): unknown[] {
|
|
65
|
+
if (!raw) return [];
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(raw);
|
|
68
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
69
|
+
} catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
let sections = parseSections(page?.sections);
|
|
25
74
|
|
|
26
75
|
// In edit mode, prefer the latest unpublished draft so edits resume across reloads.
|
|
27
76
|
if (editMode && page) {
|
|
@@ -32,9 +81,18 @@ if (editMode && page) {
|
|
|
32
81
|
.bind(page.id)
|
|
33
82
|
.first<{ version_data: string }>();
|
|
34
83
|
if (draft?.version_data) {
|
|
35
|
-
const d = JSON.parse(draft.version_data) as {
|
|
84
|
+
const d = JSON.parse(draft.version_data) as {
|
|
85
|
+
title?: string;
|
|
86
|
+
body?: string;
|
|
87
|
+
sections?: unknown;
|
|
88
|
+
};
|
|
36
89
|
if (typeof d.title === "string") title = d.title;
|
|
37
90
|
if (typeof d.body === "string") body = d.body;
|
|
91
|
+
// Sections stage as drafts like any other field, so edit mode must render
|
|
92
|
+
// the draft's array — otherwise an editor's in-progress section edits
|
|
93
|
+
// vanish on reload while their title edits survive.
|
|
94
|
+
if (Array.isArray(d.sections)) sections = d.sections;
|
|
95
|
+
else if (typeof d.sections === "string") sections = parseSections(d.sections);
|
|
38
96
|
}
|
|
39
97
|
} catch {
|
|
40
98
|
// Non-fatal — fall back to the live row.
|
|
@@ -42,16 +100,31 @@ if (editMode && page) {
|
|
|
42
100
|
}
|
|
43
101
|
---
|
|
44
102
|
|
|
45
|
-
<Site
|
|
103
|
+
<Site
|
|
104
|
+
title={page?.seo_title ?? undefined}
|
|
105
|
+
description={page?.seo_description ?? undefined}
|
|
106
|
+
ogImage={page?.og_image ?? undefined}
|
|
107
|
+
noindex={Boolean(page?.noindex)}
|
|
108
|
+
versionedPageId={page?.id}
|
|
109
|
+
sections={sections}
|
|
110
|
+
>
|
|
46
111
|
{
|
|
47
112
|
page ? (
|
|
48
|
-
<main
|
|
113
|
+
<main>
|
|
114
|
+
{/* The page-builder sections, edited on-canvas. <Sections> resolves every
|
|
115
|
+
image's alt/caption from the media registry in ONE query for the whole
|
|
116
|
+
page, then hands each section its own path so `data-louise-sfield`
|
|
117
|
+
markers land at the right depth. */}
|
|
118
|
+
<Sections items={sections} db={env.DB} mediaBase={env.MEDIA_URL} />
|
|
119
|
+
|
|
120
|
+
<div class="mx-auto max-w-3xl px-6 py-16">
|
|
49
121
|
<Editable as="h1" collection="pages" key={page.id} field="title" class="text-5xl font-bold">
|
|
50
122
|
{title}
|
|
51
123
|
</Editable>
|
|
52
124
|
<Editable as="div" collection="pages" key={page.id} field="body" type="richtext" class="prose mt-6 max-w-none">
|
|
53
125
|
<Fragment set:html={body} />
|
|
54
126
|
</Editable>
|
|
127
|
+
</div>
|
|
55
128
|
</main>
|
|
56
129
|
) : (
|
|
57
130
|
<main>
|
|
@@ -3,12 +3,29 @@
|
|
|
3
3
|
// clicking it starts a session and lands on `/?louise` (edit mode on). The gate
|
|
4
4
|
// is enumeration-safe — the response is identical whether or not the email is an
|
|
5
5
|
// editor — so the UI always says "check your email".
|
|
6
|
+
import { env } from "cloudflare:workers";
|
|
7
|
+
import { turnstileSiteKey } from "louise-toolkit/auth";
|
|
6
8
|
import Site from "../layouts/Site.astro";
|
|
7
9
|
|
|
8
10
|
export const prerender = false;
|
|
11
|
+
|
|
12
|
+
// Turnstile, rendered ONLY when a real (non-test) site key is set.
|
|
13
|
+
//
|
|
14
|
+
// This has to match the server exactly. `getLouiseAuth` registers Better Auth's
|
|
15
|
+
// captcha plugin on /sign-in/magic-link the moment BOTH halves of the pair are
|
|
16
|
+
// real, and that plugin rejects any request without an `x-captcha-response`
|
|
17
|
+
// header. So a page that never rendered a widget meant provisioning Turnstile —
|
|
18
|
+
// which .env.example openly invites you to do — locked the owner out of their
|
|
19
|
+
// own sign-in, with a 403 and no clue why.
|
|
20
|
+
//
|
|
21
|
+
// `turnstileSiteKey` returns null for the always-passing TEST key, which is the
|
|
22
|
+
// same condition `activeCaptchaSecret` uses server-side. One shared rule, so the
|
|
23
|
+
// widget appears exactly when the server starts demanding a token.
|
|
24
|
+
const siteKey = turnstileSiteKey(env);
|
|
9
25
|
---
|
|
10
26
|
|
|
11
|
-
|
|
27
|
+
{/* noindex: an editor sign-in page has nothing for a search result to offer. */}
|
|
28
|
+
<Site title="Sign in" noindex>
|
|
12
29
|
<main class="hero min-h-screen">
|
|
13
30
|
<div class="hero-content w-full max-w-sm">
|
|
14
31
|
<form id="magic" class="card w-full bg-base-200 shadow-sm">
|
|
@@ -26,6 +43,13 @@ export const prerender = false;
|
|
|
26
43
|
class="input input-bordered w-full"
|
|
27
44
|
/>
|
|
28
45
|
</label>
|
|
46
|
+
{
|
|
47
|
+
siteKey && (
|
|
48
|
+
/* Turnstile injects a hidden `cf-turnstile-response` input here once
|
|
49
|
+
it solves. The submit handler reads it out of the form. */
|
|
50
|
+
<div class="cf-turnstile" data-sitekey={siteKey} data-size="flexible" />
|
|
51
|
+
)
|
|
52
|
+
}
|
|
29
53
|
<button type="submit" class="btn btn-primary">Email me a sign-in link</button>
|
|
30
54
|
<p id="msg" class="text-sm opacity-70" aria-live="polite"></p>
|
|
31
55
|
</div>
|
|
@@ -33,6 +57,20 @@ export const prerender = false;
|
|
|
33
57
|
</div>
|
|
34
58
|
</main>
|
|
35
59
|
|
|
60
|
+
{
|
|
61
|
+
/* Turnstile's widget script. `is:inline` because it's a remote src Astro must
|
|
62
|
+
not process; the CSP already allows challenges.cloudflare.com for script,
|
|
63
|
+
frame, and connect (astroidSecurity). Only loaded when there's a widget. */
|
|
64
|
+
siteKey && (
|
|
65
|
+
<script
|
|
66
|
+
is:inline
|
|
67
|
+
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
|
|
68
|
+
async
|
|
69
|
+
defer
|
|
70
|
+
/>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
36
74
|
{/* Processed (NOT is:inline) so Astro's security.csp hashes it into script-src.
|
|
37
75
|
Keep it dependency-free and static — an is:inline or define:vars script here
|
|
38
76
|
would be unhashable and CSP-blocked. */}
|
|
@@ -45,10 +83,22 @@ export const prerender = false;
|
|
|
45
83
|
const email = input?.value.trim();
|
|
46
84
|
if (!email || !msg) return;
|
|
47
85
|
msg.textContent = "Sending…";
|
|
86
|
+
// Turnstile writes its token into a hidden input it injects into the form.
|
|
87
|
+
// Better Auth's captcha plugin reads it from the `x-captcha-response`
|
|
88
|
+
// HEADER (not the body), and rejects the request outright when it's
|
|
89
|
+
// missing — so this header is what makes a captcha-enabled sign-in work.
|
|
90
|
+
// Absent when no widget rendered, in which case the plugin isn't active
|
|
91
|
+
// either and the header is simply not sent.
|
|
92
|
+
const token = (form as HTMLFormElement).querySelector<HTMLInputElement>(
|
|
93
|
+
'input[name="cf-turnstile-response"]',
|
|
94
|
+
)?.value;
|
|
48
95
|
try {
|
|
49
96
|
await fetch("/api/auth/sign-in/magic-link", {
|
|
50
97
|
method: "POST",
|
|
51
|
-
headers: {
|
|
98
|
+
headers: {
|
|
99
|
+
"content-type": "application/json",
|
|
100
|
+
...(token ? { "x-captcha-response": token } : {}),
|
|
101
|
+
},
|
|
52
102
|
body: JSON.stringify({ email, callbackURL: "/?louise" }),
|
|
53
103
|
});
|
|
54
104
|
msg.textContent = "If that email is an editor, a sign-in link is on its way.";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// robots.txt — origin-aware, with the disallow list derived from your Astroid
|
|
2
|
+
// config (the editor + its API always; portal and checkout routes when those
|
|
3
|
+
// modules are on). Scaffolded once and yours to edit: add a Disallow here when
|
|
4
|
+
// you add a route crawlers shouldn't reach.
|
|
5
|
+
import type { APIRoute } from "astro";
|
|
6
|
+
import { astroidRobotsTxt } from "astroidjs";
|
|
7
|
+
import { env } from "cloudflare:workers";
|
|
8
|
+
import astroidConfig from "../../astroid.config.js";
|
|
9
|
+
|
|
10
|
+
export const prerender = false;
|
|
11
|
+
|
|
12
|
+
export const GET: APIRoute = async (context) => {
|
|
13
|
+
// The origin actually serving this request — never a configured domain. A
|
|
14
|
+
// preview deploy that advertises the production host invites its content to
|
|
15
|
+
// be indexed under the real domain.
|
|
16
|
+
const origin = new URL(context.request.url).origin;
|
|
17
|
+
|
|
18
|
+
// The same site-wide switch that noindexes pages also stops the crawl, rather
|
|
19
|
+
// than relying on crawlers fetching every page to find the meta tag.
|
|
20
|
+
let disableIndexing = false;
|
|
21
|
+
try {
|
|
22
|
+
const row = await env.DB.prepare("SELECT disable_indexing FROM site_settings WHERE id = 1").first<{
|
|
23
|
+
disable_indexing: number;
|
|
24
|
+
}>();
|
|
25
|
+
disableIndexing = Boolean(row?.disable_indexing);
|
|
26
|
+
} catch {
|
|
27
|
+
// No DB binding yet (pre-provision) — fall through to the crawlable default.
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return new Response(astroidRobotsTxt(astroidConfig, { origin, disableIndexing }), {
|
|
31
|
+
headers: {
|
|
32
|
+
"content-type": "text/plain; charset=utf-8",
|
|
33
|
+
"cache-control": "public, max-age=3600",
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// sitemap.xml — the published `pages` rows, plus whatever else this site serves.
|
|
2
|
+
// Scaffolded once and yours to edit: add your own routes (a product catalog, a
|
|
3
|
+
// gallery) to the `entries` array below.
|
|
4
|
+
//
|
|
5
|
+
// `astroidSitemapXml` drops anything matching the config's noindex prefixes, so
|
|
6
|
+
// this file and robots.txt can never disagree about what's crawlable.
|
|
7
|
+
import type { APIRoute } from "astro";
|
|
8
|
+
import { astroidSitemapXml, type SitemapEntry } from "astroidjs";
|
|
9
|
+
import { env } from "cloudflare:workers";
|
|
10
|
+
import astroidConfig from "../../astroid.config.js";
|
|
11
|
+
|
|
12
|
+
export const prerender = false;
|
|
13
|
+
|
|
14
|
+
export const GET: APIRoute = async (context) => {
|
|
15
|
+
const origin = new URL(context.request.url).origin;
|
|
16
|
+
const entries: SitemapEntry[] = [{ path: "/" }];
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const { results } = await env.DB.prepare(
|
|
20
|
+
"SELECT slug, updated_at FROM pages WHERE status = 'published' AND noindex = 0",
|
|
21
|
+
).all<{ slug: string; updated_at: number | null }>();
|
|
22
|
+
for (const row of results ?? []) {
|
|
23
|
+
// `home` is served at "/", which is already listed — never at "/home".
|
|
24
|
+
if (row.slug === "home") continue;
|
|
25
|
+
// Drizzle stores `updated_at` as a Unix timestamp in SECONDS; <lastmod>
|
|
26
|
+
// wants a W3C datetime, so a raw epoch number would be invalid.
|
|
27
|
+
const lastmod = row.updated_at ? new Date(row.updated_at * 1000) : undefined;
|
|
28
|
+
entries.push({ path: `/${row.slug}`, lastmod });
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// No DB binding yet (pre-provision) — ship the root-only sitemap.
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return new Response(astroidSitemapXml(astroidConfig, entries, { origin }), {
|
|
35
|
+
headers: {
|
|
36
|
+
"content-type": "application/xml; charset=utf-8",
|
|
37
|
+
"cache-control": "public, max-age=3600",
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
};
|
|
@@ -5,6 +5,15 @@
|
|
|
5
5
|
@plugin "daisyui";
|
|
6
6
|
@import "louise-toolkit/theme/louise.css";
|
|
7
7
|
|
|
8
|
+
/* Astroid's section components (<Hero>, the section library, etc.) ship as SOURCE
|
|
9
|
+
inside node_modules, and Tailwind v4 does not scan node_modules by default. So
|
|
10
|
+
the utilities used ONLY in those files — bg-primary, text-primary-content,
|
|
11
|
+
items-center, and every other colorway/alignment class — would never be
|
|
12
|
+
generated, and the whole `_settings` token contract would silently no-op: a
|
|
13
|
+
section set to `colorway: "brand"` would render on the default background.
|
|
14
|
+
This tells Tailwind to scan them too. Keep it if you use any astroidjs section. */
|
|
15
|
+
@source "../../node_modules/astroidjs/src/components";
|
|
16
|
+
|
|
8
17
|
:root {
|
|
9
18
|
--brand: __BRAND_COLOR__;
|
|
10
19
|
}
|