@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.
- package/bin/maggie.js +152 -0
- package/bundled-references/ai-native-blog-contract.md +310 -0
- package/bundled-references/blog-data-contract.md +146 -0
- package/bundled-references/blog-implementation.md +46 -0
- package/bundled-references/blog-operations-contract.md +68 -0
- package/bundled-references/browser-inspection.md +39 -0
- package/bundled-references/provider-adapter-contract.md +68 -0
- package/bundled-references/seo-technical-contract.md +75 -0
- package/bundled-skills/README.md +16 -0
- package/bundled-skills/maggie-blog-bootstrap/SKILL.md +243 -0
- package/bundled-skills/maggie-clone/SKILL.md +213 -0
- package/bundled-skills/maggie-deployment/SKILL.md +61 -0
- package/bundled-skills/maggie-deployment/agents/openai.yaml +4 -0
- package/bundled-skills/maggie-deployment/references/cloudflare.md +76 -0
- package/bundled-skills/maggie-deployment/references/provider-contract.md +32 -0
- package/bundled-skills/maggie-project-context/SKILL.md +38 -0
- package/bundled-skills/maggie-seo-geo/SKILL.md +53 -0
- package/bundled-skills/maggie-social-share/SKILL.md +48 -0
- package/bundled-tools/clis/maggie.py +748 -0
- package/bundled-tools/clis/maggie_clone.py +82 -0
- package/bundled-tools/clis/site_audit.py +99 -0
- package/bundled-tools/integrations/analytics.md +34 -0
- package/bundled-tools/integrations/maggie-api-pull.md +72 -0
- package/bundled-tools/integrations/maggie-project-context.md +62 -0
- package/bundled-tools/integrations/maggie-seo-audit.md +16 -0
- package/bundled-tools/integrations/maggie-skills-api.md +76 -0
- package/bundled-tools/integrations/maggie-social-share.md +23 -0
- package/bundled-tools/integrations/maggie-visibility.md +22 -0
- package/package.json +29 -0
- package/references/ai-native-blog-contract.md +310 -0
- package/references/blog-data-contract.md +146 -0
- package/references/blog-implementation.md +46 -0
- package/references/blog-operations-contract.md +68 -0
- package/references/browser-inspection.md +39 -0
- package/references/provider-adapter-contract.md +68 -0
- 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
|
+
|