@topy-ai/maggie 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 (36) hide show
  1. package/bin/maggie.js +152 -0
  2. package/bundled-references/ai-native-blog-contract.md +310 -0
  3. package/bundled-references/blog-data-contract.md +146 -0
  4. package/bundled-references/blog-implementation.md +46 -0
  5. package/bundled-references/blog-operations-contract.md +68 -0
  6. package/bundled-references/browser-inspection.md +39 -0
  7. package/bundled-references/provider-adapter-contract.md +68 -0
  8. package/bundled-references/seo-technical-contract.md +75 -0
  9. package/bundled-skills/README.md +16 -0
  10. package/bundled-skills/maggie-blog-bootstrap/SKILL.md +243 -0
  11. package/bundled-skills/maggie-clone/SKILL.md +213 -0
  12. package/bundled-skills/maggie-deployment/SKILL.md +61 -0
  13. package/bundled-skills/maggie-deployment/agents/openai.yaml +4 -0
  14. package/bundled-skills/maggie-deployment/references/cloudflare.md +76 -0
  15. package/bundled-skills/maggie-deployment/references/provider-contract.md +32 -0
  16. package/bundled-skills/maggie-project-context/SKILL.md +38 -0
  17. package/bundled-skills/maggie-seo-geo/SKILL.md +53 -0
  18. package/bundled-skills/maggie-social-share/SKILL.md +48 -0
  19. package/bundled-tools/clis/maggie.py +748 -0
  20. package/bundled-tools/clis/maggie_clone.py +82 -0
  21. package/bundled-tools/clis/site_audit.py +99 -0
  22. package/bundled-tools/integrations/analytics.md +34 -0
  23. package/bundled-tools/integrations/maggie-api-pull.md +72 -0
  24. package/bundled-tools/integrations/maggie-project-context.md +62 -0
  25. package/bundled-tools/integrations/maggie-seo-audit.md +16 -0
  26. package/bundled-tools/integrations/maggie-skills-api.md +76 -0
  27. package/bundled-tools/integrations/maggie-social-share.md +23 -0
  28. package/bundled-tools/integrations/maggie-visibility.md +22 -0
  29. package/package.json +29 -0
  30. package/references/ai-native-blog-contract.md +310 -0
  31. package/references/blog-data-contract.md +146 -0
  32. package/references/blog-implementation.md +46 -0
  33. package/references/blog-operations-contract.md +68 -0
  34. package/references/browser-inspection.md +39 -0
  35. package/references/provider-adapter-contract.md +68 -0
  36. package/references/seo-technical-contract.md +75 -0
