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,312 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { answerFor, selectBackend, selectRuntime } from '../src/lib/data.js';
|
|
3
|
+
import { createFixtureBackend } from '../src/lib/fixture-backend.js';
|
|
4
|
+
import { createWe8Backend } from '../src/lib/we8-backend.js';
|
|
5
|
+
import type { Post, SiteBackend } from '../src/lib/types.js';
|
|
6
|
+
|
|
7
|
+
const CONFIG = { key: 'pk_test', apiUrl: 'https://cms.example.com/api' };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A stand-in for a live we8 API: enough of the `/v1` dialect for the backend
|
|
11
|
+
* to build its content graph, and nothing more.
|
|
12
|
+
*/
|
|
13
|
+
function fakeApi() {
|
|
14
|
+
const posts = [
|
|
15
|
+
{
|
|
16
|
+
slug: 'first-post',
|
|
17
|
+
type: 'blog',
|
|
18
|
+
title: 'How do I read an hour meter?',
|
|
19
|
+
excerpt: 'Once a week, on paper, next to the machine.',
|
|
20
|
+
content: '# Body\n\n## Why weekly?\n\nBecause a month is too coarse.\n',
|
|
21
|
+
coverImageUrl: null,
|
|
22
|
+
category: 'Practice',
|
|
23
|
+
tags: ['metering'],
|
|
24
|
+
authorIds: ['aut_1'],
|
|
25
|
+
wordCount: 440,
|
|
26
|
+
cta: null,
|
|
27
|
+
publishedAt: '2026-05-01T00:00:00.000Z',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
slug: 'a-toolkit',
|
|
31
|
+
type: 'whitepaper',
|
|
32
|
+
title: 'The runtime log',
|
|
33
|
+
excerpt: 'A printable twelve month log.',
|
|
34
|
+
content: 'Body.',
|
|
35
|
+
coverImageUrl: null,
|
|
36
|
+
category: 'Toolkit',
|
|
37
|
+
tags: [],
|
|
38
|
+
authorIds: [],
|
|
39
|
+
wordCount: null,
|
|
40
|
+
cta: {
|
|
41
|
+
style: 'gated-download',
|
|
42
|
+
buttonLabel: 'Send it',
|
|
43
|
+
formKey: 'contact',
|
|
44
|
+
assetUrl: 'https://cms.example.com/log.pdf',
|
|
45
|
+
},
|
|
46
|
+
publishedAt: '2026-06-01T00:00:00.000Z',
|
|
47
|
+
// The flat AEO fields as the CMS ships them.
|
|
48
|
+
answerSummary: 'A weekly record of hour meters.',
|
|
49
|
+
questionHeading: 'What is a runtime log?',
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const author = {
|
|
54
|
+
id: 'aut_1',
|
|
55
|
+
slug: 'jane-okonjo',
|
|
56
|
+
name: 'Jane Okonjo',
|
|
57
|
+
type: 'team',
|
|
58
|
+
roleTitle: 'Founder',
|
|
59
|
+
links: [{ label: 'Email', url: 'mailto:jane@example.com' }],
|
|
60
|
+
createdAt: '2026-01-01',
|
|
61
|
+
updatedAt: '2026-01-01',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const site = {
|
|
65
|
+
name: 'Fake Co',
|
|
66
|
+
siteUrl: 'https://fake.example/',
|
|
67
|
+
seo: { defaultTitle: 'Fake Co', discourageSearchEngines: false },
|
|
68
|
+
publisher: { name: 'Fake Co' },
|
|
69
|
+
postsPathPrefix: '/insights',
|
|
70
|
+
contentPaths: { news: '/newsroom' },
|
|
71
|
+
gaMeasurementId: null,
|
|
72
|
+
brand: {},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const calls: string[] = [];
|
|
76
|
+
const fn: typeof fetch = (input) => {
|
|
77
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
78
|
+
calls.push(url);
|
|
79
|
+
const path = new URL(url).pathname;
|
|
80
|
+
const body = ((): unknown => {
|
|
81
|
+
if (path === '/api/v1/site') return { ok: true, site };
|
|
82
|
+
if (path === '/api/v1/posts') {
|
|
83
|
+
const page = Number(new URL(url).searchParams.get('page') ?? '1');
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
items: page === 1 ? posts : [],
|
|
87
|
+
page,
|
|
88
|
+
pageSize: 50,
|
|
89
|
+
total: posts.length,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const detail = posts.find((post) => path === `/api/v1/posts/${post.slug}`);
|
|
93
|
+
if (detail) {
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
post: detail,
|
|
97
|
+
authors: detail.authorIds.length ? [author] : [],
|
|
98
|
+
jsonld: {},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (path.startsWith('/api/v1/documents/')) {
|
|
102
|
+
return { ok: true, document: { kind: 'llms-txt', content: '# Fake Co', updatedAt: null } };
|
|
103
|
+
}
|
|
104
|
+
return { ok: false, error: 'no route', code: 'not_found' };
|
|
105
|
+
})();
|
|
106
|
+
return Promise.resolve(
|
|
107
|
+
new Response(JSON.stringify(body), {
|
|
108
|
+
status: 200,
|
|
109
|
+
headers: { 'content-type': 'application/json' },
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
};
|
|
113
|
+
return { fn, calls };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The point of the contract: run the SAME assertions against both
|
|
118
|
+
* implementations. Anything that passes here is safe for a page to rely on
|
|
119
|
+
* whichever backend a build selects.
|
|
120
|
+
*/
|
|
121
|
+
const backends: [string, () => SiteBackend][] = [
|
|
122
|
+
['fixtures', () => createFixtureBackend()],
|
|
123
|
+
['we8', () => createWe8Backend(CONFIG, fakeApi().fn)],
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
describe.each(backends)('SiteBackend contract: %s', (_name, make) => {
|
|
127
|
+
it('reports an identity with a trailing-slash-free site URL and every content path', async () => {
|
|
128
|
+
const identity = await make().identity();
|
|
129
|
+
expect(identity.name.length).toBeGreaterThan(0);
|
|
130
|
+
expect(identity.siteUrl).not.toMatch(/\/$/);
|
|
131
|
+
expect(identity.siteUrl).toMatch(/^https?:\/\//);
|
|
132
|
+
expect(typeof identity.seo.noindex).toBe('boolean');
|
|
133
|
+
expect(Object.keys(identity.contentPaths)).toHaveLength(6);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('lists posts newest first', async () => {
|
|
137
|
+
const posts = await make().listAllPosts();
|
|
138
|
+
expect(posts.length).toBeGreaterThan(0);
|
|
139
|
+
const dates = posts.map((post) => post.publishedAt);
|
|
140
|
+
expect([...dates].sort().reverse()).toEqual(dates);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('returns fully-formed posts: nulls, never undefined', async () => {
|
|
144
|
+
const posts = await make().listAllPosts();
|
|
145
|
+
for (const post of posts) {
|
|
146
|
+
expect(post.excerpt === null || typeof post.excerpt === 'string').toBe(true);
|
|
147
|
+
expect(post.coverImageUrl === null || typeof post.coverImageUrl === 'string').toBe(true);
|
|
148
|
+
expect(post.category === null || typeof post.category === 'string').toBe(true);
|
|
149
|
+
expect(Array.isArray(post.tags)).toBe(true);
|
|
150
|
+
expect(Array.isArray(post.authorSlugs)).toBe(true);
|
|
151
|
+
expect(post.cta === null || typeof post.cta.buttonLabel === 'string').toBe(true);
|
|
152
|
+
expect(post.readingMinutes === null || post.readingMinutes > 0).toBe(true);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('pages and filters', async () => {
|
|
157
|
+
const backend = make();
|
|
158
|
+
const all = await backend.listAllPosts();
|
|
159
|
+
const first = await backend.listPosts({ page: 1, pageSize: 1 });
|
|
160
|
+
expect(first.items).toHaveLength(1);
|
|
161
|
+
expect(first.total).toBe(all.length);
|
|
162
|
+
|
|
163
|
+
const type = all[0]?.type;
|
|
164
|
+
const filtered = await backend.listPosts({ ...(type ? { type } : {}), pageSize: 50 });
|
|
165
|
+
expect(filtered.items.every((post) => post.type === type)).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('resolves a post by slug and returns null for an unknown one', async () => {
|
|
169
|
+
const backend = make();
|
|
170
|
+
const all = await backend.listAllPosts();
|
|
171
|
+
const slug = all[0]?.slug ?? '';
|
|
172
|
+
expect((await backend.getPost(slug))?.slug).toBe(slug);
|
|
173
|
+
expect(await backend.getPost('no-such-post')).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('resolves authors by the slugs its posts reference', async () => {
|
|
177
|
+
const backend = make();
|
|
178
|
+
const posts = await backend.listAllPosts();
|
|
179
|
+
const authors = await backend.listAuthors();
|
|
180
|
+
const known = new Set(authors.map((author) => author.slug));
|
|
181
|
+
for (const post of posts) {
|
|
182
|
+
for (const slug of post.authorSlugs) expect(known.has(slug)).toBe(true);
|
|
183
|
+
}
|
|
184
|
+
const first = authors[0];
|
|
185
|
+
if (first) expect((await backend.getAuthor(first.slug))?.name).toBe(first.name);
|
|
186
|
+
expect(await backend.getAuthor('nobody')).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('serves a document body as a string, empty rather than throwing when absent', async () => {
|
|
190
|
+
expect(typeof (await make().getDocument('llms-txt'))).toBe('string');
|
|
191
|
+
expect(typeof (await make().getDocument('design-md'))).toBe('string');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('merges no duplicate sitemap URLs for content it already renders', async () => {
|
|
195
|
+
const backend = make();
|
|
196
|
+
await backend.listAllPosts();
|
|
197
|
+
expect(await backend.extraSitemapUrls('https://site.example')).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe('the we8 backend and the /v1 dialect', () => {
|
|
202
|
+
it('resolves the byline through the detail route, not through author ids', async () => {
|
|
203
|
+
const backend = createWe8Backend(CONFIG, fakeApi().fn);
|
|
204
|
+
const post = await backend.getPost('first-post');
|
|
205
|
+
expect(post?.authorSlugs).toEqual(['jane-okonjo']);
|
|
206
|
+
const author = await backend.getAuthor('jane-okonjo');
|
|
207
|
+
expect(author?.roleTitle).toBe('Founder');
|
|
208
|
+
expect(author?.bio).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('picks up answer fields the moment the API carries them, and tolerates their absence', async () => {
|
|
212
|
+
const backend = createWe8Backend(CONFIG, fakeApi().fn);
|
|
213
|
+
expect(await backend.getPost('a-toolkit').then((post) => post?.answer)).toEqual({
|
|
214
|
+
question: 'What is a runtime log?',
|
|
215
|
+
summary: 'A weekly record of hour meters.',
|
|
216
|
+
source: 'authored',
|
|
217
|
+
});
|
|
218
|
+
// The post without the fields is not an error; it just has no block.
|
|
219
|
+
expect(await backend.getPost('first-post').then((post) => post?.answer)).toBeNull();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('fills every content path, falling back to the universal prefix', async () => {
|
|
223
|
+
const identity = await createWe8Backend(CONFIG, fakeApi().fn).identity();
|
|
224
|
+
expect(identity.contentPaths.news).toBe('/newsroom');
|
|
225
|
+
expect(identity.contentPaths.blog).toBe('/insights');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('loads the content graph once, however many times pages ask', async () => {
|
|
229
|
+
const api = fakeApi();
|
|
230
|
+
const backend = createWe8Backend(CONFIG, api.fn);
|
|
231
|
+
await backend.listAllPosts();
|
|
232
|
+
await backend.listAuthors();
|
|
233
|
+
await backend.getPost('first-post');
|
|
234
|
+
const listCalls = api.calls.filter((url) => url.includes('/v1/posts?')).length;
|
|
235
|
+
expect(listCalls).toBe(1);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('converts a CTA into the site vocabulary', async () => {
|
|
239
|
+
const post = await createWe8Backend(CONFIG, fakeApi().fn).getPost('a-toolkit');
|
|
240
|
+
expect(post?.cta).toEqual({
|
|
241
|
+
style: 'gated-download',
|
|
242
|
+
heading: null,
|
|
243
|
+
buttonLabel: 'Send it',
|
|
244
|
+
url: null,
|
|
245
|
+
formKey: 'contact',
|
|
246
|
+
assetUrl: 'https://cms.example.com/log.pdf',
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('backend selection', () => {
|
|
252
|
+
const live = { WE8_API_URL: 'https://cms.example.com/api', WE8_PUBLISHABLE_KEY: 'pk_x' };
|
|
253
|
+
|
|
254
|
+
it('uses we8 when both variables are set', () => {
|
|
255
|
+
expect(selectBackend(live).name).toBe('we8');
|
|
256
|
+
expect(selectRuntime(live)).toEqual({
|
|
257
|
+
enabled: true,
|
|
258
|
+
key: 'pk_x',
|
|
259
|
+
apiUrl: 'https://cms.example.com/api',
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('falls back to fixtures rather than failing when configuration is incomplete', () => {
|
|
264
|
+
expect(selectBackend({}).name).toBe('fixtures');
|
|
265
|
+
expect(selectBackend({ WE8_API_URL: live.WE8_API_URL }).name).toBe('fixtures');
|
|
266
|
+
expect(selectBackend({ WE8_PUBLISHABLE_KEY: 'pk_x' }).name).toBe('fixtures');
|
|
267
|
+
expect(selectRuntime({}).enabled).toBe(false);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('honors an explicit fixtures flag even with a live API configured', () => {
|
|
271
|
+
const forced = { ...live, WE8_DATA_SOURCE: 'fixtures' };
|
|
272
|
+
expect(selectBackend(forced).name).toBe('fixtures');
|
|
273
|
+
// And the browser half goes dark with it: no key is embedded in a build
|
|
274
|
+
// that is deliberately not talking to the backend.
|
|
275
|
+
expect(selectRuntime(forced)).toEqual({ enabled: false, key: null, apiUrl: null });
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe('answerFor', () => {
|
|
280
|
+
const base: Post = {
|
|
281
|
+
slug: 's',
|
|
282
|
+
type: 'blog',
|
|
283
|
+
title: 'How do I do the thing?',
|
|
284
|
+
excerpt: 'Like this.',
|
|
285
|
+
content: '',
|
|
286
|
+
coverImageUrl: null,
|
|
287
|
+
category: null,
|
|
288
|
+
tags: [],
|
|
289
|
+
authorSlugs: [],
|
|
290
|
+
publishedAt: '2026-01-01T00:00:00.000Z',
|
|
291
|
+
answer: null,
|
|
292
|
+
cta: null,
|
|
293
|
+
readingMinutes: null,
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
it('prefers an authored block', () => {
|
|
297
|
+
const authored = { question: 'Q', summary: 'A', source: 'authored' as const };
|
|
298
|
+
expect(answerFor({ ...base, answer: authored })).toBe(authored);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('derives one from the title and excerpt, marked as derived', () => {
|
|
302
|
+
expect(answerFor(base)).toEqual({
|
|
303
|
+
question: 'How do I do the thing?',
|
|
304
|
+
summary: 'Like this.',
|
|
305
|
+
source: 'derived',
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('returns null when there is nothing honest to say', () => {
|
|
310
|
+
expect(answerFor({ ...base, excerpt: null })).toBeNull();
|
|
311
|
+
});
|
|
312
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
articleJsonLd,
|
|
4
|
+
faqJsonLd,
|
|
5
|
+
resolveSeo,
|
|
6
|
+
robotsTxt,
|
|
7
|
+
sitemapXml,
|
|
8
|
+
} from '../src/lib/seo.js';
|
|
9
|
+
import { outline, renderBody, slugifyHeading } from '../src/lib/markdown.js';
|
|
10
|
+
import type { Post, SiteIdentity } from '../src/lib/types.js';
|
|
11
|
+
|
|
12
|
+
function identity(overrides: Partial<SiteIdentity['seo']> = {}): SiteIdentity {
|
|
13
|
+
return {
|
|
14
|
+
name: 'Northgate Tools',
|
|
15
|
+
siteUrl: 'https://northgatetools.example',
|
|
16
|
+
seo: {
|
|
17
|
+
defaultTitle: null,
|
|
18
|
+
titleTemplate: null,
|
|
19
|
+
metaDescription: null,
|
|
20
|
+
ogImageUrl: null,
|
|
21
|
+
twitterHandle: null,
|
|
22
|
+
noindex: false,
|
|
23
|
+
...overrides,
|
|
24
|
+
},
|
|
25
|
+
publisher: { name: 'Northgate Tools', logoUrl: null },
|
|
26
|
+
contentPaths: {
|
|
27
|
+
blog: '/blog',
|
|
28
|
+
article: '/blog',
|
|
29
|
+
news: '/blog',
|
|
30
|
+
research: '/resources',
|
|
31
|
+
whitepaper: '/resources',
|
|
32
|
+
'case-study': '/resources',
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('resolveSeo (code-first: the page wins, the site fills)', () => {
|
|
38
|
+
it('applies the title template to a page title', () => {
|
|
39
|
+
expect(resolveSeo({ title: 'Blog' }, identity({ titleTemplate: '%s | Northgate' })).title).toBe(
|
|
40
|
+
'Blog | Northgate',
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('falls back to defaultTitle WITHOUT the template, then to the site name', () => {
|
|
45
|
+
expect(
|
|
46
|
+
resolveSeo({}, identity({ defaultTitle: 'Northgate: tools', titleTemplate: '%s | X' })).title,
|
|
47
|
+
).toBe('Northgate: tools');
|
|
48
|
+
expect(resolveSeo({}, identity()).title).toBe('Northgate Tools');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('lets a page description beat the site fallback', () => {
|
|
52
|
+
const resolved = resolveSeo(
|
|
53
|
+
{ description: 'Page copy.' },
|
|
54
|
+
identity({ metaDescription: 'Site copy.' }),
|
|
55
|
+
);
|
|
56
|
+
expect(resolved.description).toBe('Page copy.');
|
|
57
|
+
expect(resolved.ogDescription).toBe('Page copy.');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('builds an absolute canonical from the page path only', () => {
|
|
61
|
+
expect(resolveSeo({ path: '/blog' }, identity()).canonical).toBe(
|
|
62
|
+
'https://northgatetools.example/blog',
|
|
63
|
+
);
|
|
64
|
+
expect(resolveSeo({}, identity()).canonical).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('carries the site noindex flag, and lets a page force it', () => {
|
|
68
|
+
expect(resolveSeo({}, identity()).noindex).toBe(false);
|
|
69
|
+
expect(resolveSeo({}, identity({ noindex: true })).noindex).toBe(true);
|
|
70
|
+
expect(resolveSeo({ noindex: true }, identity()).noindex).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('robotsTxt', () => {
|
|
75
|
+
it('disallows everything when the site asks to be left alone', () => {
|
|
76
|
+
expect(robotsTxt(identity({ noindex: true }))).toBe('User-agent: *\nDisallow: /\n');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('advertises the sitemap and llms.txt when indexable', () => {
|
|
80
|
+
const txt = robotsTxt(identity());
|
|
81
|
+
expect(txt).toContain('Sitemap: https://northgatetools.example/sitemap.xml');
|
|
82
|
+
expect(txt).toContain('llms.txt');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('sitemapXml', () => {
|
|
87
|
+
it('escapes the ampersands a query string brings', () => {
|
|
88
|
+
const xml = sitemapXml(['https://x.example/a?b=1&c=2']);
|
|
89
|
+
expect(xml).toContain('<loc>https://x.example/a?b=1&c=2</loc>');
|
|
90
|
+
expect(xml).not.toContain('&c=2');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const post: Post = {
|
|
95
|
+
slug: 'p',
|
|
96
|
+
type: 'blog',
|
|
97
|
+
title: 'How do I read an hour meter?',
|
|
98
|
+
excerpt: 'Weekly, on paper.',
|
|
99
|
+
content: '',
|
|
100
|
+
coverImageUrl: null,
|
|
101
|
+
category: null,
|
|
102
|
+
tags: ['metering'],
|
|
103
|
+
authorSlugs: ['jane'],
|
|
104
|
+
publishedAt: '2026-05-01T00:00:00.000Z',
|
|
105
|
+
answer: null,
|
|
106
|
+
cta: null,
|
|
107
|
+
readingMinutes: 3,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
describe('faqJsonLd', () => {
|
|
111
|
+
it('publishes authored answers as an FAQPage', () => {
|
|
112
|
+
const json = faqJsonLd([{ question: 'Q?', summary: 'A.', source: 'authored' }]);
|
|
113
|
+
expect(json?.['@type']).toBe('FAQPage');
|
|
114
|
+
expect(json?.['mainEntity']).toEqual([
|
|
115
|
+
{ '@type': 'Question', name: 'Q?', acceptedAnswer: { '@type': 'Answer', text: 'A.' } },
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('HONESTY: never publishes a derived block, and emits nothing at all when none qualify', () => {
|
|
120
|
+
expect(faqJsonLd([{ question: 'Q?', summary: 'A.', source: 'derived' }])).toBeNull();
|
|
121
|
+
expect(faqJsonLd([null])).toBeNull();
|
|
122
|
+
const mixed = faqJsonLd([
|
|
123
|
+
{ question: 'Q1', summary: 'A1', source: 'derived' },
|
|
124
|
+
{ question: 'Q2', summary: 'A2', source: 'authored' },
|
|
125
|
+
]);
|
|
126
|
+
expect(mixed?.['mainEntity']).toHaveLength(1);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('articleJsonLd', () => {
|
|
131
|
+
it('maps the content type to a schema.org type and links the byline to its author page', () => {
|
|
132
|
+
const json = articleJsonLd(
|
|
133
|
+
post,
|
|
134
|
+
'https://northgatetools.example/blog/p',
|
|
135
|
+
[{ slug: 'jane', name: 'Jane', roleTitle: null, bio: null, avatarUrl: null, links: [] }],
|
|
136
|
+
identity(),
|
|
137
|
+
);
|
|
138
|
+
expect(json['@type']).toBe('BlogPosting');
|
|
139
|
+
expect(json['author']).toEqual([
|
|
140
|
+
{
|
|
141
|
+
'@type': 'Person',
|
|
142
|
+
name: 'Jane',
|
|
143
|
+
url: 'https://northgatetools.example/authors/jane',
|
|
144
|
+
},
|
|
145
|
+
]);
|
|
146
|
+
expect(articleJsonLd({ ...post, type: 'news' }, 'u', [], identity())['@type']).toBe(
|
|
147
|
+
'NewsArticle',
|
|
148
|
+
);
|
|
149
|
+
expect(articleJsonLd({ ...post, type: 'whitepaper' }, 'u', [], identity())['@type']).toBe(
|
|
150
|
+
'Article',
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('markdown outline', () => {
|
|
156
|
+
const body = '# Title\n\n## Why weekly?\n\ntext\n\n### A detail\n\n## What then?\n\ntext\n';
|
|
157
|
+
|
|
158
|
+
it('collects h2 and h3 headings, and never the h1', () => {
|
|
159
|
+
expect(outline(body)).toEqual([
|
|
160
|
+
{ depth: 2, text: 'Why weekly?', id: 'why-weekly' },
|
|
161
|
+
{ depth: 3, text: 'A detail', id: 'a-detail' },
|
|
162
|
+
{ depth: 2, text: 'What then?', id: 'what-then' },
|
|
163
|
+
]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('slugifies punctuation out of an anchor', () => {
|
|
167
|
+
expect(slugifyHeading('How often, really?')).toBe('how-often-really');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('anchors every heading in the rendered HTML, in order', () => {
|
|
171
|
+
const { html, headings } = renderBody(body);
|
|
172
|
+
expect(headings).toHaveLength(3);
|
|
173
|
+
expect(html).toContain('<h2 id="why-weekly">Why weekly?</h2>');
|
|
174
|
+
expect(html).toContain('<h3 id="a-detail">A detail</h3>');
|
|
175
|
+
expect(html).toContain('<h2 id="what-then">What then?</h2>');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "astro/tsconfigs/strict",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"customConditions": ["@we8/source"],
|
|
5
|
+
"strictNullChecks": true,
|
|
6
|
+
"noUncheckedIndexedAccess": true,
|
|
7
|
+
"verbatimModuleSyntax": true
|
|
8
|
+
},
|
|
9
|
+
"include": [".astro/types.d.ts", "**/*"],
|
|
10
|
+
"exclude": ["dist"]
|
|
11
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
// Resolve the workspace packages to their TypeScript source, mirroring the
|
|
4
|
+
// tsconfig `customConditions: ["@we8/source"]`, so the data-layer tests run
|
|
5
|
+
// without a prior build of the sibling packages.
|
|
6
|
+
const root = new URL('../../packages/', import.meta.url).pathname;
|
|
7
|
+
|
|
8
|
+
export default defineConfig({
|
|
9
|
+
test: {
|
|
10
|
+
include: ['test/**/*.test.ts'],
|
|
11
|
+
},
|
|
12
|
+
resolve: {
|
|
13
|
+
alias: {
|
|
14
|
+
'@we8/astro': `${root}astro/src/index.ts`,
|
|
15
|
+
'@we8/client': `${root}client/src/index.ts`,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|