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.
- package/README.md +82 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +97 -0
- package/dist/cli.js.map +1 -0
- package/dist/copy-template.d.ts +12 -0
- package/dist/copy-template.d.ts.map +1 -0
- package/dist/copy-template.js +36 -0
- package/dist/copy-template.js.map +1 -0
- package/dist/files.d.ts +55 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +373 -0
- package/dist/files.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/options.d.ts +78 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +235 -0
- package/dist/options.js.map +1 -0
- package/dist/prompts.d.ts +24 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +66 -0
- package/dist/prompts.js.map +1 -0
- package/dist/scaffold.d.ts +49 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +178 -0
- package/dist/scaffold.js.map +1 -0
- package/package.json +58 -0
- package/template/.env.example +15 -0
- package/template/README.md +202 -0
- package/template/astro.config.mjs +23 -0
- package/template/package.json +29 -0
- package/template/public/favicon.svg +5 -0
- package/template/src/components/AnswerBlock.astro +31 -0
- package/template/src/components/JsonLd.astro +8 -0
- package/template/src/components/PostCard.astro +24 -0
- package/template/src/components/PostCta.astro +61 -0
- package/template/src/components/ResourceCard.astro +40 -0
- package/template/src/components/SiteFooter.astro +19 -0
- package/template/src/components/SiteHead.astro +36 -0
- package/template/src/components/SiteHeader.astro +34 -0
- package/template/src/layouts/BaseLayout.astro +61 -0
- package/template/src/layouts/PostLayout.astro +128 -0
- package/template/src/lib/data.ts +217 -0
- package/template/src/lib/fixture-backend.ts +84 -0
- package/template/src/lib/fixtures.ts +307 -0
- package/template/src/lib/markdown.ts +66 -0
- package/template/src/lib/seo.ts +191 -0
- package/template/src/lib/types.ts +180 -0
- package/template/src/lib/we8-backend.ts +254 -0
- package/template/src/pages/about.astro +96 -0
- package/template/src/pages/authors/[slug].astro +113 -0
- package/template/src/pages/blog/[slug].astro +28 -0
- package/template/src/pages/blog/index.astro +75 -0
- package/template/src/pages/contact.astro +106 -0
- package/template/src/pages/index.astro +106 -0
- package/template/src/pages/llms.txt.ts +64 -0
- package/template/src/pages/resources/[slug].astro +25 -0
- package/template/src/pages/resources.astro +95 -0
- package/template/src/pages/robots.txt.ts +18 -0
- package/template/src/pages/sitemap.xml.ts +22 -0
- package/template/src/styles/global.css +449 -0
- package/template/test/data-layer.test.ts +312 -0
- package/template/test/seo.test.ts +177 -0
- package/template/tsconfig.json +11 -0
- 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,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> · {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> · {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> · <a href="/sitemap.xml">Sitemap</a> · 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>
|