package/bin/maggie.js ADDED
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { cp } from "node:fs/promises";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
9
+ const SKILLS_ROOT = join(PACKAGE_ROOT, "bundled-skills");
10
+ const REFERENCES_ROOT = join(PACKAGE_ROOT, "bundled-references");
11
+ const TOOLS_ROOT = join(PACKAGE_ROOT, "bundled-tools");
12
+ const SKILL_NAMES = [
13
+ "maggie-blog-bootstrap",
14
+ "maggie-clone",
15
+ "maggie-deployment",
16
+ "maggie-project-context",
17
+ "maggie-seo-geo",
18
+ "maggie-social-share",
19
+ ];
20
+
21
+ function usage() {
22
+ console.log(`Maggie Skills installer
23
+
24
+ Usage:
25
+ maggie init [--project PATH] [--agent auto|codex|claude|all] [--skills LIST]
26
+ maggie install [SKILL ...] [--project PATH] [--agent auto|codex|claude|all]
27
+ maggie list
28
+ maggie doctor [--project PATH]
29
+ maggie remove [SKILL ...] [--project PATH] [--agent codex|claude|all]
30
+
31
+ Examples:
32
+ npx @topy-ai/maggie init
33
+ npx @topy-ai/maggie init --agent codex --skills maggie-blog-bootstrap,maggie-clone
34
+ npx @topy-ai/maggie doctor --project .
35
+ `);
36
+ }
37
+
38
+ function option(args, name, fallback = undefined) {
39
+ const index = args.indexOf(name);
40
+ return index === -1 ? fallback : args[index + 1] ?? fallback;
41
+ }
42
+
43
+ function values(args, name) {
44
+ const value = option(args, name, "");
45
+ return value ? value.split(",").map((item) => item.trim()).filter(Boolean) : [];
46
+ }
47
+
48
+ function projectRoot(args) {
49
+ return resolve(option(args, "--project", process.cwd()));
50
+ }
51
+
52
+ function selectedSkills(args) {
53
+ const requested = values(args, "--skills").concat(args.filter((item) => SKILL_NAMES.includes(item)));
54
+ const unique = [...new Set(requested)];
55
+ if (!unique.length) return SKILL_NAMES;
56
+ const invalid = unique.filter((name) => !SKILL_NAMES.includes(name));
57
+ if (invalid.length) throw new Error(`unknown skill: ${invalid.join(", ")}`);
58
+ return unique;
59
+ }
60
+
61
+ function agentRoots(args, root) {
62
+ const requested = option(args, "--agent", "auto");
63
+ if (!["auto", "codex", "claude", "all"].includes(requested)) throw new Error(`unknown agent: ${requested}`);
64
+ if (requested === "codex") return [join(root, ".agents")];
65
+ if (requested === "claude") return [join(root, ".claude")];
66
+ if (requested === "all") return [join(root, ".agents"), join(root, ".claude")];
67
+ const roots = [];
68
+ if (existsSync(join(root, ".agents"))) roots.push(join(root, ".agents"));
69
+ if (existsSync(join(root, ".claude"))) roots.push(join(root, ".claude"));
70
+ return roots.length ? roots : [join(root, ".agents")];
71
+ }
72
+
73
+ function copyIfMissing(source, target, force = false) {
74
+ if (existsSync(target) && !force) return "skipped";
75
+ mkdirSync(dirname(target), { recursive: true });
76
+ cp(source, target, { recursive: true, force });
77
+ return "installed";
78
+ }
79
+
80
+ function install(args) {
81
+ const root = projectRoot(args);
82
+ const skills = selectedSkills(args);
83
+ const force = args.includes("--force");
84
+ const roots = agentRoots(args, root);
85
+ if (!existsSync(SKILLS_ROOT)) throw new Error("bundled skills are missing; run npm pack from the package source");
86
+ for (const agentRoot of roots) {
87
+ for (const skill of skills) {
88
+ const result = copyIfMissing(join(SKILLS_ROOT, skill), join(agentRoot, "skills", skill), force);
89
+ console.log(`${result} ${agentRoot}/skills/${skill}`);
90
+ }
91
+ if (existsSync(REFERENCES_ROOT)) copyIfMissing(REFERENCES_ROOT, join(agentRoot, "references"), force);
92
+ }
93
+ const tools = join(root, "tools");
94
+ if (existsSync(TOOLS_ROOT)) {
95
+ for (const group of ["clis", "integrations"]) {
96
+ if (existsSync(join(TOOLS_ROOT, group))) copyIfMissing(join(TOOLS_ROOT, group), join(tools, group), force);
97
+ }
98
+ }
99
+ const stateDir = join(root, ".maggie");
100
+ mkdirSync(stateDir, { recursive: true });
101
+ writeFileSync(join(stateDir, "install.json"), JSON.stringify({ version: "0.1.0", agents: roots.map((item) => item.slice(root.length + 1)), skills, installed_at: new Date().toISOString() }, null, 2) + "\n");
102
+ console.log(`Maggie installed in ${root}`);
103
+ console.log("Run `maggie doctor --project .` before using mutating workflows.");
104
+ }
105
+
106
+ function list() {
107
+ for (const skill of SKILL_NAMES) console.log(skill);
108
+ }
109
+
110
+ function doctor(args) {
111
+ const root = projectRoot(args);
112
+ const checks = [
113
+ ["project", existsSync(root)],
114
+ ["bootstrap-state", existsSync(join(root, ".maggie", "bootstrap-state.json"))],
115
+ ["codex-skills", existsSync(join(root, ".agents", "skills"))],
116
+ ["claude-skills", existsSync(join(root, ".claude", "skills"))],
117
+ ];
118
+ for (const [name, passed] of checks) console.log(`${passed ? "PASS" : "INFO"} ${name}`);
119
+ const installed = [];
120
+ for (const agentRoot of [join(root, ".agents"), join(root, ".claude")]) {
121
+ for (const skill of SKILL_NAMES) if (existsSync(join(agentRoot, "skills", skill, "SKILL.md"))) installed.push(`${agentRoot}/${skill}`);
122
+ }
123
+ console.log(`INFO installed-skills=${installed.length}`);
124
+ console.log("INFO doctor is diagnostic; bootstrap completion remains a project decision gate.");
125
+ }
126
+
127
+ function remove(args) {
128
+ const root = projectRoot(args);
129
+ const skills = selectedSkills(args);
130
+ for (const agentRoot of agentRoots(args, root)) {
131
+ for (const skill of skills) {
132
+ const target = join(agentRoot, "skills", skill);
133
+ if (existsSync(target)) {
134
+ rmSync(target, { recursive: true, force: false });
135
+ console.log(`removed ${target}`);
136
+ } else console.log(`absent ${target}`);
137
+ }
138
+ }
139
+ }
140
+
141
+ const [command = "help", ...args] = process.argv.slice(2);
142
+ try {
143
+ if (["help", "--help", "-h"].includes(command)) usage();
144
+ else if (command === "list") list();
145
+ else if (command === "init" || command === "install") install(args);
146
+ else if (command === "doctor") doctor(args);
147
+ else if (command === "remove") remove(args);
148
+ else throw new Error(`unknown command: ${command}`);
149
+ } catch (error) {
150
+ console.error(`maggie: ${error.message}`);
151
+ process.exitCode = 1;
152
+ }
@@ -0,0 +1,310 @@
1
+ # Maggie AI-Native Blog Application Contract
2
+
3
+ This contract describes the complete application surface for a Maggie-powered
4
+ blog. It fixes the content, operations, and SEO semantics while leaving visual
5
+ design and component composition to the implementing agent.
6
+
7
+ ## Public information architecture
8
+
9
+ The default route map is:
10
+
11
+ ```text
12
+ /blog published post index
13
+ /blog/[slug] post detail
14
+ /topics topic index
15
+ /topics/[topic-slug] topic landing page and post grid
16
+ /authors/[author-slug] authorship page when author pages are enabled
17
+ /about optional organisation/EEAT page
18
+ /sitemap.xml sitemap index or single sitemap
19
+ /sitemap-posts-[part].xml post sitemap parts when required
20
+ /robots.txt crawl rules and sitemap reference
21
+ ```
22
+
23
+ ### Blog index
24
+
25
+ The blog index should contain, in an accessible and crawlable order:
26
+
27
+ 1. page title and concise description;
28
+ 2. optional featured or latest post block;
29
+ 3. hot-topic navigation using real topic URLs;
30
+ 4. blog grid/list with title, excerpt, date, author, image alt text, and
31
+ normal anchor links;
32
+ 5. stable pagination with self-canonical pages and no indexable duplicate
33
+ query URLs;
34
+ 6. useful FAQ section only when questions and answers are maintained in the
35
+ content model;
36
+ 7. a configured CTA with explicit attribution.
37
+
38
+ ### Post detail
39
+
40
+ Each published post must render:
41
+
42
+ - title, excerpt, canonical URL, language, and visible publication date;
43
+ - author name, author profile link, credentials or organisation context when
44
+ available, and source attribution;
45
+ - modified date only when it represents a real content revision;
46
+ - readable content with one `h1`, logical `h2`/`h3` headings, table of contents
47
+ when useful, internal links, related topics, and related posts;
48
+ - cover image with dimensions and meaningful alt text;
49
+ - Article/BlogPosting JSON-LD matching the visible page;
50
+ - FAQ section only when the FAQ items are visible and maintained;
51
+ - CTA selected by policy, with UTM attribution generated from known values only;
52
+ - no draft, queue, internal note, or private project context.
53
+
54
+ ### Topic landing page
55
+
56
+ Each indexable topic page must have:
57
+
58
+ - unique topic title and useful topic description;
59
+ - a stable topic slug and canonical URL;
60
+ - a crawlable post grid filtered by `published` status;
61
+ - pagination when the list exceeds the configured page size;
62
+ - optional topic FAQ with visible answers and FAQ schema only when eligible;
63
+ - related topics and one configured CTA where relevant;
64
+ - no thin page created solely because a tag exists.
65
+
66
+ Do not create indexable topic pages until the topic has a description and a
67
+ minimum configured number of published posts, unless the user explicitly
68
+ approves an exception.
69
+
70
+ ## Stable domain model
71
+
72
+ ```ts
73
+ type PublishStatus = "draft" | "review" | "approved" | "published" | "archived";
74
+
75
+ type Author = {
76
+ id: string;
77
+ slug: string;
78
+ name: string;
79
+ jobTitle?: string;
80
+ organisation?: string;
81
+ bio?: string;
82
+ profileUrl?: string;
83
+ sameAs?: string[];
84
+ avatarUrl?: string;
85
+ credentials?: string[];
86
+ };
87
+
88
+ type Topic = {
89
+ id: string;
90
+ slug: string;
91
+ title: string;
92
+ description: string;
93
+ status: "draft" | "published" | "archived";
94
+ seoTitle?: string;
95
+ seoDescription?: string;
96
+ faqIds: string[];
97
+ minimumPublishedPosts: number;
98
+ };
99
+
100
+ type FaqItem = {
101
+ id: string;
102
+ question: string;
103
+ answerMarkdown: string;
104
+ sortOrder: number;
105
+ status: "draft" | "published";
106
+ };
107
+
108
+ type Cta = {
109
+ id: string;
110
+ label: string;
111
+ url: string;
112
+ placement: "blog_index" | "post" | "topic" | "footer";
113
+ status: "draft" | "active" | "archived";
114
+ trackingKey?: string;
115
+ };
116
+
117
+ type SeoSnapshot = {
118
+ title: string;
119
+ description: string;
120
+ canonicalUrl: string;
121
+ robots: "index,follow" | "noindex,follow" | "noindex,nofollow";
122
+ ogType: "website" | "article";
123
+ ogImageUrl?: string;
124
+ twitterCard: "summary" | "summary_large_image";
125
+ jsonLdType: "Article" | "BlogPosting" | "CollectionPage" | "FAQPage";
126
+ };
127
+
128
+ type BlogReport = {
129
+ id: string;
130
+ runType: "technical_seo" | "content_quality" | "visibility" | "analytics";
131
+ status: "queued" | "running" | "completed" | "failed";
132
+ startedAt?: string;
133
+ completedAt?: string;
134
+ summary: Record<string, unknown>;
135
+ findings: Array<{
136
+ severity: "error" | "warning" | "info";
137
+ code: string;
138
+ url?: string;
139
+ message: string;
140
+ }>;
141
+ };
142
+ ```
143
+
144
+ Extend the [canonical post model](blog-data-contract.md) with `authorIds`,
145
+ `topicIds`, `faqIds`, `ctaId`, `seo`, and `contentQuality` rather than putting
146
+ these fields into an untyped metadata blob.
147
+
148
+ ## Maggie Ops dashboard
149
+
150
+ The operations dashboard is private, authenticated, and never included in the
151
+ public sitemap or robots allowlist:
152
+
153
+ ```text
154
+ /ops health summary and pending actions
155
+ /ops/posts filterable post inventory
156
+ /ops/posts/new create draft
157
+ /ops/posts/[id]/edit edit metadata/content with validation
158
+ /ops/posts/[id]/preview preview draft without indexing
159
+ /ops/topics topic and FAQ management
160
+ /ops/sitemap sitemap source, matching history, and eligibility
161
+ /ops/reports technical SEO, content, visibility, analytics reports
162
+ /ops/settings/site origin, locale, timezone, author defaults, CTA defaults
163
+ /ops/settings/integrations AI CMO key status, API Pull, GSC, and GA4 settings
164
+ ```
165
+
166
+ Dashboard actions must be explicit and auditable:
167
+
168
+ | Action | Required state | External write |
169
+ |---|---|---:|
170
+ | Save draft | authenticated editor | no |
171
+ | Approve post | review-ready post and editor approval | no |
172
+ | Publish post | approved post and publishing permission | yes/public |
173
+ | Match sitemap | completed bootstrap and configured source | AI CMO API |
174
+ | Queue rewrite | matched asset and rewrite policy | AI CMO API |
175
+ | Sync API Pull | server key and source mapping | AI CMO API |
176
+ | Run report | configured target/property | provider/API varies |
177
+ | Change integration | owner/admin permission | yes |
178
+
179
+ Every mutation records actor, timestamp, previous state, new state, reason,
180
+ and correlation/idempotency key. The dashboard should show a dry-run preview
181
+ before queueing, publishing, changing sitemap sources, or changing integration
182
+ settings.
183
+
184
+ ### Ops API boundary
185
+
186
+ Keep the browser UI thin. All reads and mutations go through authenticated
187
+ server-side routes with typed request/response objects:
188
+
189
+ ```text
190
+ GET /api/ops/summary
191
+ GET /api/ops/posts?status=&topic=&page=
192
+ POST /api/ops/posts
193
+ GET /api/ops/posts/[id]
194
+ PATCH /api/ops/posts/[id]
195
+ POST /api/ops/posts/[id]/preview
196
+ POST /api/ops/posts/[id]/approve
197
+ POST /api/ops/posts/[id]/publish
198
+ GET /api/ops/topics
199
+ POST /api/ops/topics
200
+ PATCH /api/ops/topics/[id]
201
+ GET /api/ops/sitemap/runs
202
+ POST /api/ops/sitemap/match
203
+ POST /api/ops/sitemap/auto-detect
204
+ GET /api/ops/reports?type=&status=
205
+ POST /api/ops/reports
206
+ GET /api/ops/settings
207
+ PATCH /api/ops/settings/site
208
+ PATCH /api/ops/settings/integrations
209
+ ```
210
+
211
+ Every route must perform session authentication, role authorization, input
212
+ schema validation, and state-transition validation. Do not expose raw database
213
+ queries or the AI CMO key to the browser. Return a correlation ID with errors
214
+ so an Ops user can reconcile a failed action without seeing secret values.
215
+
216
+ The post editor must validate title, slug, excerpt, language, author, topics,
217
+ publication status, canonical URL, cover image/alt text, and SEO fields before
218
+ save. A preview uses `noindex` and the same renderer as the public post; it is
219
+ not a second content implementation.
220
+
221
+ ### Ops dashboard summary
222
+
223
+ `GET /api/ops/summary` should expose only bounded operational facts:
224
+
225
+ ```ts
226
+ type OpsSummary = {
227
+ bootstrap: "complete" | "incomplete";
228
+ content: { published: number; drafts: number; review: number; failedSyncs: number };
229
+ sitemap: { lastRunAt?: string; matched: number; eligible: number; unmatched: number };
230
+ reports: { openErrors: number; lastTechnicalSeoRunAt?: string };
231
+ integrations: { aiCmo: "disabled" | "configured" | "verified" | "error"; gsc: string; ga4: string };
232
+ };
233
+ ```
234
+
235
+ Counts must be calculated from the same repositories used by the public
236
+ routes. Do not use stale client counters to decide whether a post is eligible
237
+ for publication or rewrite.
238
+
239
+ ## Configuration boundary
240
+
241
+ Keep non-secret configuration typed and reviewable:
242
+
243
+ ```ts
244
+ type BlogConfig = {
245
+ site: {
246
+ name: string;
247
+ baseUrl: string;
248
+ locale: string;
249
+ timezone: string;
250
+ blogPath: string;
251
+ postsPerPage: number;
252
+ topicMinimumPosts: number;
253
+ };
254
+ defaults: {
255
+ authorId?: string;
256
+ ctaId?: string;
257
+ language: string;
258
+ robotsPolicy: "index,follow" | "noindex,follow";
259
+ };
260
+ integrations: {
261
+ aiCmo: { enabled: boolean; baseUrl: string; apiKeyEnv: string; syncMode: "manual" | "scheduled" };
262
+ gsc: { enabled: boolean; verificationMode?: "html" | "dns" };
263
+ ga4: { enabled: boolean; measurementIdEnv?: string; consentRequired: boolean };
264
+ };
265
+ publishing: {
266
+ requireApproval: boolean;
267
+ allowScheduledPublish: boolean;
268
+ allowAutoRewritePublish: boolean;
269
+ };
270
+ };
271
+ ```
272
+
273
+ Secret values stay in runtime environment variables. The dashboard may show
274
+ configured/missing/verified status, but never the key or token value.
275
+
276
+ ## Backend and SEO invariants
277
+
278
+ - Public reads select `status = published` and valid `publishedAt` in one
279
+ repository function; templates must not implement their own filtering.
280
+ - Post, topic, FAQ, author, CTA, and report IDs are stable and never derived
281
+ from mutable display text after publication.
282
+ - Every public URL has one canonical owner. Slug changes create redirects and
283
+ never silently create a second page.
284
+ - Preview, draft, review, archived, ops, and report URLs are `noindex` and
285
+ excluded from sitemap output.
286
+ - The sitemap is generated from the same publication query used by the public
287
+ list, not from a separate hand-maintained URL array.
288
+ - Metadata and JSON-LD are generated from validated domain objects, not from
289
+ free-form agent prose.
290
+ - Build and report failures preserve the last known-good public output.
291
+ - API Pull, GSC, and GA4 integrations are optional; missing credentials must
292
+ produce a visible disabled/unverified state, not a broken build.
293
+
294
+ ## Implementation order
295
+
296
+ Generate and verify the application in this order:
297
+
298
+ 1. typed config and environment validation;
299
+ 2. database migrations and repository functions;
300
+ 3. post/topic/author/FAQ/CTA schemas and seed fixtures;
301
+ 4. public rendering and metadata helpers;
302
+ 5. sitemap/robots generation from the publication query;
303
+ 6. authenticated Ops API and dashboard screens;
304
+ 7. API Pull adapter, sitemap matching, and state reporting;
305
+ 8. reports, GSC/GA4 readback, and scheduled jobs;
306
+ 9. integration, route, metadata, and migration tests.
307
+
308
+ Do not build the dashboard against mock objects after step 2. Fixtures are for
309
+ tests and empty-state UI only; production reads must use the validated
310
+ repository boundary.
@@ -0,0 +1,146 @@
1
+ # Maggie Blog Data Contract
2
+
3
+ This contract is the stable backend boundary for every Maggie framework
4
+ template. UI components may differ, but adapters should normalize all content
5
+ to this model before rendering.
6
+
7
+ ## Canonical post model
8
+
9
+ ```ts
10
+ type PostStatus = "draft" | "review" | "approved" | "scheduled" | "published" | "archived";
11
+
12
+ type BlogPost = {
13
+ id: string; // local stable id
14
+ source: "local" | "api-pull" | "cms" | "manual";
15
+ sourceId?: string; // remote content_id or provider id
16
+ slug: string; // unique public identity
17
+ title: string;
18
+ excerpt: string;
19
+ contentMarkdown?: string;
20
+ contentHtml?: string;
21
+ canonicalUrl: string;
22
+ coverImageUrl?: string;
23
+ coverImageAlt?: string;
24
+ authorName?: string;
25
+ authorUrl?: string;
26
+ language: string;
27
+ tags: string[];
28
+ status: PostStatus;
29
+ publishedAt?: string; // ISO-8601 UTC
30
+ updatedAt?: string; // ISO-8601 UTC
31
+ createdAt: string; // ISO-8601 UTC
32
+ contentHash?: string;
33
+ };
34
+ ```
35
+
36
+ Required invariants:
37
+
38
+ - `slug` is unique and does not change after publication without a redirect.
39
+ - `status = published` and a valid `publishedAt` are both required for a
40
+ public route, list item, sitemap entry, or Article JSON-LD record.
41
+ - `canonicalUrl` is absolute, uses the configured site origin, and has no
42
+ tracking query parameters.
43
+ - Store Markdown and/or sanitized HTML, never unsanitized remote HTML.
44
+ - Keep remote `sourceId` separate from local `id`; neither may be inferred from
45
+ a mutable title.
46
+ - Dates are serialized as ISO-8601 UTC and displayed using the chosen locale.
47
+
48
+ ## SQLite reference schema
49
+
50
+ The Astro starter additionally includes migration tables for external CMS
51
+ imports: `wp_migrations`, `wp_import_items`, `wp_terms`, `media_assets`,
52
+ `post_media`, and `redirects`. A WordPress source identity is stored as
53
+ `source = 'cms'` and `source_id = 'wordpress:<id>'` so it remains compatible
54
+ with the canonical post source enum while remaining unique and rerunnable.
55
+
56
+ This is the default for a new, small single-instance project. Use the same
57
+ logical fields with Postgres or another engine when the deployment requires it.
58
+
59
+ ```sql
60
+ CREATE TABLE posts (
61
+ id TEXT PRIMARY KEY,
62
+ source TEXT NOT NULL CHECK (source IN ('local', 'api-pull', 'cms', 'manual')),
63
+ source_id TEXT,
64
+ slug TEXT NOT NULL UNIQUE,
65
+ title TEXT NOT NULL,
66
+ excerpt TEXT NOT NULL DEFAULT '',
67
+ content_markdown TEXT,
68
+ content_html TEXT,
69
+ canonical_url TEXT NOT NULL UNIQUE,
70
+ cover_image_url TEXT,
71
+ cover_image_alt TEXT,
72
+ author_name TEXT,
73
+ author_url TEXT,
74
+ language TEXT NOT NULL DEFAULT 'en',
75
+ status TEXT NOT NULL DEFAULT 'draft'
76
+ CHECK (status IN ('draft', 'review', 'approved', 'scheduled', 'published', 'archived')),
77
+ published_at TEXT,
78
+ updated_at TEXT,
79
+ created_at TEXT NOT NULL,
80
+ content_hash TEXT,
81
+ CHECK (status <> 'published' OR published_at IS NOT NULL)
82
+ );
83
+
84
+ CREATE UNIQUE INDEX posts_source_identity
85
+ ON posts (source, source_id)
86
+ WHERE source_id IS NOT NULL;
87
+ CREATE INDEX posts_public_order
88
+ ON posts (status, published_at DESC, updated_at DESC);
89
+
90
+ CREATE TABLE post_tags (
91
+ post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
92
+ tag TEXT NOT NULL,
93
+ PRIMARY KEY (post_id, tag)
94
+ );
95
+
96
+ CREATE TABLE content_sync_state (
97
+ source TEXT PRIMARY KEY,
98
+ cursor TEXT,
99
+ etag TEXT,
100
+ last_success_at TEXT,
101
+ last_error TEXT,
102
+ updated_at TEXT NOT NULL
103
+ );
104
+
105
+ CREATE TABLE content_delivery_state (
106
+ post_id TEXT PRIMARY KEY REFERENCES posts(id) ON DELETE CASCADE,
107
+ remote_content_id TEXT,
108
+ remote_version TEXT,
109
+ delivery_status TEXT NOT NULL DEFAULT 'not_delivered'
110
+ CHECK (delivery_status IN ('not_delivered', 'received', 'stored', 'published', 'failed')),
111
+ last_reported_url TEXT,
112
+ last_reported_hash TEXT,
113
+ last_error TEXT,
114
+ updated_at TEXT NOT NULL
115
+ );
116
+
117
+ CREATE TABLE post_redirects (
118
+ old_slug TEXT PRIMARY KEY,
119
+ post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
120
+ created_at TEXT NOT NULL
121
+ );
122
+ ```
123
+
124
+ For a multi-instance or serverless production deployment, confirm the hosted
125
+ database and migration strategy before using SQLite. Never silently create a
126
+ second database beside an existing ORM or CMS database.
127
+
128
+ ## API Pull normalization
129
+
130
+ Map the remote response at one adapter boundary:
131
+
132
+ ```text
133
+ remote content_id -> posts.source_id and content_delivery_state.remote_content_id
134
+ remote id -> posts.source_id when content_id is unavailable
135
+ remote title -> posts.title
136
+ remote excerpt -> posts.excerpt
137
+ remote content_markdown -> posts.content_markdown
138
+ remote content_html -> sanitize, then posts.content_html
139
+ remote canonical_url -> posts.canonical_url
140
+ remote slug -> posts.slug, never overwrite a published local slug
141
+ remote generated_at -> posts.updated_at
142
+ ```
143
+
144
+ The adapter must validate required fields, preserve the original response for
145
+ debugging outside public output, upsert by source identity, and only mark a
146
+ delivery successful after local storage succeeds.
@@ -0,0 +1,46 @@
1
+ # Framework-Neutral Blog Implementation Contract
2
+
3
+ This is the minimum complete blog surface. Framework templates may map these
4
+ routes differently, but must preserve their behaviour.
5
+
6
+ ## Public surface
7
+
8
+ | Surface | Requirement |
9
+ |---|---|
10
+ | Posts index | Crawlable links, title, excerpt, published date, pagination or bounded feed |
11
+ | Post detail | Stable slug, 404 for missing/unpublished content, canonical URL |
12
+ | Robots | Allow public posts and reference the sitemap |
13
+ | Sitemap | Published posts only, absolute canonical URLs, `lastmod` when known |
14
+ | Metadata | Title, description, canonical, OG, Twitter, Article JSON-LD |
15
+ | Analytics | GA4 only when configured and consent policy permits |
16
+ | GSC | Verification path or documented DNS verification |
17
+
18
+ ## Data rules
19
+
20
+ - `slug` is unique and immutable after publication unless a redirect is created.
21
+ - `publishedAt` controls sitemap/index inclusion.
22
+ - `updatedAt` is not a substitute for publication state.
23
+ - `canonicalUrl` wins over inferred request host when configured.
24
+ - Untrusted HTML is sanitized before rendering.
25
+
26
+ ## Technical SEO acceptance
27
+
28
+ - one canonical per post;
29
+ - no indexable duplicate query URLs;
30
+ - server output contains the post title and primary content;
31
+ - one `h1` per post;
32
+ - images have dimensions or stable layout reservation and alt text;
33
+ - internal links use normal anchors;
34
+ - sitemap and robots return 200 with the correct content type.
35
+
36
+ ## GEO/AI discoverability acceptance
37
+
38
+ The implementation should make the same facts available to humans and crawlers:
39
+
40
+ - clear author, organisation, date, and source attribution;
41
+ - concise answer-first opening paragraphs;
42
+ - descriptive headings and stable URLs;
43
+ - Article JSON-LD matching visible content;
44
+ - no robots rule that accidentally blocks major search crawlers;
45
+ - no claim that crawler access guarantees AI citations or rankings.
46
+