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,75 @@
1
+ ---
2
+ /**
3
+ * The blog listing: editorial content, newest first.
4
+ *
5
+ * Resource types (whitepapers, case studies, research) are deliberately not
6
+ * here; they have their own page. Every card leads with the post's answer
7
+ * summary rather than the first paragraph of its body, so the listing itself
8
+ * is scannable and quotable.
9
+ */
10
+ import JsonLd from '../../components/JsonLd.astro';
11
+ import PostCard from '../../components/PostCard.astro';
12
+ import BaseLayout from '../../layouts/BaseLayout.astro';
13
+ import { getArticles, getIdentity, postUrl } from '../../lib/data.js';
14
+ import { breadcrumbJsonLd } from '../../lib/seo.js';
15
+
16
+ const identity = await getIdentity();
17
+ const posts = await getArticles();
18
+
19
+ const blogJsonLd = {
20
+ '@context': 'https://schema.org',
21
+ '@type': 'Blog',
22
+ name: `${identity.name} blog`,
23
+ url: `${identity.siteUrl}/blog`,
24
+ blogPost: posts.map((post) => ({
25
+ '@type': 'BlogPosting',
26
+ headline: post.title,
27
+ url: postUrl(post, identity),
28
+ datePublished: post.publishedAt,
29
+ })),
30
+ };
31
+ ---
32
+
33
+ <BaseLayout
34
+ identity={identity}
35
+ seo={{
36
+ title: 'Blog',
37
+ description:
38
+ 'Practical writing on maintenance scheduling, work orders, and downtime measurement for workshops under fifty machines.',
39
+ }}
40
+ >
41
+ <Fragment slot="jsonLd">
42
+ <JsonLd data={blogJsonLd} />
43
+ <JsonLd
44
+ data={breadcrumbJsonLd(
45
+ [
46
+ { name: 'Home', path: '/' },
47
+ { name: 'Blog', path: '/blog' },
48
+ ],
49
+ identity,
50
+ )}
51
+ />
52
+ </Fragment>
53
+
54
+ <div class="stack">
55
+ <section class="prose">
56
+ <h1>Blog</h1>
57
+ <p class="lede">
58
+ Each post opens with the question it answers and a summary short enough to quote. Read the
59
+ summary, and read the rest only if you need the reasoning.
60
+ </p>
61
+ </section>
62
+
63
+ {
64
+ posts.length === 0 ? (
65
+ <p class="notice">No posts published yet.</p>
66
+ ) : (
67
+ <ul class="grid">
68
+ {posts.map((post) => (
69
+ <PostCard post={post} />
70
+ ))}
71
+ </ul>
72
+ )
73
+ }
74
+ </div>
75
+ </BaseLayout>
@@ -0,0 +1,106 @@
1
+ ---
2
+ /**
3
+ * Contact.
4
+ *
5
+ * The form is `<We8Form>` from `@we8/astro`: a real `<form>` element that a
6
+ * script upgrades to submit through the API. Without JS the markup is still a
7
+ * valid, labelled, accessible form, so the fallback below is a plain mailto
8
+ * rather than a promise the page cannot keep.
9
+ *
10
+ * With no backend configured the form is not rendered at all. A form that
11
+ * silently discards a message is worse than a page that says to email us.
12
+ */
13
+ import We8Form from '@we8/astro/We8Form.astro';
14
+ import JsonLd from '../components/JsonLd.astro';
15
+ import BaseLayout from '../layouts/BaseLayout.astro';
16
+ import { getIdentity, runtime } from '../lib/data.js';
17
+ import { breadcrumbJsonLd } from '../lib/seo.js';
18
+
19
+ const identity = await getIdentity();
20
+
21
+ /**
22
+ * The form's key in the CMS. Create a form with this key in the admin, or
23
+ * change the value here to match one you already have.
24
+ */
25
+ const FORM_KEY = 'contact';
26
+ const live = runtime.enabled && runtime.key !== null;
27
+ ---
28
+
29
+ <BaseLayout
30
+ identity={identity}
31
+ seo={{
32
+ title: 'Contact',
33
+ description: `Get in touch with ${identity.name}. We answer within two working days, and there is no sales sequence behind it.`,
34
+ }}
35
+ >
36
+ <JsonLd
37
+ slot="jsonLd"
38
+ data={breadcrumbJsonLd(
39
+ [
40
+ { name: 'Home', path: '/' },
41
+ { name: 'Contact', path: '/contact' },
42
+ ],
43
+ identity,
44
+ )}
45
+ />
46
+
47
+ <div class="stack prose">
48
+ <h1>Contact</h1>
49
+ <p class="lede">
50
+ Tell us how many machines you run and what breaks most. That is enough for a useful first
51
+ reply.
52
+ </p>
53
+
54
+ {
55
+ live ? (
56
+ <We8Form
57
+ formKey={FORM_KEY}
58
+ key={runtime.key ?? undefined}
59
+ apiUrl={runtime.apiUrl ?? undefined}
60
+ successMessage="Thanks. We answer within two working days."
61
+ >
62
+ <div class="field">
63
+ <label for="name">Your name</label>
64
+ <input id="name" name="name" type="text" required autocomplete="name" />
65
+ </div>
66
+
67
+ <div class="field">
68
+ <label for="email">Email</label>
69
+ <input id="email" name="email" type="email" required autocomplete="email" />
70
+ </div>
71
+
72
+ <div class="field">
73
+ <label for="machines">How many machines?</label>
74
+ <input id="machines" name="machines" type="text" inputmode="numeric" />
75
+ </div>
76
+
77
+ <div class="field">
78
+ <label for="message">What would you like to know?</label>
79
+ <textarea id="message" name="message" required />
80
+ </div>
81
+
82
+ {/* A honeypot: bots fill it, people never see it. The API drops any
83
+ submission that carries a value here. */}
84
+ <div class="honeypot" aria-hidden="true">
85
+ <label for="website">Leave this empty</label>
86
+ <input id="website" name="website" type="text" tabindex="-1" autocomplete="off" />
87
+ </div>
88
+
89
+ <button type="submit">Send message</button>
90
+ </We8Form>
91
+ ) : (
92
+ <p class="notice">
93
+ This build has no backend configured, so the contact form is not rendered. Set
94
+ <code> WE8_API_URL</code> and <code> WE8_PUBLISHABLE_KEY</code>, create a form with the
95
+ key <code>{FORM_KEY}</code> in your admin, and it appears here.
96
+ </p>
97
+ )
98
+ }
99
+
100
+ <h2>Would you rather just email?</h2>
101
+ <p>
102
+ <a href="mailto:hello@northgatetools.example">hello@northgatetools.example</a> reaches the same
103
+ inbox. The form exists so you do not have to leave the page.
104
+ </p>
105
+ </div>
106
+ </BaseLayout>
@@ -0,0 +1,106 @@
1
+ ---
2
+ /**
3
+ * Home.
4
+ *
5
+ * Answer-first, like every page here: the hero states what this is in one
6
+ * sentence, the "What is this?" block answers the question a stranger actually
7
+ * has, and the recent writing follows. No carousel, no hero image, nothing
8
+ * that has to load before the point arrives.
9
+ */
10
+ import AnswerBlock from '../components/AnswerBlock.astro';
11
+ import JsonLd from '../components/JsonLd.astro';
12
+ import PostCard from '../components/PostCard.astro';
13
+ import BaseLayout from '../layouts/BaseLayout.astro';
14
+ import { getArticles, getIdentity, getResources } from '../lib/data.js';
15
+ import { faqJsonLd } from '../lib/seo.js';
16
+
17
+ const identity = await getIdentity();
18
+ const articles = (await getArticles()).slice(0, 3);
19
+ const resources = (await getResources()).slice(0, 3);
20
+
21
+ /**
22
+ * The home page's own answer blocks. On a content page these come from the
23
+ * CMS; here the site is answering questions about itself, so they are written
24
+ * in place and marked `authored`, which is what they are.
25
+ */
26
+ const faqs = [
27
+ {
28
+ question: `What does ${identity.name} do?`,
29
+ summary:
30
+ 'We make maintenance planning software for workshops with fewer than fifty machines. It schedules servicing on runtime hours, records work orders in six fields, and reports downtime from two timestamps. It does not require a consultant to set up.',
31
+ source: 'authored' as const,
32
+ },
33
+ {
34
+ question: 'Who is it for?',
35
+ summary:
36
+ 'Shops where one or two people handle maintenance alongside other work, and where a full enterprise system would cost more in setup time than it saves in a year. If you have a dedicated reliability team, you have outgrown us.',
37
+ source: 'authored' as const,
38
+ },
39
+ {
40
+ question: 'What does it cost?',
41
+ summary:
42
+ 'A flat monthly fee per site, with no per-user pricing and no setup charge. There is a sixty day trial with your own data, and you can export everything as CSV at any point, including after you leave.',
43
+ source: 'authored' as const,
44
+ },
45
+ ];
46
+ ---
47
+
48
+ <BaseLayout
49
+ identity={identity}
50
+ seo={{
51
+ description: identity.seo.metaDescription ?? undefined,
52
+ }}
53
+ >
54
+ <JsonLd slot="jsonLd" data={faqJsonLd(faqs)} />
55
+
56
+ <div class="stack">
57
+ <section class="prose">
58
+ <h1>Maintenance planning that fits on a clipboard</h1>
59
+ <p class="lede">
60
+ {identity.name} helps small workshops schedule servicing on runtime hours instead of the
61
+ calendar, and record enough to know which machines are actually costing them.
62
+ </p>
63
+ <p>
64
+ <a class="button" href="/resources">Get the starter kit</a>
65
+ <a class="button secondary" href="/contact">Talk to us</a>
66
+ </p>
67
+ </section>
68
+
69
+ <section aria-labelledby="faq-heading" class="stack">
70
+ <h2 id="faq-heading">Straight answers</h2>
71
+ {faqs.map((faq) => <AnswerBlock answer={faq} />)}
72
+ </section>
73
+
74
+ {
75
+ articles.length > 0 && (
76
+ <section aria-labelledby="recent-heading">
77
+ <h2 id="recent-heading">Recent writing</h2>
78
+ <ul class="grid">
79
+ {articles.map((post) => (
80
+ <PostCard post={post} />
81
+ ))}
82
+ </ul>
83
+ <p>
84
+ <a href="/blog">All posts</a>
85
+ </p>
86
+ </section>
87
+ )
88
+ }
89
+
90
+ {
91
+ resources.length > 0 && (
92
+ <section aria-labelledby="resources-heading">
93
+ <h2 id="resources-heading">Take something with you</h2>
94
+ <ul class="grid">
95
+ {resources.map((post) => (
96
+ <PostCard post={post} />
97
+ ))}
98
+ </ul>
99
+ <p>
100
+ <a href="/resources">All resources</a>
101
+ </p>
102
+ </section>
103
+ )
104
+ }
105
+ </div>
106
+ </BaseLayout>
@@ -0,0 +1,64 @@
1
+ /**
2
+ * llms.txt: a plain-text summary of the site, written for answer engines.
3
+ *
4
+ * The backend serves one as a site document; when it has none, this route
5
+ * builds one from what the site actually publishes. Generating it beats
6
+ * serving nothing, and a generated file listing real URLs and real answer
7
+ * summaries is more useful to a model than a hand-written one gone stale.
8
+ */
9
+ import type { APIRoute } from 'astro';
10
+ import {
11
+ answerFor,
12
+ getArticles,
13
+ getDocument,
14
+ getIdentity,
15
+ getResources,
16
+ postUrl,
17
+ type Post,
18
+ type SiteIdentity,
19
+ } from '../lib/data.js';
20
+
21
+ async function authoredDocument(): Promise<string> {
22
+ try {
23
+ return await getDocument('llms-txt');
24
+ } catch {
25
+ // A backend without documents is not an error here; fall through to the
26
+ // generated version.
27
+ return '';
28
+ }
29
+ }
30
+
31
+ function entry(post: Post, identity: SiteIdentity): string {
32
+ const answer = answerFor(post);
33
+ return `- [${post.title}](${postUrl(post, identity)})${answer ? `: ${answer.summary}` : ''}`;
34
+ }
35
+
36
+ async function generate(identity: SiteIdentity): Promise<string> {
37
+ const articles = await getArticles();
38
+ const resources = await getResources();
39
+ return [
40
+ `# ${identity.name}`,
41
+ '',
42
+ ...(identity.seo.metaDescription ? [`> ${identity.seo.metaDescription}`, ''] : []),
43
+ 'Every page on this site opens with the question it answers and a short',
44
+ 'summary that stands on its own when quoted.',
45
+ '',
46
+ '## Blog',
47
+ ...articles.map((post) => entry(post, identity)),
48
+ '',
49
+ '## Resources',
50
+ ...resources.map((post) => entry(post, identity)),
51
+ '',
52
+ '## Pages',
53
+ `- [About](${identity.siteUrl}/about)`,
54
+ `- [Contact](${identity.siteUrl}/contact)`,
55
+ '',
56
+ ].join('\n');
57
+ }
58
+
59
+ export const GET: APIRoute = async () => {
60
+ const identity = await getIdentity();
61
+ const authored = await authoredDocument();
62
+ const body = authored.trim().length > 0 ? authored : await generate(identity);
63
+ return new Response(body, { headers: { 'content-type': 'text/plain; charset=utf-8' } });
64
+ };
@@ -0,0 +1,25 @@
1
+ ---
2
+ /** One resource: whitepaper, case study, or research note. */
3
+ import type { GetStaticPaths } from 'astro';
4
+ import PostLayout from '../../layouts/PostLayout.astro';
5
+ import { bylineFor, getAuthors, getIdentity, getResources } from '../../lib/data.js';
6
+
7
+ export const getStaticPaths = (async () => {
8
+ const posts = await getResources();
9
+ const authors = await getAuthors();
10
+ const identity = await getIdentity();
11
+ return posts.map((post) => ({
12
+ params: { slug: post.slug },
13
+ props: { post, byline: bylineFor(post, authors), identity },
14
+ }));
15
+ }) satisfies GetStaticPaths;
16
+
17
+ const { post, byline, identity } = Astro.props;
18
+ ---
19
+
20
+ <PostLayout
21
+ post={post}
22
+ byline={byline}
23
+ identity={identity}
24
+ section={{ name: 'Resources', path: '/resources' }}
25
+ />
@@ -0,0 +1,95 @@
1
+ ---
2
+ /**
3
+ * Resources: the toolkits, case studies, and research the site gives away.
4
+ *
5
+ * These are posts whose content type is one of RESOURCE_TYPES, laid out as a
6
+ * shelf rather than a feed. The site's llms.txt is surfaced here too, because
7
+ * it is a document the site publishes and this is the page about documents the
8
+ * site publishes.
9
+ */
10
+ import JsonLd from '../components/JsonLd.astro';
11
+ import ResourceCard from '../components/ResourceCard.astro';
12
+ import BaseLayout from '../layouts/BaseLayout.astro';
13
+ import { answerFor, getIdentity, getResources, postUrl } from '../lib/data.js';
14
+ import { faqJsonLd } from '../lib/seo.js';
15
+
16
+ const identity = await getIdentity();
17
+ const resources = await getResources();
18
+
19
+ const groups = [
20
+ { type: 'whitepaper', heading: 'Toolkits and downloads' },
21
+ { type: 'case-study', heading: 'Case studies' },
22
+ { type: 'research', heading: 'Research' },
23
+ ] as const;
24
+
25
+ const itemList = {
26
+ '@context': 'https://schema.org',
27
+ '@type': 'ItemList',
28
+ name: 'Resources',
29
+ itemListElement: resources.map((post, index) => ({
30
+ '@type': 'ListItem',
31
+ position: index + 1,
32
+ url: postUrl(post, identity),
33
+ name: post.title,
34
+ })),
35
+ };
36
+ ---
37
+
38
+ <BaseLayout
39
+ identity={identity}
40
+ seo={{
41
+ title: 'Resources',
42
+ description:
43
+ 'Printable toolkits, case studies, and field research on maintenance planning for small workshops. Free, and most of it needs no email.',
44
+ }}
45
+ >
46
+ <Fragment slot="jsonLd">
47
+ <JsonLd data={itemList} />
48
+ <JsonLd data={faqJsonLd(resources.map((post) => answerFor(post)))} />
49
+ </Fragment>
50
+
51
+ <div class="stack">
52
+ <section class="prose">
53
+ <h1>Resources</h1>
54
+ <p class="lede">
55
+ Everything here is free. Two items ask for an email because we send a printable pack; the
56
+ rest you can read on the page.
57
+ </p>
58
+ </section>
59
+
60
+ {
61
+ resources.length === 0 ? (
62
+ <p class="notice">
63
+ No resources published yet. Publish a post of type whitepaper, case study, or research and
64
+ it appears here.
65
+ </p>
66
+ ) : (
67
+ groups.map((group) => {
68
+ const items = resources.filter((post) => post.type === group.type);
69
+ return (
70
+ items.length > 0 && (
71
+ <section aria-label={group.heading}>
72
+ <h2>{group.heading}</h2>
73
+ <ul class="grid">
74
+ {items.map((post) => (
75
+ <ResourceCard post={post} />
76
+ ))}
77
+ </ul>
78
+ </section>
79
+ )
80
+ );
81
+ })
82
+ )
83
+ }
84
+
85
+ <section class="prose">
86
+ <h2>What is llms.txt?</h2>
87
+ <p>
88
+ A plain text summary of this site written for answer engines, served at
89
+ <a href="/llms.txt">/llms.txt</a>. It lists what we publish and where, so a model
90
+ answering a question about maintenance planning can find the right page instead of guessing
91
+ from our navigation.
92
+ </p>
93
+ </section>
94
+ </div>
95
+ </BaseLayout>
@@ -0,0 +1,18 @@
1
+ /**
2
+ * robots.txt.
3
+ *
4
+ * Honors the site's own indexing preference: if the backend says discourage
5
+ * search engines, this disallows everything rather than quietly ignoring the
6
+ * setting. It also points at the sitemap and at llms.txt, which is how an
7
+ * answer engine finds the summary meant for it.
8
+ */
9
+ import type { APIRoute } from 'astro';
10
+ import { getIdentity } from '../lib/data.js';
11
+ import { robotsTxt } from '../lib/seo.js';
12
+
13
+ export const GET: APIRoute = async () => {
14
+ const identity = await getIdentity();
15
+ return new Response(robotsTxt(identity), {
16
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
17
+ });
18
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The sitemap.
3
+ *
4
+ * One document covering everything: the site's own pages, every post, every
5
+ * author page, and anything the backend publishes that this site does not
6
+ * render (the merge, in `getSitemapUrls`). Two partial sitemaps are worse than
7
+ * one complete one, which is why this route builds it rather than leaving the
8
+ * CMS sitemap and the site sitemap to disagree.
9
+ */
10
+ import type { APIRoute } from 'astro';
11
+ import { getSitemapUrls } from '../lib/data.js';
12
+ import { sitemapXml } from '../lib/seo.js';
13
+
14
+ /** The pages this template always renders. Add yours here when you add a page. */
15
+ const STATIC_PATHS = ['/', '/about', '/blog', '/resources', '/contact'] as const;
16
+
17
+ export const GET: APIRoute = async () => {
18
+ const urls = await getSitemapUrls(STATIC_PATHS);
19
+ return new Response(sitemapXml(urls), {
20
+ headers: { 'content-type': 'application/xml; charset=utf-8' },
21
+ });
22
+ };