create-we8 0.1.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.
Files changed (68) hide show
  1. package/README.md +82 -0
  2. package/dist/cli.d.ts +8 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +97 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/copy-template.d.ts +12 -0
  7. package/dist/copy-template.d.ts.map +1 -0
  8. package/dist/copy-template.js +36 -0
  9. package/dist/copy-template.js.map +1 -0
  10. package/dist/files.d.ts +55 -0
  11. package/dist/files.d.ts.map +1 -0
  12. package/dist/files.js +373 -0
  13. package/dist/files.js.map +1 -0
  14. package/dist/index.d.ts +17 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +17 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/options.d.ts +78 -0
  19. package/dist/options.d.ts.map +1 -0
  20. package/dist/options.js +235 -0
  21. package/dist/options.js.map +1 -0
  22. package/dist/prompts.d.ts +24 -0
  23. package/dist/prompts.d.ts.map +1 -0
  24. package/dist/prompts.js +66 -0
  25. package/dist/prompts.js.map +1 -0
  26. package/dist/scaffold.d.ts +49 -0
  27. package/dist/scaffold.d.ts.map +1 -0
  28. package/dist/scaffold.js +178 -0
  29. package/dist/scaffold.js.map +1 -0
  30. package/package.json +58 -0
  31. package/template/.env.example +15 -0
  32. package/template/README.md +202 -0
  33. package/template/astro.config.mjs +23 -0
  34. package/template/package.json +29 -0
  35. package/template/public/favicon.svg +5 -0
  36. package/template/src/components/AnswerBlock.astro +31 -0
  37. package/template/src/components/JsonLd.astro +8 -0
  38. package/template/src/components/PostCard.astro +24 -0
  39. package/template/src/components/PostCta.astro +61 -0
  40. package/template/src/components/ResourceCard.astro +40 -0
  41. package/template/src/components/SiteFooter.astro +19 -0
  42. package/template/src/components/SiteHead.astro +36 -0
  43. package/template/src/components/SiteHeader.astro +34 -0
  44. package/template/src/layouts/BaseLayout.astro +61 -0
  45. package/template/src/layouts/PostLayout.astro +128 -0
  46. package/template/src/lib/data.ts +217 -0
  47. package/template/src/lib/fixture-backend.ts +84 -0
  48. package/template/src/lib/fixtures.ts +307 -0
  49. package/template/src/lib/markdown.ts +66 -0
  50. package/template/src/lib/seo.ts +191 -0
  51. package/template/src/lib/types.ts +180 -0
  52. package/template/src/lib/we8-backend.ts +254 -0
  53. package/template/src/pages/about.astro +96 -0
  54. package/template/src/pages/authors/[slug].astro +113 -0
  55. package/template/src/pages/blog/[slug].astro +28 -0
  56. package/template/src/pages/blog/index.astro +75 -0
  57. package/template/src/pages/contact.astro +106 -0
  58. package/template/src/pages/index.astro +106 -0
  59. package/template/src/pages/llms.txt.ts +64 -0
  60. package/template/src/pages/resources/[slug].astro +25 -0
  61. package/template/src/pages/resources.astro +95 -0
  62. package/template/src/pages/robots.txt.ts +18 -0
  63. package/template/src/pages/sitemap.xml.ts +22 -0
  64. package/template/src/styles/global.css +449 -0
  65. package/template/test/data-layer.test.ts +312 -0
  66. package/template/test/seo.test.ts +177 -0
  67. package/template/tsconfig.json +11 -0
  68. package/template/vitest.config.ts +18 -0
