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,180 @@
1
+ /**
2
+ * The data-layer contract.
3
+ *
4
+ * These types are the site's OWN vocabulary. Nothing here imports we8, and
5
+ * nothing here is shaped by any particular API: that is the whole point. A
6
+ * page renders a `Post`, not a we8 post, so replacing the backend is a matter
7
+ * of writing one more `SiteBackend` and pointing `data.ts` at it.
8
+ *
9
+ * See the README section "The data-layer contract" for the rules.
10
+ */
11
+
12
+ /**
13
+ * The kinds of content this site publishes. Blog and article are editorial;
14
+ * research, whitepaper, and case study are the long-form material the
15
+ * resources page lays out. The list matches the we8 CMS vocabulary so the
16
+ * default backend needs no translation table, but it belongs to the site: a
17
+ * different backend maps its own types onto these.
18
+ */
19
+ export const CONTENT_TYPES = [
20
+ 'blog',
21
+ 'article',
22
+ 'news',
23
+ 'research',
24
+ 'whitepaper',
25
+ 'case-study',
26
+ ] as const;
27
+
28
+ export type ContentType = (typeof CONTENT_TYPES)[number];
29
+
30
+ /** The content types the resources page collects. */
31
+ export const RESOURCE_TYPES: readonly ContentType[] = ['research', 'whitepaper', 'case-study'];
32
+
33
+ export interface AuthorLink {
34
+ label: string;
35
+ url: string;
36
+ }
37
+
38
+ export interface Author {
39
+ slug: string;
40
+ name: string;
41
+ roleTitle: string | null;
42
+ bio: string | null;
43
+ avatarUrl: string | null;
44
+ links: AuthorLink[];
45
+ }
46
+
47
+ /**
48
+ * An inline call to action attached to a post. `link` is a plain button.
49
+ * `gated-download` asks for an email through `formKey` and then reveals
50
+ * `assetUrl`.
51
+ */
52
+ export interface CallToAction {
53
+ style: 'link' | 'gated-download';
54
+ heading: string | null;
55
+ buttonLabel: string;
56
+ url: string | null;
57
+ formKey: string | null;
58
+ assetUrl: string | null;
59
+ }
60
+
61
+ /**
62
+ * The answer-first block that makes a page quotable by an answer engine: the
63
+ * question a person actually types, and a short self-contained answer that
64
+ * stands on its own when lifted out of the page.
65
+ *
66
+ * `source` records where it came from, because the honest fallback matters:
67
+ * `authored` means an editor wrote the question and the answer, `derived`
68
+ * means the site built one from the title and the excerpt because the backend
69
+ * has no answer fields yet. Only `authored` blocks become `FAQPage` JSON-LD;
70
+ * claiming a derived summary is a curated answer would be a lie to the crawler.
71
+ */
72
+ export interface AnswerBlock {
73
+ question: string;
74
+ summary: string;
75
+ source: 'authored' | 'derived';
76
+ }
77
+
78
+ export interface Post {
79
+ slug: string;
80
+ type: ContentType;
81
+ title: string;
82
+ excerpt: string | null;
83
+ /** Markdown. Rendered by `lib/markdown.ts`, never by a page. */
84
+ content: string;
85
+ coverImageUrl: string | null;
86
+ category: string | null;
87
+ tags: string[];
88
+ /** Slugs of the bylined authors, in order. */
89
+ authorSlugs: string[];
90
+ /** ISO 8601. */
91
+ publishedAt: string;
92
+ answer: AnswerBlock | null;
93
+ cta: CallToAction | null;
94
+ /** Rounded reading time in minutes, or null when the backend does not count words. */
95
+ readingMinutes: number | null;
96
+ }
97
+
98
+ export interface PostPage {
99
+ items: Post[];
100
+ page: number;
101
+ pageSize: number;
102
+ total: number;
103
+ }
104
+
105
+ export interface PostQuery {
106
+ type?: ContentType;
107
+ tag?: string;
108
+ category?: string;
109
+ page?: number;
110
+ pageSize?: number;
111
+ }
112
+
113
+ /** Site-level SEO fallbacks. A page's own values always win over these. */
114
+ export interface SiteSeo {
115
+ defaultTitle: string | null;
116
+ /** `%s` is the page-title placeholder, for example `%s | Northgate Tools`. */
117
+ titleTemplate: string | null;
118
+ metaDescription: string | null;
119
+ ogImageUrl: string | null;
120
+ twitterHandle: string | null;
121
+ /** True when the site asks search engines to stay away. */
122
+ noindex: boolean;
123
+ }
124
+
125
+ export interface SiteIdentity {
126
+ name: string;
127
+ /** The site's canonical origin, no trailing slash. */
128
+ siteUrl: string;
129
+ seo: SiteSeo;
130
+ publisher: { name: string; logoUrl: string | null } | null;
131
+ /**
132
+ * The path prefix the BACKEND believes each content type is served under.
133
+ * Advisory: this template routes by its own `SECTIONS` map in `data.ts`, so
134
+ * a backend whose paths disagree cannot produce links to pages that do not
135
+ * exist. Useful for reconciling the two.
136
+ */
137
+ contentPaths: Record<ContentType, string>;
138
+ }
139
+
140
+ /** The site documents a backend can serve at the site root. */
141
+ export type DocumentKind = 'llms-txt' | 'design-md';
142
+
143
+ /**
144
+ * Everything a page can ask for. Implement this once per backend; that is the
145
+ * entire porting surface.
146
+ *
147
+ * Every method is build-time and read-only. Writes (form submissions, visit
148
+ * beacons, consent) do not appear here on purpose: they happen in the browser
149
+ * against a live API, so they are a property of the runtime configuration
150
+ * (`runtime` in `data.ts`), not of the content source.
151
+ */
152
+ export interface SiteBackend {
153
+ /** A short name for the build log and the footer, for example `we8` or `fixtures`. */
154
+ readonly name: string;
155
+ identity(): Promise<SiteIdentity>;
156
+ listPosts(query?: PostQuery): Promise<PostPage>;
157
+ /** Every published post, newest first. Used by listings, the sitemap, and static paths. */
158
+ listAllPosts(): Promise<Post[]>;
159
+ getPost(slug: string): Promise<Post | null>;
160
+ listAuthors(): Promise<Author[]>;
161
+ getAuthor(slug: string): Promise<Author | null>;
162
+ /** A site document's body. `''` when the backend has none, which is not an error. */
163
+ getDocument(kind: DocumentKind): Promise<string>;
164
+ /**
165
+ * Absolute URLs of content the backend knows about but this site does not
166
+ * statically render. Merged into the site's own sitemap. Usually empty.
167
+ */
168
+ extraSitemapUrls(siteUrl: string): Promise<string[]>;
169
+ }
170
+
171
+ /**
172
+ * How the browser should talk to a live backend. `enabled: false` means this
173
+ * build has no API behind it, so the form, the consent banner, and the visit
174
+ * beacon are not rendered at all rather than rendered dead.
175
+ */
176
+ export interface RuntimeConfig {
177
+ enabled: boolean;
178
+ key: string | null;
179
+ apiUrl: string | null;
180
+ }
@@ -0,0 +1,254 @@
1
+ /**
2
+ * The we8 backend: the data-layer contract implemented over the we8 `/v1` API.
3
+ *
4
+ * This is the ONLY module in the template that imports `@we8/astro` or
5
+ * `@we8/client`. Everything else, `data.ts` included, speaks the site's own
6
+ * vocabulary from `types.ts`. Deleting this file and its entry in `data.ts` is
7
+ * the entire cost of moving to a different backend.
8
+ *
9
+ * One build-time pass loads the whole content graph and memoizes it: the list
10
+ * endpoint gives slugs and content, and each post's detail endpoint resolves
11
+ * its byline (a post carries author ids, which no public consumer can turn
12
+ * into names). A starter-sized site does this once per build; if your site
13
+ * outgrows it, page `listPosts` directly and drop `listAllPosts`.
14
+ */
15
+ import {
16
+ createBuildClient,
17
+ mergeSitemap,
18
+ tryResolveConfig,
19
+ type EnvLike,
20
+ type We8AstroConfig,
21
+ type We8Author,
22
+ type We8Post,
23
+ } from '@we8/astro';
24
+ import type {
25
+ Author,
26
+ CallToAction,
27
+ ContentType,
28
+ DocumentKind,
29
+ Post,
30
+ PostPage,
31
+ PostQuery,
32
+ RuntimeConfig,
33
+ SiteBackend,
34
+ SiteIdentity,
35
+ } from './types.js';
36
+ import { CONTENT_TYPES } from './types.js';
37
+
38
+ /** Average adult reading speed, words per minute. Only used when the API counted words. */
39
+ const WORDS_PER_MINUTE = 220;
40
+
41
+ /**
42
+ * Read the answer-first fields off a post.
43
+ *
44
+ * The `/v1` dialect carries them flat: `answerSummary` (editor-authored, the
45
+ * only kind that becomes FAQPage markup) and `questionHeading` (falls back to
46
+ * the title). Both are optional on older backends, so the read stays
47
+ * defensive and the template degrades to a derived block without them.
48
+ */
49
+ function readAnswer(post: We8Post): Post['answer'] {
50
+ const summary = typeof post.answerSummary === 'string' ? post.answerSummary.trim() : '';
51
+ if (!summary) return null;
52
+ const question = typeof post.questionHeading === 'string' ? post.questionHeading.trim() : '';
53
+ return { question: question || post.title, summary, source: 'authored' };
54
+ }
55
+
56
+ function toContentType(value: string): ContentType {
57
+ return (CONTENT_TYPES as readonly string[]).includes(value)
58
+ ? (value as ContentType)
59
+ : 'blog';
60
+ }
61
+
62
+ function toCta(post: We8Post): CallToAction | null {
63
+ const cta = post.cta;
64
+ if (!cta) return null;
65
+ return {
66
+ style: cta.style,
67
+ heading: cta.heading ?? null,
68
+ buttonLabel: cta.buttonLabel,
69
+ url: cta.url ?? null,
70
+ formKey: cta.formKey ?? null,
71
+ assetUrl: cta.assetUrl ?? null,
72
+ };
73
+ }
74
+
75
+ function toAuthor(author: We8Author): Author {
76
+ return {
77
+ slug: author.slug,
78
+ name: author.name,
79
+ roleTitle: author.roleTitle ?? null,
80
+ bio: author.bio ?? null,
81
+ avatarUrl: author.avatarUrl ?? null,
82
+ links: author.links.map((link) => ({ label: link.label, url: link.url })),
83
+ };
84
+ }
85
+
86
+ function toPost(post: We8Post, authorSlugs: string[]): Post {
87
+ return {
88
+ slug: post.slug,
89
+ type: toContentType(post.type),
90
+ title: post.title,
91
+ excerpt: post.excerpt,
92
+ content: post.content,
93
+ coverImageUrl: post.coverImageUrl,
94
+ category: post.category,
95
+ tags: post.tags,
96
+ authorSlugs,
97
+ publishedAt: post.publishedAt,
98
+ answer: readAnswer(post),
99
+ cta: toCta(post),
100
+ readingMinutes:
101
+ post.wordCount === null ? null : Math.max(1, Math.round(post.wordCount / WORDS_PER_MINUTE)),
102
+ };
103
+ }
104
+
105
+ interface Graph {
106
+ posts: Post[];
107
+ authors: Author[];
108
+ }
109
+
110
+ export function createWe8Backend(config: We8AstroConfig, fetchImpl?: typeof fetch): SiteBackend {
111
+ const client = createBuildClient(config, fetchImpl);
112
+ let graph: Promise<Graph> | null = null;
113
+
114
+ async function loadGraph(): Promise<Graph> {
115
+ // 50 is the largest page the API accepts; a larger request is a 400.
116
+ const pageSize = 50;
117
+ const raw: We8Post[] = [];
118
+ for (let page = 1; page <= 100; page++) {
119
+ const { items, total } = await client.posts.list({ page, pageSize });
120
+ raw.push(...items);
121
+ if (items.length === 0 || raw.length >= total) break;
122
+ }
123
+
124
+ const authorsBySlug = new Map<string, Author>();
125
+ const posts: Post[] = [];
126
+ for (const summary of raw) {
127
+ // The detail route is what resolves the byline; the list route carries
128
+ // author ids only.
129
+ const detail = await client.posts.get(summary.slug);
130
+ const slugs: string[] = [];
131
+ for (const author of detail.authors) {
132
+ if (!authorsBySlug.has(author.slug)) authorsBySlug.set(author.slug, toAuthor(author));
133
+ slugs.push(author.slug);
134
+ }
135
+ posts.push(toPost(detail.post, slugs));
136
+ }
137
+
138
+ posts.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
139
+ return { posts, authors: [...authorsBySlug.values()] };
140
+ }
141
+
142
+ function load(): Promise<Graph> {
143
+ graph ??= loadGraph();
144
+ return graph;
145
+ }
146
+
147
+ function matches(post: Post, query: PostQuery): boolean {
148
+ if (query.type && post.type !== query.type) return false;
149
+ if (query.tag && !post.tags.includes(query.tag)) return false;
150
+ if (query.category && post.category !== query.category) return false;
151
+ return true;
152
+ }
153
+
154
+ return {
155
+ name: 'we8',
156
+
157
+ async identity(): Promise<SiteIdentity> {
158
+ const site = await client.site.config();
159
+ const contentPaths = Object.fromEntries(
160
+ CONTENT_TYPES.map((type) => [type, site.contentPaths[type] ?? site.postsPathPrefix]),
161
+ ) as Record<ContentType, string>;
162
+ return {
163
+ name: site.name ?? site.publisher?.name ?? '',
164
+ siteUrl: site.siteUrl.replace(/\/+$/, ''),
165
+ seo: {
166
+ defaultTitle: site.seo.defaultTitle ?? null,
167
+ titleTemplate: site.seo.titleTemplate ?? null,
168
+ metaDescription: site.seo.metaDescription ?? null,
169
+ ogImageUrl: site.seo.ogImageUrl ?? null,
170
+ twitterHandle: site.seo.twitterHandle ?? null,
171
+ noindex: site.seo.discourageSearchEngines,
172
+ },
173
+ publisher: site.publisher
174
+ ? { name: site.publisher.name, logoUrl: site.publisher.logoUrl ?? null }
175
+ : null,
176
+ contentPaths,
177
+ };
178
+ },
179
+
180
+ async listPosts(query: PostQuery = {}): Promise<PostPage> {
181
+ const { posts } = await load();
182
+ const filtered = posts.filter((post) => matches(post, query));
183
+ const page = Math.max(1, query.page ?? 1);
184
+ const pageSize = Math.max(1, query.pageSize ?? 10);
185
+ const start = (page - 1) * pageSize;
186
+ return {
187
+ items: filtered.slice(start, start + pageSize),
188
+ page,
189
+ pageSize,
190
+ total: filtered.length,
191
+ };
192
+ },
193
+
194
+ async listAllPosts(): Promise<Post[]> {
195
+ return (await load()).posts;
196
+ },
197
+
198
+ async getPost(slug: string): Promise<Post | null> {
199
+ return (await load()).posts.find((post) => post.slug === slug) ?? null;
200
+ },
201
+
202
+ async listAuthors(): Promise<Author[]> {
203
+ return (await load()).authors;
204
+ },
205
+
206
+ async getAuthor(slug: string): Promise<Author | null> {
207
+ return (await load()).authors.find((author) => author.slug === slug) ?? null;
208
+ },
209
+
210
+ async getDocument(kind: DocumentKind): Promise<string> {
211
+ const document = await client.documents.get(kind);
212
+ return document.content;
213
+ },
214
+
215
+ async extraSitemapUrls(siteUrl: string): Promise<string[]> {
216
+ // Ask the API for every slug it publishes and subtract the ones this
217
+ // build rendered. Normally empty; it stops content from vanishing from
218
+ // the sitemap when the site routes only part of what the CMS holds.
219
+ const { posts } = await load();
220
+ const rendered = new Set(posts.map((post) => post.slug));
221
+ const base = siteUrl.replace(/\/+$/, '');
222
+ const identity = await this.identity();
223
+ const all = await mergeSitemap({
224
+ config,
225
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
226
+ map: (slug) => slug,
227
+ });
228
+ return all
229
+ .filter((slug) => !rendered.has(slug))
230
+ .map((slug) => `${base}${identity.contentPaths.blog}/${slug}`);
231
+ },
232
+ };
233
+ }
234
+
235
+ /**
236
+ * Build a we8 backend from the environment, or `null` when we8 is not
237
+ * configured. This is the whole detection rule: both `WE8_API_URL` and
238
+ * `WE8_PUBLISHABLE_KEY` present means live, anything else means fall back.
239
+ */
240
+ export function resolveWe8Backend(env: EnvLike, fetchImpl?: typeof fetch): SiteBackend | null {
241
+ const config = tryResolveConfig(env);
242
+ return config ? createWe8Backend(config, fetchImpl) : null;
243
+ }
244
+
245
+ /**
246
+ * The browser-side half of the we8 configuration: the publishable key and the
247
+ * API base, or nulls when we8 is not configured. Kept here so `data.ts` stays
248
+ * free of we8 imports.
249
+ */
250
+ export function resolveWe8Runtime(env: EnvLike): RuntimeConfig {
251
+ const config = tryResolveConfig(env);
252
+ if (!config?.apiUrl) return { enabled: false, key: null, apiUrl: null };
253
+ return { enabled: true, key: config.key, apiUrl: config.apiUrl };
254
+ }
@@ -0,0 +1,96 @@
1
+ ---
2
+ /**
3
+ * About.
4
+ *
5
+ * A content page with question-phrased headings, which is the whole AEO habit
6
+ * in one file: every `##` is something a person would type, and the paragraph
7
+ * under it answers that question without needing the rest of the page.
8
+ *
9
+ * The team list comes from the data layer's author roster, so it stays true as
10
+ * the CMS changes rather than drifting into a hand-maintained list.
11
+ */
12
+ import AnswerBlock from '../components/AnswerBlock.astro';
13
+ import JsonLd from '../components/JsonLd.astro';
14
+ import BaseLayout from '../layouts/BaseLayout.astro';
15
+ import { authorPath, getAuthors, getIdentity } from '../lib/data.js';
16
+ import { breadcrumbJsonLd, faqJsonLd } from '../lib/seo.js';
17
+
18
+ const identity = await getIdentity();
19
+ const authors = await getAuthors();
20
+
21
+ const answer = {
22
+ question: `Who is ${identity.name}?`,
23
+ summary: `${identity.name} is a small team building maintenance planning software for workshops under fifty machines. We started on a maintenance desk, not in a boardroom, and the product still reflects that.`,
24
+ source: 'authored' as const,
25
+ };
26
+ ---
27
+
28
+ <BaseLayout
29
+ identity={identity}
30
+ seo={{
31
+ title: 'About',
32
+ description: answer.summary,
33
+ }}
34
+ >
35
+ <Fragment slot="jsonLd">
36
+ <JsonLd data={faqJsonLd([answer])} />
37
+ <JsonLd
38
+ data={breadcrumbJsonLd(
39
+ [
40
+ { name: 'Home', path: '/' },
41
+ { name: 'About', path: '/about' },
42
+ ],
43
+ identity,
44
+ )}
45
+ />
46
+ </Fragment>
47
+
48
+ <div class="stack prose">
49
+ <h1>About {identity.name}</h1>
50
+ <AnswerBlock answer={answer} showQuestion={false} />
51
+
52
+ <h2>Why does this exist?</h2>
53
+ <p>
54
+ Maintenance software is built for plants and sold to workshops. The result is a system whose
55
+ setup costs more attention than the maintenance it manages, abandoned within a year and
56
+ quietly replaced by a spreadsheet. We built the spreadsheet's replacement instead: runtime
57
+ scheduling, a six field work order, and downtime measured from two timestamps.
58
+ </p>
59
+
60
+ <h2>What will you not sell us?</h2>
61
+ <p>
62
+ Sensors, in year one. Condition monitoring is an answer to a question you should be able to
63
+ state out loud, and most shops cannot state it yet because they are not counting runtime.
64
+ Counting runtime is free. We would rather you did that first and bought nothing.
65
+ </p>
66
+
67
+ <h2>How do you handle our data?</h2>
68
+ <p>
69
+ Your maintenance records are yours. Everything exports as CSV, including after you stop
70
+ paying us, and we do not sell or share anything. This site counts page views only, after you
71
+ agree to it, and it works exactly the same if you decline.
72
+ </p>
73
+
74
+ <h2>Who works here?</h2>
75
+ {
76
+ authors.length > 0 ? (
77
+ <ul>
78
+ {authors.map((author) => (
79
+ <li>
80
+ <a href={authorPath(author)}>{author.name}</a>
81
+ {author.roleTitle && <span class="meta">, {author.roleTitle}</span>}
82
+ </li>
83
+ ))}
84
+ </ul>
85
+ ) : (
86
+ <p>The team roster appears here once authors are published in the CMS.</p>
87
+ )
88
+ }
89
+
90
+ <h2>How do we get in touch?</h2>
91
+ <p>
92
+ The <a href="/contact">contact page</a> reaches us directly. We answer within two working
93
+ days, and there is no sales sequence behind it.
94
+ </p>
95
+ </div>
96
+ </BaseLayout>
@@ -0,0 +1,113 @@
1
+ ---
2
+ /**
3
+ * An author page: who wrote this, and everything they wrote.
4
+ *
5
+ * Author pages matter more than they look. They are what turns a byline into a
6
+ * source an answer engine can attribute, and schema.org `Person` with a real
7
+ * bio and a real list of work is the cheapest credibility signal a small site
8
+ * can emit.
9
+ */
10
+ import type { GetStaticPaths } from 'astro';
11
+ import JsonLd from '../../components/JsonLd.astro';
12
+ import PostCard from '../../components/PostCard.astro';
13
+ import BaseLayout from '../../layouts/BaseLayout.astro';
14
+ import {
15
+ authorPath,
16
+ getAuthors,
17
+ getIdentity,
18
+ getPostsByAuthor,
19
+ postUrl,
20
+ } from '../../lib/data.js';
21
+ import { breadcrumbJsonLd } from '../../lib/seo.js';
22
+
23
+ export const getStaticPaths = (async () => {
24
+ const authors = await getAuthors();
25
+ const identity = await getIdentity();
26
+ const entries = [];
27
+ for (const author of authors) {
28
+ entries.push({
29
+ params: { slug: author.slug },
30
+ props: { author, posts: await getPostsByAuthor(author.slug), identity },
31
+ });
32
+ }
33
+ return entries;
34
+ }) satisfies GetStaticPaths;
35
+
36
+ const { author, posts, identity } = Astro.props;
37
+
38
+ const personJsonLd = {
39
+ '@context': 'https://schema.org',
40
+ '@type': 'Person',
41
+ name: author.name,
42
+ url: `${identity.siteUrl}${authorPath(author)}`,
43
+ ...(author.roleTitle ? { jobTitle: author.roleTitle } : {}),
44
+ ...(author.bio ? { description: author.bio } : {}),
45
+ ...(author.avatarUrl ? { image: author.avatarUrl } : {}),
46
+ worksFor: { '@type': 'Organization', name: identity.publisher?.name ?? identity.name },
47
+ ...(posts.length
48
+ ? {
49
+ subjectOf: posts.map((post) => ({
50
+ '@type': 'Article',
51
+ headline: post.title,
52
+ url: postUrl(post, identity),
53
+ })),
54
+ }
55
+ : {}),
56
+ };
57
+ ---
58
+
59
+ <BaseLayout
60
+ identity={identity}
61
+ seo={{
62
+ title: author.name,
63
+ description:
64
+ author.bio ??
65
+ `${author.name} writes for ${identity.name} about maintenance planning for small workshops.`,
66
+ }}
67
+ >
68
+ <Fragment slot="jsonLd">
69
+ <JsonLd data={personJsonLd} />
70
+ <JsonLd
71
+ data={breadcrumbJsonLd(
72
+ [
73
+ { name: 'Home', path: '/' },
74
+ { name: author.name, path: authorPath(author) },
75
+ ],
76
+ identity,
77
+ )}
78
+ />
79
+ </Fragment>
80
+
81
+ <div class="stack">
82
+ <header class="prose">
83
+ <h1>{author.name}</h1>
84
+ {author.roleTitle && <p class="lede">{author.roleTitle}</p>}
85
+ {author.bio && <p>{author.bio}</p>}
86
+ {
87
+ author.links.length > 0 && (
88
+ <p class="meta">
89
+ {author.links.map((link, index) => (
90
+ <>
91
+ {index > 0 && ' · '}
92
+ <a href={link.url}>{link.label}</a>
93
+ </>
94
+ ))}
95
+ </p>
96
+ )
97
+ }
98
+ </header>
99
+
100
+ {
101
+ posts.length > 0 && (
102
+ <section aria-labelledby="by-author">
103
+ <h2 id="by-author">Written by {author.name}</h2>
104
+ <ul class="grid">
105
+ {posts.map((post) => (
106
+ <PostCard post={post} />
107
+ ))}
108
+ </ul>
109
+ </section>
110
+ )
111
+ }
112
+ </div>
113
+ </BaseLayout>
@@ -0,0 +1,28 @@
1
+ ---
2
+ /**
3
+ * One blog post. The page itself is four lines of data fetching: the rendering
4
+ * lives in PostLayout, shared with the resources section.
5
+ */
6
+ import type { GetStaticPaths } from 'astro';
7
+ import PostLayout from '../../layouts/PostLayout.astro';
8
+ import { bylineFor, getArticles, getAuthors, getIdentity } from '../../lib/data.js';
9
+
10
+ export const getStaticPaths = (async () => {
11
+ const posts = await getArticles();
12
+ const authors = await getAuthors();
13
+ const identity = await getIdentity();
14
+ return posts.map((post) => ({
15
+ params: { slug: post.slug },
16
+ props: { post, byline: bylineFor(post, authors), identity },
17
+ }));
18
+ }) satisfies GetStaticPaths;
19
+
20
+ const { post, byline, identity } = Astro.props;
21
+ ---
22
+
23
+ <PostLayout
24
+ post={post}
25
+ byline={byline}
26
+ identity={identity}
27
+ section={{ name: 'Blog', path: '/blog' }}
28
+ />