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,307 @@
1
+ /**
2
+ * The fixture content.
3
+ *
4
+ * Everything here is invented: a small maintenance-planning studio called
5
+ * Northgate Tools. It exists so the template builds, runs, and reads like a
6
+ * real site with no backend configured at all, which is what makes the
7
+ * data-layer contract testable rather than aspirational.
8
+ *
9
+ * Replace it with your own copy, or delete the whole fixture backend once you
10
+ * are pointed at a real CMS. Nothing else in the template imports this file.
11
+ */
12
+ import type { Author, Post, SiteIdentity } from './types.js';
13
+
14
+ export const identity: SiteIdentity = {
15
+ name: 'Northgate Tools',
16
+ siteUrl: 'https://northgatetools.example',
17
+ seo: {
18
+ defaultTitle: 'Northgate Tools: maintenance planning for small workshops',
19
+ titleTemplate: '%s | Northgate Tools',
20
+ metaDescription:
21
+ 'Northgate Tools builds maintenance planning software for workshops with fewer than fifty machines. Plain scheduling, honest reporting, no consultants required.',
22
+ ogImageUrl: null,
23
+ twitterHandle: null,
24
+ noindex: false,
25
+ },
26
+ publisher: { name: 'Northgate Tools', logoUrl: null },
27
+ contentPaths: {
28
+ blog: '/blog',
29
+ article: '/blog',
30
+ news: '/blog',
31
+ research: '/resources',
32
+ whitepaper: '/resources',
33
+ 'case-study': '/resources',
34
+ },
35
+ };
36
+
37
+ export const authors: Author[] = [
38
+ {
39
+ slug: 'jane-okonjo',
40
+ name: 'Jane Okonjo',
41
+ roleTitle: 'Founder',
42
+ bio: 'Jane ran the maintenance desk at a sheet metal shop for nine years before starting Northgate Tools. She writes about the gap between what a schedule says and what the floor actually does.',
43
+ avatarUrl: null,
44
+ links: [{ label: 'Email', url: 'mailto:jane@northgatetools.example' }],
45
+ },
46
+ {
47
+ slug: 'marek-lindqvist',
48
+ name: 'Marek Lindqvist',
49
+ roleTitle: 'Reliability lead',
50
+ bio: 'Marek spent a decade in condition monitoring across three continents. He is the reason our failure codes fit on an index card.',
51
+ avatarUrl: null,
52
+ links: [],
53
+ },
54
+ ];
55
+
56
+ /**
57
+ * Fixture posts. Note the `answer` block on each: a question phrased the way
58
+ * someone would type it, and an answer short enough to be quoted whole. That
59
+ * is the AEO habit this template is built around.
60
+ */
61
+ export const posts: Post[] = [
62
+ {
63
+ slug: 'how-often-should-a-small-workshop-service-its-machines',
64
+ type: 'blog',
65
+ title: 'How often should a small workshop service its machines?',
66
+ excerpt:
67
+ 'Interval-based servicing is the wrong default for a shop under fifty machines. Here is what to use instead, and how to get there without buying sensors.',
68
+ content: `Most small workshops inherit a servicing interval from a manual and never revisit it. The manual was written for a machine running two shifts in a climate-controlled plant. Your machine runs four hours a day next to an open door.
69
+
70
+ ## Start from runtime, not from the calendar
71
+
72
+ Count hours, not weeks. A machine that ran 40 hours last month and one that ran 400 do not need the same attention, and a calendar schedule cannot tell them apart. Most controllers already expose an hour meter. Read it once a week and write it down; that single number beats any interval a manual can give you.
73
+
74
+ ## Let failures reset the interval
75
+
76
+ When something breaks, the interval that preceded it was wrong. Shorten it for that machine, not for the fleet. Fleet-wide changes are how a shop ends up servicing everything twice as often and still missing the one machine that actually fails.
77
+
78
+ ## What this looks like after a quarter
79
+
80
+ You will have three groups: machines you barely touch, machines on a steady runtime interval, and two or three that need watching. That grouping is worth more than any sensor you could buy in year one.
81
+
82
+ ## When to add condition monitoring
83
+
84
+ Add it when a specific machine has failed twice in a way runtime did not predict, and the downtime cost more than the sensor. Not before. Condition monitoring is an answer to a question you should be able to state out loud.`,
85
+ coverImageUrl: null,
86
+ category: 'Practice',
87
+ tags: ['maintenance', 'scheduling'],
88
+ authorSlugs: ['jane-okonjo'],
89
+ publishedAt: '2026-07-14T09:00:00.000Z',
90
+ answer: {
91
+ question: 'How often should a small workshop service its machines?',
92
+ summary:
93
+ 'Service on runtime hours rather than the calendar, and shorten the interval for any individual machine that fails inside it. For a shop under fifty machines, a weekly hour-meter reading is more useful than sensors in the first year.',
94
+ source: 'authored',
95
+ },
96
+ cta: null,
97
+ readingMinutes: 4,
98
+ },
99
+ {
100
+ slug: 'what-belongs-on-a-maintenance-work-order',
101
+ type: 'blog',
102
+ title: 'What belongs on a maintenance work order?',
103
+ excerpt:
104
+ 'Six fields, and a hard rule about the seventh. Anything longer stops being filled in by the second week.',
105
+ content: `A work order that nobody completes is worse than no work order, because it tells you a job was done when it was not.
106
+
107
+ ## The six fields that survive
108
+
109
+ Machine, date, who did it, what they did, what it needed that they did not have, and how long it took. That is the whole form. Every shop that adds a seventh field discovers within a month that the seventh field is blank and the sixth has started going blank with it.
110
+
111
+ ## The rule about the seventh
112
+
113
+ If you want to add a field, remove one first. This sounds like a slogan until you try it. It forces the question of which field has actually earned its place, and the answer is usually that the newest one has not.
114
+
115
+ ## Why "what it needed that they did not have" matters most
116
+
117
+ That field is your parts list, your purchasing signal, and your best evidence for a budget conversation, and it costs the technician four words. It is the only field on the form that pays for itself the same week.
118
+
119
+ ## Paper is fine
120
+
121
+ If the paper version works and the software version does not, the software is wrong. Start by photographing the paper.`,
122
+ coverImageUrl: null,
123
+ category: 'Practice',
124
+ tags: ['work-orders', 'process'],
125
+ authorSlugs: ['marek-lindqvist', 'jane-okonjo'],
126
+ publishedAt: '2026-06-02T09:00:00.000Z',
127
+ answer: {
128
+ question: 'What belongs on a maintenance work order?',
129
+ summary:
130
+ 'Six fields: machine, date, technician, work performed, missing parts, and time taken. Adding a seventh field reliably empties the sixth, so remove one before you add one.',
131
+ source: 'authored',
132
+ },
133
+ cta: null,
134
+ readingMinutes: 3,
135
+ },
136
+ {
137
+ slug: 'why-your-downtime-number-is-probably-wrong',
138
+ type: 'article',
139
+ title: 'Why your downtime number is probably wrong',
140
+ excerpt:
141
+ 'Three ways downtime gets miscounted in small shops, and the one change that fixes most of it.',
142
+ content: `Ask two people in the same shop how much downtime last month cost and you will get two answers an order of magnitude apart. Both are usually defensible, which is the problem.
143
+
144
+ ## The three miscounts
145
+
146
+ First, waiting is not counted. The machine stopped at nine and the technician arrived at two; most shops record five minutes of work and no downtime. Second, partial capacity is counted as zero. A machine running at half rate is treated as running. Third, planned stoppages are excluded by definition, which hides the case where the plan itself was the failure.
147
+
148
+ ## The one change
149
+
150
+ Record the stop time and the restart time, and nothing else. Not the repair duration, not the cause, not the category. Two timestamps. Everything else can be reconstructed later; the timestamps cannot.
151
+
152
+ ## What to do with a year of timestamps
153
+
154
+ Sort by duration and look at the top ten. In every shop we have worked with, those ten stoppages account for more lost hours than the rest of the year combined, and at least three of them share a cause nobody had named.`,
155
+ coverImageUrl: null,
156
+ category: 'Analysis',
157
+ tags: ['downtime', 'measurement'],
158
+ authorSlugs: ['marek-lindqvist'],
159
+ publishedAt: '2026-04-21T09:00:00.000Z',
160
+ answer: {
161
+ question: 'Why is my downtime number wrong?',
162
+ summary:
163
+ 'Downtime is usually undercounted because waiting time is excluded, reduced-rate running is treated as running, and planned stoppages are excluded by definition. Recording only the stop and restart timestamps fixes most of it.',
164
+ source: 'authored',
165
+ },
166
+ cta: null,
167
+ readingMinutes: 4,
168
+ },
169
+ {
170
+ slug: 'the-fifty-machine-maintenance-starter-kit',
171
+ type: 'whitepaper',
172
+ title: 'The fifty machine maintenance starter kit',
173
+ excerpt:
174
+ 'A printable pack: the six-field work order, a runtime log, a failure code card, and a quarterly review agenda.',
175
+ content: `This pack is what we hand a shop on day one. It is four pages, it is deliberately boring, and it assumes you have a printer and nothing else.
176
+
177
+ ## What is in it
178
+
179
+ The six-field work order, sized for a clipboard. A runtime log with room for twelve months of weekly readings. A failure code card with eleven codes, which is as many as anyone remembers. A one-page agenda for a quarterly review that takes forty minutes.
180
+
181
+ ## How to use it
182
+
183
+ Print the work order double sided and put fifty of them on the wall by the tool crib. Put the runtime log next to the hour meter, not in an office. Give the failure code card to whoever writes the work orders, and accept that codes will be wrong for the first month.
184
+
185
+ ## What it deliberately leaves out
186
+
187
+ There is no criticality ranking, no spare parts matrix, and no KPI sheet. All three are useful in year two and all three are abandoned in year one.`,
188
+ coverImageUrl: null,
189
+ category: 'Toolkit',
190
+ tags: ['toolkit', 'templates'],
191
+ authorSlugs: ['jane-okonjo'],
192
+ publishedAt: '2026-05-19T09:00:00.000Z',
193
+ answer: {
194
+ question: 'What should a small workshop set up first for maintenance?',
195
+ summary:
196
+ 'A six-field work order, a weekly runtime log kept at the machine, a failure code card with about a dozen codes, and a forty minute quarterly review. Criticality rankings and KPI sheets can wait for year two.',
197
+ source: 'authored',
198
+ },
199
+ cta: {
200
+ style: 'gated-download',
201
+ heading: 'Get the printable pack',
202
+ buttonLabel: 'Send me the kit',
203
+ url: null,
204
+ formKey: 'contact',
205
+ assetUrl: 'https://northgatetools.example/downloads/starter-kit.pdf',
206
+ },
207
+ readingMinutes: 5,
208
+ },
209
+ {
210
+ slug: 'case-study-a-sheet-metal-shop-cuts-unplanned-stops-by-half',
211
+ type: 'case-study',
212
+ title: 'How one sheet metal shop cut unplanned stops by half',
213
+ excerpt:
214
+ 'Eleven machines, two technicians, no new hardware. What changed was the order in which they wrote things down.',
215
+ content: `The shop runs eleven machines across one and a half shifts. Before the change, unplanned stops averaged nine a month. Twelve months later they average four.
216
+
217
+ ## What they changed
218
+
219
+ They moved the runtime log from the office to the machine. They cut the work order from fourteen fields to six. They started reading the top ten stoppages every quarter instead of the monthly average.
220
+
221
+ ## What they did not change
222
+
223
+ No sensors, no new software in the first nine months, no additional staff. The one purchase was a clipboard for each machine.
224
+
225
+ ## The honest part
226
+
227
+ Two of the eleven machines got worse. Both were near the end of their service life, and the clearer records are what finally made that case to the owner. Better measurement does not always produce a better number; sometimes it produces a better decision.`,
228
+ coverImageUrl: null,
229
+ category: 'Case study',
230
+ tags: ['case-study', 'downtime'],
231
+ authorSlugs: ['jane-okonjo', 'marek-lindqvist'],
232
+ publishedAt: '2026-03-08T09:00:00.000Z',
233
+ answer: {
234
+ question: 'Can a small shop reduce unplanned downtime without buying sensors?',
235
+ summary:
236
+ 'Yes. An eleven machine shop halved unplanned stops in a year by moving the runtime log to the machine, cutting the work order to six fields, and reviewing the ten longest stoppages quarterly. No new hardware was bought.',
237
+ source: 'authored',
238
+ },
239
+ cta: {
240
+ style: 'link',
241
+ heading: 'Want the same review?',
242
+ buttonLabel: 'Book a quarterly review',
243
+ url: 'https://northgatetools.example/contact',
244
+ formKey: null,
245
+ assetUrl: null,
246
+ },
247
+ readingMinutes: 4,
248
+ },
249
+ {
250
+ slug: 'runtime-versus-calendar-scheduling-a-field-comparison',
251
+ type: 'research',
252
+ title: 'Runtime versus calendar scheduling: a field comparison',
253
+ excerpt:
254
+ 'Fourteen shops, two years of records, and what the comparison does and does not show.',
255
+ content: `We compared maintenance records from fourteen small shops, half on calendar intervals and half on runtime intervals, over two years.
256
+
257
+ ## What we found
258
+
259
+ Runtime-scheduled shops performed fewer services overall and reported fewer unplanned stops per thousand running hours. The difference was clearest in shops with uneven machine utilisation, and nearly absent in shops where every machine ran roughly the same hours.
260
+
261
+ ## What this does not show
262
+
263
+ The sample is small, self-selected, and not randomised: shops that switched to runtime scheduling were already paying more attention. We are reporting an association, not an effect. If your machines all run the same hours, this comparison gives you no reason to change anything.
264
+
265
+ ## Method
266
+
267
+ Records were normalised to running hours from controller hour meters where available and estimated from shift logs elsewhere. Estimated shops are flagged separately in the appendix and excluded from the primary comparison.`,
268
+ coverImageUrl: null,
269
+ category: 'Research',
270
+ tags: ['research', 'scheduling'],
271
+ authorSlugs: ['marek-lindqvist'],
272
+ publishedAt: '2026-02-11T09:00:00.000Z',
273
+ answer: {
274
+ question: 'Is runtime-based maintenance scheduling better than calendar-based?',
275
+ summary:
276
+ 'In a fourteen shop comparison, runtime scheduling meant fewer services and fewer unplanned stops per thousand running hours, with the benefit concentrated in shops whose machines run very different hours. The sample was small and self-selected, so treat it as an association.',
277
+ source: 'authored',
278
+ },
279
+ cta: {
280
+ style: 'gated-download',
281
+ heading: 'Read the full comparison',
282
+ buttonLabel: 'Send me the paper',
283
+ url: null,
284
+ formKey: 'contact',
285
+ assetUrl: 'https://northgatetools.example/downloads/runtime-comparison.pdf',
286
+ },
287
+ readingMinutes: 6,
288
+ },
289
+ ];
290
+
291
+ /** The llms.txt body served at the site root when no backend supplies one. */
292
+ export const llmsTxt = `# Northgate Tools
293
+
294
+ > Maintenance planning software for workshops with fewer than fifty machines.
295
+
296
+ Northgate Tools publishes practical maintenance guidance for small workshops.
297
+ Our writing is answer-first: each article opens with the question it answers
298
+ and a short summary you can quote whole.
299
+
300
+ ## Content
301
+ - /blog: practice and analysis on maintenance scheduling, work orders, and downtime.
302
+ - /resources: research, whitepapers, and case studies, several with printable packs.
303
+ - /about: who we are and what we will not sell you.
304
+
305
+ ## Contact
306
+ - /contact
307
+ `;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Markdown rendering, and the heading outline that goes with it.
3
+ *
4
+ * Post bodies arrive as markdown from whatever backend is in use, so rendering
5
+ * is a presentation concern, not a data one: the data layer hands pages a
6
+ * string and this module turns it into HTML.
7
+ *
8
+ * `marked` is the one runtime dependency this template adds, and it has no
9
+ * dependencies of its own. The content is the site owner's, published through
10
+ * their own CMS, so it is treated as trusted; if you accept markdown from
11
+ * anyone else, sanitize the output before rendering it.
12
+ */
13
+ import { marked } from 'marked';
14
+
15
+ marked.setOptions({ gfm: true, breaks: false });
16
+
17
+ export function renderMarkdown(markdown: string): string {
18
+ return marked.parse(markdown, { async: false });
19
+ }
20
+
21
+ export interface Heading {
22
+ depth: number;
23
+ text: string;
24
+ id: string;
25
+ }
26
+
27
+ /** Turn a heading's text into a stable anchor id. */
28
+ export function slugifyHeading(text: string): string {
29
+ return text
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9]+/g, '-')
32
+ .replace(/^-+|-+$/g, '');
33
+ }
34
+
35
+ /**
36
+ * The `##` and `###` headings of a body, in order.
37
+ *
38
+ * This is the AEO workhorse: a page whose headings are questions gives an
39
+ * answer engine an outline it can lift directly, and gives a reader an
40
+ * on-page table of contents for free. It reads the markdown source rather
41
+ * than the rendered HTML so it never depends on the renderer's output shape.
42
+ */
43
+ export function outline(markdown: string): Heading[] {
44
+ const headings: Heading[] = [];
45
+ for (const line of markdown.split('\n')) {
46
+ const match = /^(#{2,3})\s+(.+?)\s*#*$/.exec(line);
47
+ if (!match) continue;
48
+ const text = match[2] ?? '';
49
+ headings.push({ depth: match[1]?.length ?? 2, text, id: slugifyHeading(text) });
50
+ }
51
+ return headings;
52
+ }
53
+
54
+ /**
55
+ * Add the outline's anchor ids to the rendered HTML, so the table of contents
56
+ * links somewhere and a crawler can cite a section rather than a page.
57
+ */
58
+ export function renderBody(markdown: string): { html: string; headings: Heading[] } {
59
+ const headings = outline(markdown);
60
+ let html = renderMarkdown(markdown);
61
+ for (const heading of headings) {
62
+ const tag = `h${heading.depth}`;
63
+ html = html.replace(`<${tag}>`, `<${tag} id="${heading.id}">`);
64
+ }
65
+ return { html, headings };
66
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * SEO and AEO helpers, written against the site's own `SiteIdentity` rather
3
+ * than any backend's shape.
4
+ *
5
+ * The merge rule is code-first: whatever a page declares wins, and the site
6
+ * identity only fills the gaps. That is the same rule `@we8/astro`'s `mergeSeo`
7
+ * applies to a we8 site config; restating it over `SiteIdentity` is what keeps
8
+ * pages ignorant of the backend.
9
+ */
10
+ import type { AnswerBlock, Author, Post, SiteIdentity } from './types.js';
11
+
12
+ /** What a page declares about itself. All optional. */
13
+ export interface PageSeo {
14
+ title?: string;
15
+ description?: string;
16
+ ogImageUrl?: string;
17
+ /** Absolute or site-relative canonical path. */
18
+ path?: string;
19
+ noindex?: boolean;
20
+ }
21
+
22
+ export interface ResolvedSeo {
23
+ title: string;
24
+ description: string | null;
25
+ ogTitle: string;
26
+ ogDescription: string | null;
27
+ ogImageUrl: string | null;
28
+ twitterHandle: string | null;
29
+ canonical: string | null;
30
+ noindex: boolean;
31
+ }
32
+
33
+ /**
34
+ * Code-first merge. A page title gets the site's `titleTemplate` applied; a
35
+ * page without one falls back to `defaultTitle` un-templated, since that value
36
+ * already names the site, and then to the site name.
37
+ */
38
+ export function resolveSeo(page: PageSeo, identity: SiteIdentity): ResolvedSeo {
39
+ const { seo } = identity;
40
+ const title = page.title
41
+ ? seo.titleTemplate
42
+ ? seo.titleTemplate.replace('%s', page.title)
43
+ : page.title
44
+ : (seo.defaultTitle ?? identity.name);
45
+ const description = page.description ?? seo.metaDescription ?? null;
46
+ return {
47
+ title,
48
+ description,
49
+ ogTitle: page.title ?? seo.defaultTitle ?? identity.name,
50
+ ogDescription: description,
51
+ ogImageUrl: page.ogImageUrl ?? seo.ogImageUrl ?? null,
52
+ twitterHandle: seo.twitterHandle,
53
+ canonical: page.path ? `${identity.siteUrl}${page.path}` : null,
54
+ noindex: page.noindex ?? seo.noindex,
55
+ };
56
+ }
57
+
58
+ /** A robots.txt body honoring the site's indexing preference. */
59
+ export function robotsTxt(identity: SiteIdentity, sitemapPath = '/sitemap.xml'): string {
60
+ if (identity.seo.noindex) return 'User-agent: *\nDisallow: /\n';
61
+ return [
62
+ 'User-agent: *',
63
+ 'Allow: /',
64
+ '',
65
+ `Sitemap: ${identity.siteUrl}${sitemapPath}`,
66
+ '',
67
+ '# Answer engines are welcome. Start with /llms.txt.',
68
+ '',
69
+ ].join('\n');
70
+ }
71
+
72
+ /** A minimal urlset document. One sitemap, whatever the content source. */
73
+ export function sitemapXml(urls: readonly string[]): string {
74
+ const entries = urls.map((url) => ` <url><loc>${escapeXml(url)}</loc></url>`).join('\n');
75
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries}\n</urlset>\n`;
76
+ }
77
+
78
+ function escapeXml(value: string): string {
79
+ return value
80
+ .replace(/&/g, '&amp;')
81
+ .replace(/</g, '&lt;')
82
+ .replace(/>/g, '&gt;')
83
+ .replace(/"/g, '&quot;');
84
+ }
85
+
86
+ /* -------------------------------------------------------------------------- */
87
+ /* JSON-LD */
88
+ /* -------------------------------------------------------------------------- */
89
+
90
+ type JsonLd = Record<string, unknown>;
91
+
92
+ /** The `@type` a content type maps to in schema.org terms. */
93
+ function schemaTypeFor(type: Post['type']): string {
94
+ switch (type) {
95
+ case 'news':
96
+ return 'NewsArticle';
97
+ case 'blog':
98
+ return 'BlogPosting';
99
+ default:
100
+ return 'Article';
101
+ }
102
+ }
103
+
104
+ export function organizationJsonLd(identity: SiteIdentity): JsonLd {
105
+ const publisher = identity.publisher;
106
+ return {
107
+ '@context': 'https://schema.org',
108
+ '@type': 'Organization',
109
+ name: publisher?.name ?? identity.name,
110
+ url: identity.siteUrl,
111
+ ...(publisher?.logoUrl ? { logo: publisher.logoUrl } : {}),
112
+ };
113
+ }
114
+
115
+ export function websiteJsonLd(identity: SiteIdentity): JsonLd {
116
+ return {
117
+ '@context': 'https://schema.org',
118
+ '@type': 'WebSite',
119
+ name: identity.name,
120
+ url: identity.siteUrl,
121
+ };
122
+ }
123
+
124
+ export function articleJsonLd(
125
+ post: Post,
126
+ url: string,
127
+ byline: Author[],
128
+ identity: SiteIdentity,
129
+ ): JsonLd {
130
+ return {
131
+ '@context': 'https://schema.org',
132
+ '@type': schemaTypeFor(post.type),
133
+ headline: post.title,
134
+ ...(post.excerpt ? { description: post.excerpt } : {}),
135
+ ...(post.coverImageUrl ? { image: post.coverImageUrl } : {}),
136
+ datePublished: post.publishedAt,
137
+ mainEntityOfPage: { '@type': 'WebPage', '@id': url },
138
+ author: byline.map((author) => ({
139
+ '@type': 'Person',
140
+ name: author.name,
141
+ url: `${identity.siteUrl}/authors/${author.slug}`,
142
+ })),
143
+ publisher: {
144
+ '@type': 'Organization',
145
+ name: identity.publisher?.name ?? identity.name,
146
+ ...(identity.publisher?.logoUrl ? { logo: identity.publisher.logoUrl } : {}),
147
+ },
148
+ ...(post.tags.length ? { keywords: post.tags.join(', ') } : {}),
149
+ };
150
+ }
151
+
152
+ /**
153
+ * An `FAQPage` for a set of answer blocks.
154
+ *
155
+ * Only `authored` blocks qualify. A derived block is the site paraphrasing its
156
+ * own excerpt; presenting that to a crawler as a curated question and answer
157
+ * would be a claim the content does not support, and answer engines are
158
+ * getting better at noticing. Returns `null` when nothing qualifies, and the
159
+ * page then emits no FAQ markup at all.
160
+ */
161
+ export function faqJsonLd(blocks: readonly (AnswerBlock | null)[]): JsonLd | null {
162
+ const authored = blocks.filter(
163
+ (block): block is AnswerBlock => block !== null && block.source === 'authored',
164
+ );
165
+ if (authored.length === 0) return null;
166
+ return {
167
+ '@context': 'https://schema.org',
168
+ '@type': 'FAQPage',
169
+ mainEntity: authored.map((block) => ({
170
+ '@type': 'Question',
171
+ name: block.question,
172
+ acceptedAnswer: { '@type': 'Answer', text: block.summary },
173
+ })),
174
+ };
175
+ }
176
+
177
+ export function breadcrumbJsonLd(
178
+ trail: readonly { name: string; path: string }[],
179
+ identity: SiteIdentity,
180
+ ): JsonLd {
181
+ return {
182
+ '@context': 'https://schema.org',
183
+ '@type': 'BreadcrumbList',
184
+ itemListElement: trail.map((crumb, index) => ({
185
+ '@type': 'ListItem',
186
+ position: index + 1,
187
+ name: crumb.name,
188
+ item: `${identity.siteUrl}${crumb.path}`,
189
+ })),
190
+ };
191
+ }