@@ -0,0 +1,15 @@
1
+ # The we8 backend. Set both and the site builds from your CMS; leave either
2
+ # unset and it builds from the fixtures in src/lib/fixtures.ts.
3
+ #
4
+ # WE8_API_URL is your CMS's origin with NO path on the end: a self-hosted we8
5
+ # CMS serves /v1 at its own root, so a trailing /api produces 404s. Locally
6
+ # that is http://localhost:8787.
7
+ WE8_API_URL=https://cms.example.com
8
+ WE8_PUBLISHABLE_KEY=pk_replace_me
9
+
10
+ # Force the fixture backend even with a live API configured. Useful for an
11
+ # offline build, a screenshot, or a reproducible CI run.
12
+ # WE8_DATA_SOURCE=fixtures
13
+
14
+ # Your public origin. Drives canonical URLs and the sitemap.
15
+ SITE=https://northgatetools.example
@@ -0,0 +1,202 @@
1
+ # we8-template-astro
2
+
3
+ The we8 Astro starter: a small marketing site with a blog, content pages, a
4
+ resources shelf, a contact form, consent, and SEO and AEO built in. It is wired
5
+ to a we8 CMS by default and it builds with no backend at all, from fixtures.
6
+
7
+ It is not published to npm. `create-we8` copies it into your project, and from
8
+ that moment it is your code: edit it, delete what you do not need, and do not
9
+ expect an upgrade path.
10
+
11
+ ```sh
12
+ npm install
13
+ npm run dev # http://localhost:4321, fixture content, no backend needed
14
+ ```
15
+
16
+ Point it at a CMS by copying `.env.example` to `.env` and filling in two
17
+ values:
18
+
19
+ ```sh
20
+ WE8_API_URL=https://cms.example.com
21
+ WE8_PUBLISHABLE_KEY=pk_...
22
+ ```
23
+
24
+ That is the whole configuration. `WE8_API_URL` is the CMS origin with no path
25
+ on the end, because a self-hosted we8 CMS serves `/v1` at its own root; locally
26
+ that is `http://localhost:8787`. The publishable key is meant to ship in a
27
+ frontend; never put a secret (`sk_`) key here.
28
+
29
+ ## What you get
30
+
31
+ | Route | What it is |
32
+ | ------------------ | ------------------------------------------------------------------------- |
33
+ | `/` | Home: hero, answer blocks, recent writing, recent resources |
34
+ | `/about` | A content page with question-phrased headings and the team roster |
35
+ | `/blog` | The editorial listing |
36
+ | `/blog/[slug]` | A post: answer, byline, table of contents, body, CTA |
37
+ | `/resources` | Toolkits, case studies, and research, grouped |
38
+ | `/resources/[slug]`| The same post page, in the resources section |
39
+ | `/authors/[slug]` | An author, their bio, and everything they wrote |
40
+ | `/contact` | The progressive-enhancement contact form |
41
+ | `/robots.txt` | Honors the site's indexing preference, points at the sitemap and llms.txt |
42
+ | `/sitemap.xml` | One sitemap: site pages, posts, authors, plus anything the CMS adds |
43
+ | `/llms.txt` | The CMS document if there is one, otherwise generated from real content |
44
+
45
+ ## The data-layer contract
46
+
47
+ This template is backend-agnostic by construction, not by intention.
48
+
49
+ ```
50
+ src/lib/types.ts the contract: Post, Author, SiteIdentity, SiteBackend
51
+ src/lib/we8-backend.ts the we8 adapter <- the ONLY file that imports we8
52
+ src/lib/fixture-backend.ts the static adapter
53
+ src/lib/fixtures.ts the invented content it serves
54
+ src/lib/data.ts picks one, and is the ONLY module pages import data from
55
+ ```
56
+
57
+ Four rules, and the tests enforce the ones that can be enforced:
58
+
59
+ 1. **Pages never import a backend.** Every page and component imports from
60
+ `src/lib/data.js`. Nothing outside `src/lib/` knows which backend is in use.
61
+ 2. **`we8-backend.ts` is the only file that imports `@we8/astro` or
62
+ `@we8/client`.** `data.ts` does not, `types.ts` does not, no page does. The
63
+ one exception is UI: `<ConsentBanner>`, `<VisitBeacon>`, and `<We8Form>` are
64
+ imported by `BaseLayout.astro`, `contact.astro`, and `PostCta.astro`. They
65
+ are components, not data, and each is a one-line swap.
66
+ 3. **The contract owns its own vocabulary.** `Post`, `Author`, and
67
+ `SiteIdentity` are this site's types. They are not re-exported we8 types,
68
+ which is why a second backend owes we8 nothing.
69
+ 4. **Two implementations, one test suite.** `test/data-layer.test.ts` runs the
70
+ same contract assertions against both backends with `describe.each`. Adding
71
+ a third backend means adding one line to that array.
72
+
73
+ To swap backends, write a `SiteBackend` and return it from `selectBackend` in
74
+ `data.ts`. Nothing else changes.
75
+
76
+ ### Which backend a build uses
77
+
78
+ | Environment | Backend | Browser features |
79
+ | ---------------------------------------------------- | -------- | ----------------------- |
80
+ | `WE8_API_URL` and `WE8_PUBLISHABLE_KEY` both set | we8 | form, consent, beacon |
81
+ | Either missing | fixtures | none rendered |
82
+ | `WE8_DATA_SOURCE=fixtures` | fixtures | none rendered |
83
+
84
+ The fallback is deliberate: a template that cannot build without a backend is
85
+ not backend-agnostic. `WE8_DATA_SOURCE=fixtures` forces the static path even
86
+ with a live API configured, which is what makes an offline or CI build
87
+ reproducible.
88
+
89
+ When no backend is live, the contact form, the consent banner, and the visit
90
+ beacon are not rendered at all. A form that silently discards a message is
91
+ worse than a page that tells you to send an email.
92
+
93
+ ## SEO and AEO
94
+
95
+ Search engines want a page they can index. Answer engines want a paragraph they
96
+ can quote. This template is built for the second, which turns out to satisfy
97
+ the first.
98
+
99
+ **Per page.** `resolveSeo` in `src/lib/seo.ts` merges a page's own declarations
100
+ over the site identity, page first. Title, description, canonical, Open Graph,
101
+ Twitter, and the robots directive all come from one resolved object rendered by
102
+ `SiteHead.astro`.
103
+
104
+ **Structured data.** `Organization` and `WebSite` on every page;
105
+ `BlogPosting`/`NewsArticle`/`Article` with a linked byline on posts;
106
+ `BreadcrumbList` on inner pages; `Person` with a work list on author pages;
107
+ `ItemList` on resources; `FAQPage` where there are real questions and answers.
108
+
109
+ **Answer-first content.** Every post carries an answer block: the question a
110
+ person would type, and an answer short enough to lift out whole. It renders
111
+ directly under the title, before any preamble, and it is what the listing cards
112
+ and `llms.txt` quote. Headings are questions, so the table of contents doubles
113
+ as an outline a crawler can cite by anchor.
114
+
115
+ **The honesty rule.** An answer block is `authored` when an editor wrote it and
116
+ `derived` when the site built one from the title and the excerpt. Derived blocks
117
+ render, because a summary helps a reader either way. Only authored blocks become
118
+ `FAQPage` markup: presenting a paraphrased excerpt to a crawler as a curated
119
+ answer is a claim the content does not support.
120
+
121
+ **llms.txt.** Served from the CMS document when there is one, otherwise
122
+ generated from real URLs and real answer summaries. A generated file that is
123
+ true beats a hand-written one that has gone stale.
124
+
125
+ ### What the CMS needs to send
126
+
127
+ The template reads two flat, optional AEO fields off each post, exactly as
128
+ `@we8/cms` ships them:
129
+
130
+ ```jsonc
131
+ {
132
+ "slug": "how-do-berth-windows-actually-work",
133
+ "title": "How do berth windows actually work?",
134
+ "answerSummary": "A berth window reserves a quay slot for an arriving vessel...",
135
+ "questionHeading": "How do berth windows work?" // optional, falls back to the title
136
+ }
137
+ ```
138
+
139
+ `answerSummary` is editor-authored in the CMS, which is why the template
140
+ treats it as an authored answer and emits `FAQPage` markup for it. When the
141
+ fields are absent (an older backend) the template derives a block instead.
142
+
143
+ ## Editing the site
144
+
145
+ - **Your copy** lives in `src/pages/index.astro` (the hero and its answer
146
+ blocks) and `src/pages/about.astro`. These are yours to rewrite; they are not
147
+ fetched from the CMS.
148
+ - **Your content** lives in the CMS: posts, authors, and the CTA on each post.
149
+ - **Your fixtures** live in `src/lib/fixtures.ts`. Replace or delete them once
150
+ you have a real backend.
151
+ - **Your routes** are the `SECTIONS` map in `src/lib/data.ts`. Change a value
152
+ and rename the matching folder under `src/pages` together. The CMS's own
153
+ `contentPaths` are advisory here; this template will not link to a page it
154
+ does not render.
155
+ - **Your styling** is one plain CSS file, `src/styles/global.css`: tokens, then
156
+ elements, then a handful of components, with a dark scheme via
157
+ `prefers-color-scheme`. No framework, no build step. Replacing it with
158
+ something else touches no other file.
159
+
160
+ ## The contact form
161
+
162
+ `<We8Form>` renders a real `<form>` and upgrades it with a submit handler. The
163
+ markup is labelled and accessible without JS; the fallback for a visitor with
164
+ JS off is the mailto address on the page, not a promise the form cannot keep.
165
+
166
+ The form posts to the CMS form with the key `contact`. Create one with that key
167
+ in your admin, or change `FORM_KEY` in `src/pages/contact.astro`. The hidden
168
+ `website` field is a honeypot: bots fill it, the API drops those submissions.
169
+
170
+ ## Consent and analytics
171
+
172
+ The banner asks once. The visit beacon does not fire until analytics consent
173
+ exists, and it fires the moment it does, without a reload. A decline is a
174
+ decision: the banner does not come back, and the site behaves identically.
175
+
176
+ Both come from `@we8/astro`. The banner ships markup only; its styling lives in
177
+ `global.css` under the `[data-we8-consent]` selectors.
178
+
179
+ ## Scripts
180
+
181
+ | Command | What it does |
182
+ | ------------------- | --------------------------------------- |
183
+ | `npm run dev` | Dev server |
184
+ | `npm run build` | Static build into `dist/` |
185
+ | `npm run preview` | Serve the built site |
186
+ | `npm run typecheck` | `astro check` |
187
+ | `npm run test` | The data-layer and SEO tests |
188
+
189
+ ## Dependencies
190
+
191
+ `astro`, `@we8/astro`, and `marked`. That is the whole list. `marked` renders
192
+ post bodies and has no dependencies of its own. Post content comes from your own
193
+ CMS and is treated as trusted; if you ever accept markdown from anyone else,
194
+ sanitize it before rendering.
195
+
196
+ ## More
197
+
198
+ The we8 package documentation covers the rest. `quick-start.md` has the
199
+ full-stack path from an empty directory to this site built against a live CMS,
200
+ `frontend.md` covers the client, the integration, and this data-layer contract,
201
+ and `api.md` is the `/v1` contract the we8 adapter speaks. Your own CMS also
202
+ serves the machine-readable version at `GET /v1/openapi.json`.
@@ -0,0 +1,23 @@
1
+ // @ts-check
2
+ import { defineConfig } from 'astro/config';
3
+ import { we8 } from '@we8/astro';
4
+
5
+ // SITE is your public origin. It drives canonical URLs and the sitemap, and it
6
+ // is separate from the CMS's own siteUrl: you can point this template at a
7
+ // staging origin without touching the CMS.
8
+ const site = process.env.SITE ?? 'https://northgatetools.example';
9
+
10
+ export default defineConfig({
11
+ site,
12
+ // `optional: true` keeps a no-backend build quiet. This template is designed
13
+ // to build from fixtures when we8 is not configured, so a missing key is a
14
+ // choice, not a misconfiguration.
15
+ integrations: [we8({ optional: true })],
16
+ vite: {
17
+ // Astro exposes only PUBLIC_-prefixed variables on import.meta.env. Both
18
+ // we8 variables are public by design (a publishable key is meant to ship,
19
+ // and the API URL is not a secret), so widening the prefix means one
20
+ // variable serves the build and the browser instead of two.
21
+ envPrefix: ['PUBLIC_', 'WE8_'],
22
+ },
23
+ });
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "we8-template-astro",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "private": true,
7
+ "description": "The we8 Astro starter template: blog, content pages, resources, consent, forms, SEO and AEO, wired to the CMS by default and buildable from fixtures with no backend at all.",
8
+ "scripts": {
9
+ "dev": "astro dev",
10
+ "start": "astro dev",
11
+ "build": "astro build",
12
+ "preview": "astro preview",
13
+ "typecheck": "astro check",
14
+ "test": "vitest run"
15
+ },
16
+ "dependencies": {
17
+ "@we8/astro": "^0.1.0",
18
+ "astro": "^7.2.7",
19
+ "marked": "^15.0.0"
20
+ },
21
+ "devDependencies": {
22
+ "@astrojs/check": "^0.9.4",
23
+ "typescript": "^5.7.3",
24
+ "vitest": "^4.1.9"
25
+ },
26
+ "engines": {
27
+ "node": ">=22.12.0"
28
+ }
29
+ }
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Site icon">
2
+ <rect width="32" height="32" rx="7" fill="#1f5c4c" />
3
+ <path d="M8 21.5 13 10h2.4l5 11.5h-2.6l-1-2.5h-5.2l-1 2.5H8Zm4.5-4.6h3.6l-1.8-4.4-1.8 4.4Z" fill="#ffffff" />
4
+ <circle cx="23" cy="11" r="2.2" fill="#7fc9b2" />
5
+ </svg>
@@ -0,0 +1,31 @@
1
+ ---
2
+ /**
3
+ * The answer-first block: the question this page answers, and an answer short
4
+ * enough to be quoted whole.
5
+ *
6
+ * This is the visible half of AEO. It sits directly under the heading, before
7
+ * any preamble, so a reader gets the answer in one glance and an answer engine
8
+ * finds a self-contained paragraph next to the question it matches.
9
+ *
10
+ * A `derived` block is the site paraphrasing its own excerpt because no editor
11
+ * wrote an answer yet. It still helps the reader, so it renders; it is simply
12
+ * not offered to crawlers as FAQ markup (see `faqJsonLd`).
13
+ */
14
+ import type { AnswerBlock } from '../lib/data.js';
15
+
16
+ interface Props {
17
+ answer: AnswerBlock | null;
18
+ /** Render the question as a heading. Off on a post page, where the title is already the question. */
19
+ showQuestion?: boolean;
20
+ }
21
+ const { answer, showQuestion = true } = Astro.props;
22
+ ---
23
+
24
+ {
25
+ answer && (
26
+ <div class="answer">
27
+ {showQuestion && <h2>{answer.question}</h2>}
28
+ <p>{answer.summary}</p>
29
+ </div>
30
+ )
31
+ }
@@ -0,0 +1,8 @@
1
+ ---
2
+ /** One JSON-LD graph node. Pass `null` to render nothing. */
3
+ interface Props {
4
+ data: Record<string, unknown> | null;
5
+ }
6
+ const { data } = Astro.props;
7
+ ---
8
+ {data && <script is:inline type="application/ld+json" set:html={JSON.stringify(data)} />}
@@ -0,0 +1,24 @@
1
+ ---
2
+ /** One post in a listing. Answer-first: the summary is the card's body. */
3
+ import { answerFor, postPath, type Post } from '../lib/data.js';
4
+
5
+ interface Props {
6
+ post: Post;
7
+ }
8
+ const { post } = Astro.props;
9
+ const href = postPath(post);
10
+ const answer = answerFor(post);
11
+ const published = new Date(post.publishedAt);
12
+ ---
13
+
14
+ <li class="card">
15
+ {post.category && <span class="tag">{post.category}</span>}
16
+ <h2><a href={href}>{post.title}</a></h2>
17
+ {answer && <p>{answer.summary}</p>}
18
+ <p class="meta">
19
+ <time datetime={post.publishedAt}>
20
+ {published.toLocaleDateString('en', { year: 'numeric', month: 'long', day: 'numeric' })}
21
+ </time>
22
+ {post.readingMinutes && <span> &middot; {post.readingMinutes} min read</span>}
23
+ </p>
24
+ </li>
@@ -0,0 +1,61 @@
1
+ ---
2
+ /**
3
+ * The call to action attached to a post.
4
+ *
5
+ * A `link` CTA is a button. A `gated-download` CTA asks for an email through
6
+ * the site's form and then reveals the asset, which needs a live backend: with
7
+ * no backend configured the asset link is offered directly instead of putting
8
+ * a form in front of a submission that would go nowhere.
9
+ */
10
+ import We8Form from '@we8/astro/We8Form.astro';
11
+ import type { CallToAction, RuntimeConfig } from '../lib/data.js';
12
+
13
+ interface Props {
14
+ cta: CallToAction | null;
15
+ runtime: RuntimeConfig;
16
+ }
17
+ const { cta, runtime } = Astro.props;
18
+ const gated = cta?.style === 'gated-download' && cta.formKey !== null;
19
+ const live = runtime.enabled && runtime.key !== null;
20
+ ---
21
+
22
+ {
23
+ cta && (
24
+ <section class="answer" aria-label="Next step">
25
+ {cta.heading && <h2>{cta.heading}</h2>}
26
+
27
+ {cta.style === 'link' && cta.url && (
28
+ <p>
29
+ <a class="button" href={cta.url}>
30
+ {cta.buttonLabel}
31
+ </a>
32
+ </p>
33
+ )}
34
+
35
+ {gated && live && (
36
+ <We8Form
37
+ formKey={cta.formKey!}
38
+ key={runtime.key ?? undefined}
39
+ apiUrl={runtime.apiUrl ?? undefined}
40
+ successMessage="Check your inbox. The download link is on its way."
41
+ >
42
+ <div class="field">
43
+ <label for="cta-email">Work email</label>
44
+ <input id="cta-email" name="email" type="email" required autocomplete="email" />
45
+ </div>
46
+ <input type="hidden" name="asset" value={cta.assetUrl ?? ''} />
47
+ <button type="submit">{cta.buttonLabel}</button>
48
+ </We8Form>
49
+ )}
50
+
51
+ {gated && !live && cta.assetUrl && (
52
+ <p>
53
+ <a class="button" href={cta.assetUrl}>
54
+ {cta.buttonLabel}
55
+ </a>
56
+ <span class="meta"> (ungated: this build has no backend to collect an email)</span>
57
+ </p>
58
+ )}
59
+ </section>
60
+ )
61
+ }
@@ -0,0 +1,40 @@
1
+ ---
2
+ /**
3
+ * One resource: a whitepaper, case study, or research note, with its download
4
+ * or link CTA surfaced on the card so the page reads as a shelf of things you
5
+ * can take away rather than another article list.
6
+ */
7
+ import { answerFor, postPath, type Post } from '../lib/data.js';
8
+
9
+ interface Props {
10
+ post: Post;
11
+ }
12
+ const { post } = Astro.props;
13
+ const href = postPath(post);
14
+ const answer = answerFor(post);
15
+
16
+ const LABELS: Record<string, string> = {
17
+ whitepaper: 'Toolkit',
18
+ 'case-study': 'Case study',
19
+ research: 'Research',
20
+ };
21
+ const label = LABELS[post.type] ?? 'Resource';
22
+ const gated = post.cta?.style === 'gated-download';
23
+ ---
24
+
25
+ <li class="card">
26
+ <span class="tag">{label}</span>
27
+ <h2><a href={href}>{post.title}</a></h2>
28
+ {answer && <p>{answer.summary}</p>}
29
+ <p class="meta">
30
+ {
31
+ post.cta
32
+ ? gated
33
+ ? 'Free download, email required'
34
+ : 'Free to read'
35
+ : 'Free to read'
36
+ }
37
+ {post.readingMinutes && <span> &middot; {post.readingMinutes} min read</span>}
38
+ </p>
39
+ <p><a class="button secondary" href={href}>{post.cta?.buttonLabel ?? 'Read it'}</a></p>
40
+ </li>
@@ -0,0 +1,19 @@
1
+ ---
2
+ interface Props {
3
+ siteName: string;
4
+ /** Which data source this build read from. Handy while you are wiring a backend. */
5
+ source: string;
6
+ }
7
+ const { siteName, source } = Astro.props;
8
+ const year = new Date().getFullYear();
9
+ ---
10
+
11
+ <footer class="site-footer">
12
+ <div class="shell">
13
+ <p>{year} {siteName}. Built with we8.</p>
14
+ <p>
15
+ <a href="/llms.txt">llms.txt</a> &middot; <a href="/sitemap.xml">Sitemap</a> &middot; content
16
+ source: {source}
17
+ </p>
18
+ </div>
19
+ </footer>
@@ -0,0 +1,36 @@
1
+ ---
2
+ /**
3
+ * Everything that goes in <head> for one page: the resolved title and
4
+ * description, canonical, Open Graph, Twitter, and the robots directive.
5
+ *
6
+ * The values come from `resolveSeo`, which merges the page's own declarations
7
+ * over the site identity. This component never reads the backend.
8
+ */
9
+ import type { ResolvedSeo } from '../lib/seo.js';
10
+
11
+ interface Props {
12
+ seo: ResolvedSeo;
13
+ siteName: string;
14
+ }
15
+ const { seo, siteName } = Astro.props;
16
+ ---
17
+
18
+ <meta charset="utf-8" />
19
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
20
+ <title>{seo.title}</title>
21
+ {seo.description && <meta name="description" content={seo.description} />}
22
+ {seo.canonical && <link rel="canonical" href={seo.canonical} />}
23
+ {seo.noindex && <meta name="robots" content="noindex, nofollow" />}
24
+
25
+ <meta property="og:type" content="website" />
26
+ <meta property="og:site_name" content={siteName} />
27
+ <meta property="og:title" content={seo.ogTitle} />
28
+ {seo.ogDescription && <meta property="og:description" content={seo.ogDescription} />}
29
+ {seo.ogImageUrl && <meta property="og:image" content={seo.ogImageUrl} />}
30
+ {seo.canonical && <meta property="og:url" content={seo.canonical} />}
31
+
32
+ <meta name="twitter:card" content={seo.ogImageUrl ? 'summary_large_image' : 'summary'} />
33
+ {seo.twitterHandle && <meta name="twitter:site" content={seo.twitterHandle} />}
34
+
35
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
36
+ <link rel="alternate" type="text/plain" href="/llms.txt" title="Site summary for answer engines" />
@@ -0,0 +1,34 @@
1
+ ---
2
+ interface Props {
3
+ siteName: string;
4
+ path: string;
5
+ }
6
+ const { siteName, path } = Astro.props;
7
+
8
+ const links = [
9
+ { href: '/', label: 'Home' },
10
+ { href: '/blog', label: 'Blog' },
11
+ { href: '/resources', label: 'Resources' },
12
+ { href: '/about', label: 'About' },
13
+ { href: '/contact', label: 'Contact' },
14
+ ];
15
+
16
+ function isCurrent(href: string): boolean {
17
+ return href === '/' ? path === '/' : path === href || path.startsWith(`${href}/`);
18
+ }
19
+ ---
20
+
21
+ <header class="site-header">
22
+ <div class="shell">
23
+ <a class="wordmark" href="/">{siteName}</a>
24
+ <nav class="site-nav" aria-label="Main">
25
+ {
26
+ links.map((link) => (
27
+ <a href={link.href} aria-current={isCurrent(link.href) ? 'page' : undefined}>
28
+ {link.label}
29
+ </a>
30
+ ))
31
+ }
32
+ </nav>
33
+ </div>
34
+ </header>
@@ -0,0 +1,61 @@
1
+ ---
2
+ /**
3
+ * The one layout. Every page renders through it.
4
+ *
5
+ * It takes the site identity and a `PageSeo` declaration, resolves the head,
6
+ * and wires the two browser-side pieces: the consent banner and the
7
+ * consent-gated visit beacon. Both come from `@we8/astro` and both are skipped
8
+ * entirely when `runtime.enabled` is false, so a fixtures build ships no dead
9
+ * analytics markup.
10
+ *
11
+ * A page may add its own JSON-LD through the `jsonLd` slot; the site-level
12
+ * Organization and WebSite nodes are emitted here so every page carries them.
13
+ */
14
+ import ConsentBanner from '@we8/astro/ConsentBanner.astro';
15
+ import VisitBeacon from '@we8/astro/VisitBeacon.astro';
16
+ import JsonLd from '../components/JsonLd.astro';
17
+ import SiteFooter from '../components/SiteFooter.astro';
18
+ import SiteHead from '../components/SiteHead.astro';
19
+ import SiteHeader from '../components/SiteHeader.astro';
20
+ import { backend, runtime, type SiteIdentity } from '../lib/data.js';
21
+ import { organizationJsonLd, resolveSeo, websiteJsonLd, type PageSeo } from '../lib/seo.js';
22
+ import '../styles/global.css';
23
+
24
+ interface Props {
25
+ identity: SiteIdentity;
26
+ seo: PageSeo;
27
+ }
28
+ const { identity, seo: pageSeo } = Astro.props;
29
+ const seo = resolveSeo({ path: Astro.url.pathname, ...pageSeo }, identity);
30
+ ---
31
+
32
+ <!doctype html>
33
+ <html lang="en">
34
+ <head>
35
+ <SiteHead seo={seo} siteName={identity.name} />
36
+ <JsonLd data={organizationJsonLd(identity)} />
37
+ <JsonLd data={websiteJsonLd(identity)} />
38
+ <slot name="jsonLd" />
39
+ </head>
40
+ <body>
41
+ <a class="skip-link" href="#main">Skip to content</a>
42
+ <SiteHeader siteName={identity.name} path={Astro.url.pathname} />
43
+
44
+ <main id="main">
45
+ <div class="shell">
46
+ <slot />
47
+ </div>
48
+ </main>
49
+
50
+ <SiteFooter siteName={identity.name} source={backend.name} />
51
+
52
+ {
53
+ runtime.enabled && (
54
+ <>
55
+ <ConsentBanner key={runtime.key ?? undefined} apiUrl={runtime.apiUrl ?? undefined} />
56
+ <VisitBeacon key={runtime.key ?? undefined} apiUrl={runtime.apiUrl ?? undefined} />
57
+ </>
58
+ )
59
+ }
60
+ </body>
61
+ </html>