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,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
|
+
/>
|