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,128 @@
1
+ ---
2
+ /**
3
+ * A single piece of content, whichever section it lives in.
4
+ *
5
+ * The page order is the AEO order: title, then the answer, then the byline,
6
+ * then a question-shaped table of contents, then the body. A reader who wants
7
+ * only the answer gets it above the fold; a crawler finds a self-contained
8
+ * summary next to the question and an outline it can cite by anchor.
9
+ */
10
+ import AnswerBlock from '../components/AnswerBlock.astro';
11
+ import JsonLd from '../components/JsonLd.astro';
12
+ import PostCta from '../components/PostCta.astro';
13
+ import BaseLayout from './BaseLayout.astro';
14
+ import {
15
+ answerFor,
16
+ authorPath,
17
+ postPath,
18
+ postUrl,
19
+ runtime,
20
+ type Author,
21
+ type Post,
22
+ type SiteIdentity,
23
+ } from '../lib/data.js';
24
+ import { renderBody } from '../lib/markdown.js';
25
+ import { articleJsonLd, breadcrumbJsonLd, faqJsonLd } from '../lib/seo.js';
26
+
27
+ interface Props {
28
+ post: Post;
29
+ byline: Author[];
30
+ identity: SiteIdentity;
31
+ /** The section this post is listed under, for the breadcrumb. */
32
+ section: { name: string; path: string };
33
+ }
34
+ const { post, byline, identity, section } = Astro.props;
35
+
36
+ const answer = answerFor(post);
37
+ const { html, headings } = renderBody(post.content);
38
+ const url = postUrl(post, identity);
39
+ const published = new Date(post.publishedAt);
40
+ ---
41
+
42
+ <BaseLayout
43
+ identity={identity}
44
+ seo={{
45
+ title: post.title,
46
+ description: answer?.summary ?? post.excerpt ?? undefined,
47
+ ogImageUrl: post.coverImageUrl ?? undefined,
48
+ path: postPath(post),
49
+ }}
50
+ >
51
+ <Fragment slot="jsonLd">
52
+ <JsonLd data={articleJsonLd(post, url, byline, identity)} />
53
+ <JsonLd data={faqJsonLd([answer])} />
54
+ <JsonLd
55
+ data={breadcrumbJsonLd(
56
+ [{ name: 'Home', path: '/' }, section, { name: post.title, path: postPath(post) }],
57
+ identity,
58
+ )}
59
+ />
60
+ </Fragment>
61
+
62
+ <article class="stack">
63
+ <header class="prose">
64
+ {post.category && <span class="tag">{post.category}</span>}
65
+ <h1>{post.title}</h1>
66
+ </header>
67
+
68
+ <AnswerBlock answer={answer} showQuestion={false} />
69
+
70
+ <p class="meta">
71
+ {
72
+ byline.length > 0 && (
73
+ <span>
74
+ By{' '}
75
+ {byline.map((author, index) => (
76
+ <>
77
+ {index > 0 && ', '}
78
+ <a href={authorPath(author)}>{author.name}</a>
79
+ </>
80
+ ))}
81
+ {' · '}
82
+ </span>
83
+ )
84
+ }
85
+ <time datetime={post.publishedAt}>
86
+ {published.toLocaleDateString('en', { year: 'numeric', month: 'long', day: 'numeric' })}
87
+ </time>
88
+ {post.readingMinutes && <span>{' · '}{post.readingMinutes} min read</span>}
89
+ </p>
90
+
91
+ {
92
+ post.coverImageUrl && (
93
+ <img src={post.coverImageUrl} alt="" width="1200" height="630" loading="eager" />
94
+ )
95
+ }
96
+
97
+ {
98
+ headings.length > 2 && (
99
+ <nav class="toc" aria-label="On this page">
100
+ <h2>On this page</h2>
101
+ <ol>
102
+ {headings
103
+ .filter((heading) => heading.depth === 2)
104
+ .map((heading) => (
105
+ <li>
106
+ <a href={`#${heading.id}`}>{heading.text}</a>
107
+ </li>
108
+ ))}
109
+ </ol>
110
+ </nav>
111
+ )
112
+ }
113
+
114
+ <div class="prose" set:html={html} />
115
+
116
+ <PostCta cta={post.cta} runtime={runtime} />
117
+
118
+ {
119
+ post.tags.length > 0 && (
120
+ <p class="meta">
121
+ Tagged: {post.tags.join(', ')}
122
+ </p>
123
+ )
124
+ }
125
+
126
+ <p><a href={section.path}>Back to {section.name.toLowerCase()}</a></p>
127
+ </article>
128
+ </BaseLayout>
@@ -0,0 +1,217 @@
1
+ /**
2
+ * THE DATA LAYER.
3
+ *
4
+ * Every page and component in this template gets its content from here and
5
+ * from nowhere else. This module picks a backend, exposes the contract from
6
+ * `types.ts`, and adds the handful of derived values pages need (paths, the
7
+ * answer block, the resources list).
8
+ *
9
+ * It contains no we8 imports of its own: the we8 adapter lives in
10
+ * `we8-backend.ts`, the static one in `fixture-backend.ts`, and this file just
11
+ * chooses. To point the site at something else, write a third backend and add
12
+ * it to `selectBackend` below.
13
+ */
14
+ import { createFixtureBackend } from './fixture-backend.js';
15
+ import { resolveWe8Backend, resolveWe8Runtime } from './we8-backend.js';
16
+ import { RESOURCE_TYPES } from './types.js';
17
+ import type {
18
+ AnswerBlock,
19
+ Author,
20
+ ContentType,
21
+ DocumentKind,
22
+ Post,
23
+ PostPage,
24
+ PostQuery,
25
+ RuntimeConfig,
26
+ SiteBackend,
27
+ SiteIdentity,
28
+ } from './types.js';
29
+
30
+ export type {
31
+ AnswerBlock,
32
+ Author,
33
+ AuthorLink,
34
+ CallToAction,
35
+ ContentType,
36
+ DocumentKind,
37
+ Post,
38
+ PostPage,
39
+ PostQuery,
40
+ RuntimeConfig,
41
+ SiteBackend,
42
+ SiteIdentity,
43
+ SiteSeo,
44
+ } from './types.js';
45
+ export { CONTENT_TYPES, RESOURCE_TYPES } from './types.js';
46
+
47
+ type EnvRecord = Record<string, string | undefined>;
48
+
49
+ /**
50
+ * Astro exposes `PUBLIC_`-prefixed variables on `import.meta.env`; this
51
+ * template widens `envPrefix` in `astro.config.mjs` so `WE8_`-prefixed ones
52
+ * come through too. `process.env` is the fallback for plain Node, which is how
53
+ * the tests and any script outside Astro read the same configuration.
54
+ */
55
+ function readEnv(): EnvRecord {
56
+ const fromAstro = import.meta.env as unknown as EnvRecord;
57
+ const fromNode = (globalThis as { process?: { env?: EnvRecord } }).process?.env ?? {};
58
+ return { ...fromNode, ...fromAstro };
59
+ }
60
+
61
+ /**
62
+ * Pick a backend.
63
+ *
64
+ * `WE8_DATA_SOURCE=fixtures` forces the static one even with a live API
65
+ * configured, which is what makes an offline build reproducible. Otherwise a
66
+ * complete we8 configuration wins, and an incomplete one falls back rather
67
+ * than failing the build: a template that cannot build without a backend is
68
+ * not backend-agnostic.
69
+ */
70
+ export function selectBackend(env: EnvRecord): SiteBackend {
71
+ if (env['WE8_DATA_SOURCE'] === 'fixtures') return createFixtureBackend();
72
+ return resolveWe8Backend(env) ?? createFixtureBackend();
73
+ }
74
+
75
+ /** The browser-side configuration, or a disabled one when nothing is live. */
76
+ export function selectRuntime(env: EnvRecord): RuntimeConfig {
77
+ if (env['WE8_DATA_SOURCE'] === 'fixtures') return { enabled: false, key: null, apiUrl: null };
78
+ return resolveWe8Runtime(env);
79
+ }
80
+
81
+ const env = readEnv();
82
+
83
+ /** The backend this build is reading from. */
84
+ export const backend: SiteBackend = selectBackend(env);
85
+
86
+ /**
87
+ * How the browser talks to the backend. When `enabled` is false the form, the
88
+ * consent banner, and the visit beacon are not rendered at all: a dead form is
89
+ * worse than no form.
90
+ */
91
+ export const runtime: RuntimeConfig = selectRuntime(env);
92
+
93
+ /* -------------------------------------------------------------------------- */
94
+ /* Content */
95
+ /* -------------------------------------------------------------------------- */
96
+
97
+ export function getIdentity(): Promise<SiteIdentity> {
98
+ return backend.identity();
99
+ }
100
+
101
+ export function getPosts(query?: PostQuery): Promise<PostPage> {
102
+ return backend.listPosts(query);
103
+ }
104
+
105
+ export function getAllPosts(): Promise<Post[]> {
106
+ return backend.listAllPosts();
107
+ }
108
+
109
+ /** Editorial content: what the blog lists. */
110
+ export async function getArticles(): Promise<Post[]> {
111
+ const posts = await backend.listAllPosts();
112
+ return posts.filter((post) => !RESOURCE_TYPES.includes(post.type));
113
+ }
114
+
115
+ /** Long-form material: what the resources page lays out. */
116
+ export async function getResources(): Promise<Post[]> {
117
+ const posts = await backend.listAllPosts();
118
+ return posts.filter((post) => RESOURCE_TYPES.includes(post.type));
119
+ }
120
+
121
+ export function getPost(slug: string): Promise<Post | null> {
122
+ return backend.getPost(slug);
123
+ }
124
+
125
+ export function getAuthors(): Promise<Author[]> {
126
+ return backend.listAuthors();
127
+ }
128
+
129
+ export function getAuthor(slug: string): Promise<Author | null> {
130
+ return backend.getAuthor(slug);
131
+ }
132
+
133
+ export async function getPostsByAuthor(slug: string): Promise<Post[]> {
134
+ const posts = await backend.listAllPosts();
135
+ return posts.filter((post) => post.authorSlugs.includes(slug));
136
+ }
137
+
138
+ export function getDocument(kind: DocumentKind): Promise<string> {
139
+ return backend.getDocument(kind);
140
+ }
141
+
142
+ /* -------------------------------------------------------------------------- */
143
+ /* Derived values */
144
+ /* -------------------------------------------------------------------------- */
145
+
146
+ /**
147
+ * The section this template serves each content type under.
148
+ *
149
+ * This is the template's own routing, and it is deliberately not read from the
150
+ * backend: `identity.contentPaths` is what the CMS believes the site's URLs
151
+ * are, which is advice, and following advice that disagrees with the pages
152
+ * this site actually renders would produce links to 404s. Change these values
153
+ * and rename the matching folders under `src/pages` together.
154
+ */
155
+ export const SECTIONS: Record<ContentType, string> = {
156
+ blog: '/blog',
157
+ article: '/blog',
158
+ news: '/blog',
159
+ research: '/resources',
160
+ whitepaper: '/resources',
161
+ 'case-study': '/resources',
162
+ };
163
+
164
+ /** The site-relative path a post is published at, for example `/blog/my-post`. */
165
+ export function postPath(post: Pick<Post, 'slug' | 'type'>): string {
166
+ return `${SECTIONS[post.type]}/${post.slug}`;
167
+ }
168
+
169
+ /** The absolute URL a post is published at. */
170
+ export function postUrl(post: Pick<Post, 'slug' | 'type'>, identity: SiteIdentity): string {
171
+ return `${identity.siteUrl}${postPath(post)}`;
172
+ }
173
+
174
+ export function authorPath(author: Pick<Author, 'slug'>): string {
175
+ return `/authors/${author.slug}`;
176
+ }
177
+
178
+ /**
179
+ * The answer-first block for a post, deriving one from the title and excerpt
180
+ * when the backend carries no authored answer. A derived block still leads
181
+ * with a short summary, which is the reader-facing half of AEO; it is marked
182
+ * `derived` so the JSON-LD does not present it as a curated answer.
183
+ */
184
+ export function answerFor(post: Post): AnswerBlock | null {
185
+ if (post.answer) return post.answer;
186
+ if (!post.excerpt) return null;
187
+ return { question: post.title, summary: post.excerpt, source: 'derived' };
188
+ }
189
+
190
+ /** Resolve the byline of a post against the author roster. */
191
+ export function bylineFor(post: Post, authors: Author[]): Author[] {
192
+ return post.authorSlugs
193
+ .map((slug) => authors.find((author) => author.slug === slug))
194
+ .filter((author): author is Author => author !== undefined);
195
+ }
196
+
197
+ /** The distinct content types present in a set of posts, in vocabulary order. */
198
+ export function typesPresent(posts: Post[]): ContentType[] {
199
+ const present = new Set(posts.map((post) => post.type));
200
+ return RESOURCE_TYPES.filter((type) => present.has(type));
201
+ }
202
+
203
+ /** Every absolute URL that belongs in the sitemap, site pages plus content. */
204
+ export async function getSitemapUrls(staticPaths: readonly string[]): Promise<string[]> {
205
+ const identity = await backend.identity();
206
+ const posts = await backend.listAllPosts();
207
+ const authors = await backend.listAuthors();
208
+ const own = [
209
+ ...staticPaths.map((path) => `${identity.siteUrl}${path}`),
210
+ ...posts.map((post) => postUrl(post, identity)),
211
+ ...authors.map((author) => `${identity.siteUrl}${authorPath(author)}`),
212
+ ];
213
+ // Anything the backend publishes that this site does not render. Merging it
214
+ // here is what keeps one sitemap authoritative instead of two partial ones.
215
+ const extra = await backend.extraSitemapUrls(identity.siteUrl);
216
+ return [...new Set([...own, ...extra])];
217
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The fixture backend: the data-layer contract implemented over static data.
3
+ *
4
+ * It exists for two reasons. It lets the template build and run with no CMS at
5
+ * all, which is what `npm run dev` does out of the box. And it is the proof
6
+ * that the contract in `types.ts` is real: two independent implementations,
7
+ * one interface, and every page written against the interface.
8
+ */
9
+ import { authors, identity, llmsTxt, posts } from './fixtures.js';
10
+ import type {
11
+ Author,
12
+ DocumentKind,
13
+ Post,
14
+ PostPage,
15
+ PostQuery,
16
+ SiteBackend,
17
+ SiteIdentity,
18
+ } from './types.js';
19
+
20
+ function byNewest(a: Post, b: Post): number {
21
+ return b.publishedAt.localeCompare(a.publishedAt);
22
+ }
23
+
24
+ function matches(post: Post, query: PostQuery): boolean {
25
+ if (query.type && post.type !== query.type) return false;
26
+ if (query.tag && !post.tags.includes(query.tag)) return false;
27
+ if (query.category && post.category !== query.category) return false;
28
+ return true;
29
+ }
30
+
31
+ export function createFixtureBackend(): SiteBackend {
32
+ const all = [...posts].sort(byNewest);
33
+
34
+ return {
35
+ name: 'fixtures',
36
+
37
+ identity(): Promise<SiteIdentity> {
38
+ return Promise.resolve(identity);
39
+ },
40
+
41
+ listPosts(query: PostQuery = {}): Promise<PostPage> {
42
+ const filtered = all.filter((post) => matches(post, query));
43
+ const page = Math.max(1, query.page ?? 1);
44
+ const pageSize = Math.max(1, query.pageSize ?? 10);
45
+ const start = (page - 1) * pageSize;
46
+ return Promise.resolve({
47
+ items: filtered.slice(start, start + pageSize),
48
+ page,
49
+ pageSize,
50
+ total: filtered.length,
51
+ });
52
+ },
53
+
54
+ listAllPosts(): Promise<Post[]> {
55
+ return Promise.resolve(all);
56
+ },
57
+
58
+ getPost(slug: string): Promise<Post | null> {
59
+ return Promise.resolve(all.find((post) => post.slug === slug) ?? null);
60
+ },
61
+
62
+ listAuthors(): Promise<Author[]> {
63
+ // Only authors with at least one published post, so the fixture set and
64
+ // a live CMS behave the same way: an author page with nothing on it is
65
+ // a dead end either way.
66
+ const bylined = new Set(all.flatMap((post) => post.authorSlugs));
67
+ return Promise.resolve(authors.filter((author) => bylined.has(author.slug)));
68
+ },
69
+
70
+ getAuthor(slug: string): Promise<Author | null> {
71
+ return Promise.resolve(authors.find((author) => author.slug === slug) ?? null);
72
+ },
73
+
74
+ getDocument(kind: DocumentKind): Promise<string> {
75
+ return Promise.resolve(kind === 'llms-txt' ? llmsTxt : '');
76
+ },
77
+
78
+ extraSitemapUrls(): Promise<string[]> {
79
+ // The fixture set is fully rendered by this site, so there is nothing to
80
+ // merge in.
81
+ return Promise.resolve([]);
82
+ },
83
+ };
84
+ }