@topy-ai/maggie 0.7.32 → 0.7.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-zh-TW.md +2 -2
- package/README.md +19 -4
- package/bundled-skills/maggie-blog-bootstrap/SKILL.md +8 -0
- package/bundled-skills/maggie-clone/SKILL.md +7 -1
- package/bundled-templates/astro-blog/README.md +217 -0
- package/bundled-templates/astro-blog/examples/blog.config.ts +30 -0
- package/bundled-templates/astro-blog/examples/content.config.ts +28 -0
- package/bundled-templates/astro-blog/examples/metadata.ts +37 -0
- package/bundled-templates/astro-blog/examples/ops-api.ts +31 -0
- package/bundled-templates/astro-blog/examples/route-manifest.ts +21 -0
- package/bundled-templates/astro-blog/starter/.env.example +36 -0
- package/bundled-templates/astro-blog/starter/README.md +168 -0
- package/bundled-templates/astro-blog/starter/astro.config.mjs +19 -0
- package/bundled-templates/astro-blog/starter/db/schema.sql +508 -0
- package/bundled-templates/astro-blog/starter/package-lock.json +8039 -0
- package/bundled-templates/astro-blog/starter/package.json +41 -0
- package/bundled-templates/astro-blog/starter/scripts/contract-check.mjs +16 -0
- package/bundled-templates/astro-blog/starter/scripts/fixture-seed.mjs +11 -0
- package/bundled-templates/astro-blog/starter/scripts/schema-check.mjs +13 -0
- package/bundled-templates/astro-blog/starter/scripts/seed-db.mjs +20 -0
- package/bundled-templates/astro-blog/starter/src/data/demo.ts +52 -0
- package/bundled-templates/astro-blog/starter/src/layouts/BaseLayout.astro +53 -0
- package/bundled-templates/astro-blog/starter/src/layouts/OpsLayout.astro +73 -0
- package/bundled-templates/astro-blog/starter/src/lib/ai-cmo.ts +14 -0
- package/bundled-templates/astro-blog/starter/src/lib/config.ts +15 -0
- package/bundled-templates/astro-blog/starter/src/lib/db.ts +95 -0
- package/bundled-templates/astro-blog/starter/src/lib/google.ts +28 -0
- package/bundled-templates/astro-blog/starter/src/lib/media.ts +12 -0
- package/bundled-templates/astro-blog/starter/src/lib/migration-validation.ts +11 -0
- package/bundled-templates/astro-blog/starter/src/lib/migration.ts +21 -0
- package/bundled-templates/astro-blog/starter/src/lib/ops-auth.ts +21 -0
- package/bundled-templates/astro-blog/starter/src/lib/providers/email.ts +45 -0
- package/bundled-templates/astro-blog/starter/src/lib/providers/media-storage.ts +37 -0
- package/bundled-templates/astro-blog/starter/src/lib/report-delivery.ts +26 -0
- package/bundled-templates/astro-blog/starter/src/lib/report-pdf.ts +49 -0
- package/bundled-templates/astro-blog/starter/src/lib/repository.ts +72 -0
- package/bundled-templates/astro-blog/starter/src/lib/seo-audit.ts +72 -0
- package/bundled-templates/astro-blog/starter/src/lib/seo-insights.ts +14 -0
- package/bundled-templates/astro-blog/starter/src/lib/seo.ts +33 -0
- package/bundled-templates/astro-blog/starter/src/lib/types.ts +58 -0
- package/bundled-templates/astro-blog/starter/src/lib/wordpress.ts +847 -0
- package/bundled-templates/astro-blog/starter/src/middleware.ts +7 -0
- package/bundled-templates/astro-blog/starter/src/pages/404.astro +11 -0
- package/bundled-templates/astro-blog/starter/src/pages/about.astro +15 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/approval/[token].ts +25 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/consent.ts +8 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/agency.ts +47 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/authors.ts +11 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/bulk.ts +58 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/calendar/publish-due.ts +12 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/config/validate.ts +4 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/content-tracking/report-state/batch.ts +19 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/content-tracking/report-state.ts +15 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/ctas.ts +5 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/entities.ts +23 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/media/optimize.ts +5 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/media/upload.ts +30 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/[id]/resume.ts +28 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/[id]/validate.ts +6 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/[id].ts +7 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/import.ts +458 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/migrations/media.ts +5 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/operations.ts +189 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/playbooks/[id]/run.ts +55 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/[id]/approve.ts +13 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/[id]/preview.ts +9 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/[id]/publish.ts +13 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/[id].ts +18 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/posts/index.ts +28 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/providers.ts +6 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/history.ts +3 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/latest.ts +3 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/posts.ts +3 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/project-context.ts +4 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/sync-updates.ts +43 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/sync.ts +172 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/pull/updates.ts +3 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/redirects/check.ts +5 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/redirects.ts +32 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/reports/deliver-due.ts +12 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/reports/deliver.ts +6 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/reports/export.ts +17 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/reports.ts +31 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/rewrite/history/[contentId].ts +3 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/rewrite/policy.ts +4 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/rewrite/queue.ts +4 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/seo/audit.ts +7 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/seo/insights.ts +5 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/settings/site.ts +7 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/settings.ts +19 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/auto-detect.ts +13 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/diff.ts +11 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/match.ts +16 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/matching-history.ts +4 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap/runs.ts +4 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/sitemap.ts +11 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/summary.ts +12 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/topics/[id]/faq.ts +12 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/topics.ts +11 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/capabilities.ts +5 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migration-plan.ts +21 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migrations/[id]/resume.ts +5 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migrations/[id]/validate.ts +6 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migrations/[id].ts +13 -0
- package/bundled-templates/astro-blog/starter/src/pages/api/ops/wordpress/migrations.ts +147 -0
- package/bundled-templates/astro-blog/starter/src/pages/authors/[slug].astro +12 -0
- package/bundled-templates/astro-blog/starter/src/pages/blog/[slug].astro +25 -0
- package/bundled-templates/astro-blog/starter/src/pages/blog/index.astro +32 -0
- package/bundled-templates/astro-blog/starter/src/pages/blog/page/[page].astro +21 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/index.astro +20 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/operations.astro +33 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/posts/[id]/edit.astro +17 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/posts/[id]/preview.astro +11 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/posts/index.astro +15 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/posts/new.astro +12 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/reports.astro +12 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/rewrite.astro +13 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/settings/integrations.astro +13 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/settings/site.astro +11 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/sitemap.astro +15 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/topics.astro +13 -0
- package/bundled-templates/astro-blog/starter/src/pages/ops/wordpress.astro +34 -0
- package/bundled-templates/astro-blog/starter/src/pages/review/[token].astro +13 -0
- package/bundled-templates/astro-blog/starter/src/pages/robots.txt.ts +4 -0
- package/bundled-templates/astro-blog/starter/src/pages/sitemap.xml.ts +12 -0
- package/bundled-templates/astro-blog/starter/src/pages/topics/[slug]/page/[page].astro +10 -0
- package/bundled-templates/astro-blog/starter/src/pages/topics/[slug].astro +19 -0
- package/bundled-templates/astro-blog/starter/src/pages/topics/index.astro +10 -0
- package/bundled-templates/astro-blog/starter/src/styles/global.css +4 -0
- package/bundled-templates/astro-blog/starter/tsconfig.json +9 -0
- package/bundled-templates/astro-blog/starter/wrangler.jsonc +52 -0
- package/bundled-tools/clis/maggie_clone.py +42 -15
- package/bundled-tools/clis/maggie_dash.py +41 -25
- package/bundled-tools/clis/maggie_design.py +2 -0
- package/bundled-tools/clis/maggie_design_system.py +7 -1
- package/bundled-tools/clis/site_audit.py +25 -2
- package/bundled-tools/runtime/maggie_memory.py +6 -1
- package/package.json +1 -1
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { db, id, now, audit } from "../../../../lib/db";
|
|
3
|
+
import { json } from "../../../../lib/ops-auth";
|
|
4
|
+
import { importWordPress, inspectWordPress } from "../../../../lib/wordpress";
|
|
5
|
+
import type { WordPressImportOptions } from "../../../../lib/wordpress";
|
|
6
|
+
|
|
7
|
+
function validSource(value: unknown): string | undefined {
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(String(value));
|
|
10
|
+
return /^https?:$/.test(url.protocol)
|
|
11
|
+
? url.toString().replace(/\/$/, "")
|
|
12
|
+
: undefined;
|
|
13
|
+
} catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function options(body: any): WordPressImportOptions {
|
|
18
|
+
const parseJson = (value: unknown) => {
|
|
19
|
+
try {
|
|
20
|
+
return typeof value === "string" ? JSON.parse(value) : value;
|
|
21
|
+
} catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const postTypes = Array.isArray(body?.postTypes)
|
|
26
|
+
? body.postTypes
|
|
27
|
+
: parseJson(body?.postTypesJson);
|
|
28
|
+
const mapping =
|
|
29
|
+
body?.mapping && typeof body.mapping === "object"
|
|
30
|
+
? body.mapping
|
|
31
|
+
: parseJson(body?.mappingJson);
|
|
32
|
+
return {
|
|
33
|
+
maxPosts: Number(body?.maxPosts || 100),
|
|
34
|
+
maxTerms: Number(body?.maxTerms || 1000),
|
|
35
|
+
mediaMode: body?.mediaMode === "all" ? "all" : "featured",
|
|
36
|
+
preservePublished: body?.preservePublished === true,
|
|
37
|
+
includeDrafts: body?.includeDrafts === true,
|
|
38
|
+
incremental: body?.incremental === true,
|
|
39
|
+
syncMode:
|
|
40
|
+
body?.syncMode === "modified" || body?.syncMode === "new"
|
|
41
|
+
? body.syncMode
|
|
42
|
+
: "all",
|
|
43
|
+
detectDeleted: body?.detectDeleted === true,
|
|
44
|
+
conflictStrategy:
|
|
45
|
+
body?.conflictStrategy === "wordpress-wins" ||
|
|
46
|
+
body?.conflictStrategy === "maggie-wins"
|
|
47
|
+
? body.conflictStrategy
|
|
48
|
+
: "manual",
|
|
49
|
+
postTypes:
|
|
50
|
+
Array.isArray(postTypes) && postTypes.length
|
|
51
|
+
? postTypes.map(String)
|
|
52
|
+
: ["post"],
|
|
53
|
+
mapping: mapping || {
|
|
54
|
+
categoryToTopic: {},
|
|
55
|
+
authorToAuthor: {},
|
|
56
|
+
tagToTaxonomy: "tag",
|
|
57
|
+
featuredImageToMedia: true,
|
|
58
|
+
seo: { yoast: true, rankMath: true, aioseo: true, customFields: true },
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const GET: APIRoute = ({ url }) => {
|
|
64
|
+
const limit = Math.min(
|
|
65
|
+
Math.max(Number(url.searchParams.get("limit") || 20), 1),
|
|
66
|
+
100,
|
|
67
|
+
);
|
|
68
|
+
return json({
|
|
69
|
+
items: db()
|
|
70
|
+
.prepare(
|
|
71
|
+
"SELECT id,source_url,mode,status,options_json,counts_json,checkpoint_json,deleted_count,conflict_count,error,started_at,completed_at FROM wp_migrations ORDER BY started_at DESC LIMIT ?",
|
|
72
|
+
)
|
|
73
|
+
.all(limit),
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
export const POST: APIRoute = async ({ request }) => {
|
|
77
|
+
const body = (await request.json().catch(() => null)) as any;
|
|
78
|
+
const sourceUrl = validSource(body?.sourceUrl);
|
|
79
|
+
if (!sourceUrl)
|
|
80
|
+
return json(
|
|
81
|
+
{ error: "sourceUrl must be an absolute http(s) WordPress site URL" },
|
|
82
|
+
400,
|
|
83
|
+
);
|
|
84
|
+
const mode = body?.mode === "apply" ? "apply" : "preview";
|
|
85
|
+
const migrationId = id("wp_migration");
|
|
86
|
+
const timestamp = now();
|
|
87
|
+
const importOptions = options(body);
|
|
88
|
+
db()
|
|
89
|
+
.prepare(
|
|
90
|
+
"INSERT INTO wp_migrations (id,source_url,mode,status,options_json,started_at) VALUES (?,?,?,?,?,?)",
|
|
91
|
+
)
|
|
92
|
+
.run(
|
|
93
|
+
migrationId,
|
|
94
|
+
sourceUrl,
|
|
95
|
+
mode,
|
|
96
|
+
"running",
|
|
97
|
+
JSON.stringify(importOptions),
|
|
98
|
+
timestamp,
|
|
99
|
+
);
|
|
100
|
+
try {
|
|
101
|
+
if (mode === "preview") {
|
|
102
|
+
const result = await inspectWordPress(sourceUrl, importOptions);
|
|
103
|
+
db()
|
|
104
|
+
.prepare(
|
|
105
|
+
"UPDATE wp_migrations SET status='completed',counts_json=?,completed_at=? WHERE id=?",
|
|
106
|
+
)
|
|
107
|
+
.run(JSON.stringify(result.totals), now(), migrationId);
|
|
108
|
+
audit(
|
|
109
|
+
"wordpress.migration.previewed",
|
|
110
|
+
"wordpress_migration",
|
|
111
|
+
migrationId,
|
|
112
|
+
null,
|
|
113
|
+
result.totals,
|
|
114
|
+
);
|
|
115
|
+
return json({
|
|
116
|
+
id: migrationId,
|
|
117
|
+
mode,
|
|
118
|
+
status: "completed",
|
|
119
|
+
counts: result.totals,
|
|
120
|
+
sample: result.sample,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const counts = await importWordPress(migrationId, sourceUrl, importOptions);
|
|
124
|
+
db()
|
|
125
|
+
.prepare(
|
|
126
|
+
"UPDATE wp_migrations SET status='completed',counts_json=?,completed_at=? WHERE id=?",
|
|
127
|
+
)
|
|
128
|
+
.run(JSON.stringify(counts), now(), migrationId);
|
|
129
|
+
audit(
|
|
130
|
+
"wordpress.migration.applied",
|
|
131
|
+
"wordpress_migration",
|
|
132
|
+
migrationId,
|
|
133
|
+
null,
|
|
134
|
+
counts,
|
|
135
|
+
);
|
|
136
|
+
return json({ id: migrationId, mode, status: "completed", counts }, 201);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
const message =
|
|
139
|
+
error instanceof Error ? error.message : "WordPress migration failed";
|
|
140
|
+
db()
|
|
141
|
+
.prepare(
|
|
142
|
+
"UPDATE wp_migrations SET status='failed',error=?,completed_at=? WHERE id=?",
|
|
143
|
+
)
|
|
144
|
+
.run(message, now(), migrationId);
|
|
145
|
+
return json({ id: migrationId, status: "failed", error: message }, 502);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
import BaseLayout from "../../layouts/BaseLayout.astro";
|
|
3
|
+
import { authorBySlug, postsForAuthor, siteUrl } from "../../lib/repository";
|
|
4
|
+
import { authorJsonLd } from "../../lib/seo";
|
|
5
|
+
const author = authorBySlug(Astro.params.slug || "");
|
|
6
|
+
if (!author) { Astro.response.status = 404; return Astro.rewrite("/404"); }
|
|
7
|
+
const authorPosts = postsForAuthor(author.slug);
|
|
8
|
+
---
|
|
9
|
+
<BaseLayout title={author.name} description={author.bio || `Articles by ${author.name}.`} canonical={siteUrl(`/authors/${author.slug}`)} jsonLd={authorJsonLd(author, siteUrl(`/authors/${author.slug}`))}>
|
|
10
|
+
<h1>{author.name}</h1><p>{author.jobTitle}</p><p>{author.bio}</p>{author.credentials?.length && <ul>{author.credentials.map((credential) => <li>{credential}</li>)}</ul>}
|
|
11
|
+
<h2>Published articles</h2>{authorPosts.map((post) => <article><h3><a href={`/blog/${post.slug}`}>{post.title}</a></h3><p>{post.excerpt}</p></article>)}
|
|
12
|
+
</BaseLayout>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
import BaseLayout from "../../layouts/BaseLayout.astro";
|
|
3
|
+
import { allTopics, postBySlug, relatedPosts } from "../../lib/repository";
|
|
4
|
+
import { faqJsonLd, postJsonLd } from "../../lib/seo";
|
|
5
|
+
import type { BlogPost } from "../../lib/types";
|
|
6
|
+
|
|
7
|
+
const post = postBySlug(Astro.params.slug || "") as BlogPost | undefined;
|
|
8
|
+
const topics = allTopics();
|
|
9
|
+
if (!post) { Astro.response.status = 404; return Astro.rewrite("/404"); }
|
|
10
|
+
---
|
|
11
|
+
<BaseLayout title={post.seoTitle || post.title} description={post.metaDescription || post.excerpt} canonical={post.canonicalUrl} ogType="article" imageUrl={post.coverImageUrl} robots={`${post.robotsIndex || "index"},${post.robotsFollow || "follow"}`} alternates={post.alternates} jsonLd={[postJsonLd(post), ...(post.faq.length ? [faqJsonLd(post.faq)] : [])]}>
|
|
12
|
+
<article>
|
|
13
|
+
<header>
|
|
14
|
+
<p>{post.topicSlugs.map((slug) => <a href={`/topics/${slug}`}>{topics.find((topic) => topic.slug === slug)?.title}</a>)}</p>
|
|
15
|
+
<h1>{post.title}</h1>
|
|
16
|
+
<p>{post.excerpt}</p>
|
|
17
|
+
<p>By <a href={`/authors/${post.author.slug}`}>{post.author.name}</a>{post.author.credentials?.length ? ` · ${post.author.credentials.join(" · ")}` : ""}</p>
|
|
18
|
+
<p><time datetime={post.publishedAt}>Published {post.publishedAt?.slice(0, 10)}</time>{post.updatedAt && <time datetime={post.updatedAt}> · Updated {post.updatedAt.slice(0, 10)}</time>}</p>
|
|
19
|
+
</header>
|
|
20
|
+
<div set:html={post.contentHtml} />
|
|
21
|
+
{post.faq.length > 0 && <section><h2>FAQs</h2>{post.faq.map((faq) => <details><summary>{faq.question}</summary><p>{faq.answer}</p></details>)}</section>}
|
|
22
|
+
{relatedPosts(post).length > 0 && <section aria-labelledby="related-title"><h2 id="related-title">Related articles</h2>{relatedPosts(post).map((related) => <article><h3><a href={`/blog/${related.slug}`}>{related.title}</a></h3><p>{related.excerpt}</p></article>)}</section>}
|
|
23
|
+
{post.ctaUrl && <aside><a href={post.ctaUrl}>{post.ctaLabel}</a></aside>}
|
|
24
|
+
</article>
|
|
25
|
+
</BaseLayout>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
import BaseLayout from "../../layouts/BaseLayout.astro";
|
|
3
|
+
import { allTopics, publishedPosts, siteUrl } from "../../lib/repository";
|
|
4
|
+
import { runtimeConfig } from "../../lib/config";
|
|
5
|
+
|
|
6
|
+
const published = publishedPosts();
|
|
7
|
+
const topics = allTopics();
|
|
8
|
+
const pageSize = runtimeConfig().site.postsPerPage;
|
|
9
|
+
const hasNext = published.length > pageSize;
|
|
10
|
+
---
|
|
11
|
+
<BaseLayout title="Blog" description="Practical guidance and insights." canonical={siteUrl("/blog")} robots={published.length ? "index,follow" : "noindex,follow"}>
|
|
12
|
+
<section aria-labelledby="blog-title">
|
|
13
|
+
<h1 id="blog-title">Ideas for building better systems</h1>
|
|
14
|
+
<p>Research, practical guides, and maintained answers.</p>
|
|
15
|
+
</section>
|
|
16
|
+
<nav aria-label="Hot topics">
|
|
17
|
+
<h2>Hot topics</h2>
|
|
18
|
+
{topics.filter((topic) => topic.status === "published").map((topic) => <a href={`/topics/${topic.slug}`}>{topic.title}</a>)}
|
|
19
|
+
</nav>
|
|
20
|
+
<section aria-labelledby="latest-title">
|
|
21
|
+
<h2 id="latest-title">Latest articles</h2>
|
|
22
|
+
<div class="post-grid">
|
|
23
|
+
{published.slice(0, pageSize).map((post) => <article><h3><a href={`/blog/${post.slug}`}>{post.title}</a></h3><p>{post.excerpt}</p><p><time datetime={post.publishedAt}>{post.publishedAt?.slice(0, 10)}</time> · {post.author.name}</p></article>)}
|
|
24
|
+
</div>
|
|
25
|
+
{hasNext && <nav aria-label="Pagination"><a href="/blog/page/2">Next</a></nav>}
|
|
26
|
+
</section>
|
|
27
|
+
<section aria-labelledby="faq-title">
|
|
28
|
+
<h2 id="faq-title">FAQs</h2>
|
|
29
|
+
{published.flatMap((post) => post.faq).map((faq) => <details><summary>{faq.question}</summary><p>{faq.answer}</p></details>)}
|
|
30
|
+
</section>
|
|
31
|
+
<aside><h2>Keep learning</h2><p>Get a clearer content operating system.</p><a href="/get-started">Get started</a></aside>
|
|
32
|
+
</BaseLayout>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
import BaseLayout from "../../../layouts/BaseLayout.astro";
|
|
3
|
+
import { publishedPosts, siteUrl } from "../../../lib/repository";
|
|
4
|
+
|
|
5
|
+
const pageSize = 12;
|
|
6
|
+
const published = publishedPosts();
|
|
7
|
+
const pageNumber = Number(Astro.params.page || 1);
|
|
8
|
+
const pagePosts = published.slice((pageNumber - 1) * pageSize, pageNumber * pageSize);
|
|
9
|
+
const canonical = siteUrl(`/blog/page/${pageNumber}`);
|
|
10
|
+
const previousHref = pageNumber === 2 ? "/blog" : `/blog/page/${pageNumber - 1}`;
|
|
11
|
+
const nextHref = `/blog/page/${pageNumber + 1}`;
|
|
12
|
+
const hasNext = pageNumber < Math.ceil(published.length / pageSize);
|
|
13
|
+
---
|
|
14
|
+
<BaseLayout title={`Blog · Page ${pageNumber}`} description="Browse published articles." canonical={canonical}>
|
|
15
|
+
<h1>Blog · Page {pageNumber}</h1>
|
|
16
|
+
<div class="post-grid">{pagePosts.map((post) => <article><h2><a href={`/blog/${post.slug}`}>{post.title}</a></h2><p>{post.excerpt}</p></article>)}</div>
|
|
17
|
+
<nav aria-label="Pagination">
|
|
18
|
+
{pageNumber > 1 && <a href={previousHref}>Previous</a>}
|
|
19
|
+
{hasNext && <a href={nextHref}>Next</a>}
|
|
20
|
+
</nav>
|
|
21
|
+
</BaseLayout>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
import OpsLayout from "../../layouts/OpsLayout.astro";
|
|
3
|
+
---
|
|
4
|
+
<OpsLayout title="Overview" description="Private content operations dashboard." canonical="https://example.com/ops">
|
|
5
|
+
<div class="maggie-page-head"><div><h1>Good morning, Operator</h1><p>Here is what needs attention across your content system.</p></div><a class="maggie-primary" href="/ops/posts/new">+ New draft</a></div>
|
|
6
|
+
<section class="maggie-grid maggie-metrics" aria-label="Content summary">
|
|
7
|
+
<article class="maggie-card"><div class="maggie-metric-label">Published posts</div><div class="maggie-metric-value" id="published-count">—</div><div class="maggie-metric-note">Public and indexable</div></article>
|
|
8
|
+
<article class="maggie-card"><div class="maggie-metric-label">Needs review</div><div class="maggie-metric-value" id="review-count">—</div><div class="maggie-metric-note">Approval workflow</div></article>
|
|
9
|
+
<article class="maggie-card"><div class="maggie-metric-label">Rewrite eligible</div><div class="maggie-metric-value" id="rewrite-count">—</div><div class="maggie-metric-note">Matched sitemap posts</div></article>
|
|
10
|
+
<article class="maggie-card"><div class="maggie-metric-label">Open SEO errors</div><div class="maggie-metric-value" id="error-count">—</div><div class="maggie-metric-note">Latest audit</div></article>
|
|
11
|
+
</section>
|
|
12
|
+
<section class="maggie-grid maggie-two-col">
|
|
13
|
+
<article class="maggie-card"><div class="maggie-card-head"><h2>Continue operating</h2><a href="/ops/operations">View all operations →</a></div><div class="maggie-action-grid"><a class="maggie-action" href="/ops/posts"><strong>Review content</strong><span>Inspect drafts, approvals, and recent changes.</span></a><a class="maggie-action" href="/ops/sitemap"><strong>Match a sitemap</strong><span>Find tracked posts and see eligible rewrite count.</span></a><a class="maggie-action" href="/ops/rewrite"><strong>Process rewrite queue</strong><span>Review matched items before sending them to Maggie.</span></a><a class="maggie-action" href="/ops/reports"><strong>Run an SEO audit</strong><span>Check indexability, metadata, content, and GEO signals.</span></a></div></article>
|
|
14
|
+
<article class="maggie-card"><div class="maggie-card-head"><h2>System health</h2><a href="/ops/settings/integrations">Settings →</a></div><ul class="maggie-list" id="health-list"><li><span>Loading health…</span></li></ul></article>
|
|
15
|
+
</section>
|
|
16
|
+
<section class="maggie-card" style="margin-top:22px"><div class="maggie-card-head"><h2>Next best actions</h2><a href="/ops/reports">Open reports →</a></div><ul class="maggie-list"><li><span><strong>Keep the public path clean</strong><small>Only published posts with valid canonical URLs enter the sitemap.</small></span><span class="maggie-pill">Guarded</span></li><li><span><strong>Review sitemap matches before rewrite</strong><small>Matching identifies candidates. It never publishes content automatically.</small></span><span class="maggie-pill warn">Review</span></li><li><span><strong>Connect measurement</strong><small>GA4 and GSC status are shown without exposing provider credentials.</small></span><span class="maggie-pill">Optional</span></li></ul></section>
|
|
17
|
+
</OpsLayout>
|
|
18
|
+
<script>
|
|
19
|
+
fetch("/api/ops/summary").then((response) => response.json()).then((data) => { document.querySelector("#published-count")!.textContent = String(data.content?.published ?? 0); document.querySelector("#review-count")!.textContent = String(data.content?.review ?? 0); document.querySelector("#rewrite-count")!.textContent = String(data.sitemap?.eligible ?? 0); document.querySelector("#error-count")!.textContent = String(data.reports?.openErrors ?? 0); const health = document.querySelector("#health-list"); if (health) health.innerHTML = `<li><span>AI CMO API<small>Connection for API Pull and rewrite</small></span><span class="maggie-pill">${data.integrations?.aiCmo || "unknown"}</span></li><li><span>Latest sitemap match<small>${data.sitemap?.lastRunAt || "No run yet"}</small></span><span class="maggie-pill">${data.sitemap?.matched ?? 0} matched</span></li><li><span>Analytics<small>Consent-aware measurement</small></span><span class="maggie-pill">${data.integrations?.ga4 || "disabled"}</span></li>`; }).catch(() => { document.querySelectorAll(".maggie-metric-value").forEach((item) => { item.textContent = "—"; }); });
|
|
20
|
+
</script>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
import OpsLayout from "../../layouts/OpsLayout.astro";
|
|
3
|
+
---
|
|
4
|
+
<OpsLayout title="Content operations" description="Calendar, revisions, tasks, redirects and agency operations." canonical="https://example.com/ops/operations">
|
|
5
|
+
<h1>Content Operations</h1>
|
|
6
|
+
<p>Operate publishing with explicit previews, audit records and safe state transitions.</p>
|
|
7
|
+
<nav aria-label="Operations"><a href="/ops">Ops home</a> · <a href="/ops/posts">Posts</a> · <a href="/ops/wordpress">Migration</a></nav>
|
|
8
|
+
<section><h2>Content calendar</h2><form id="calendar-filters"><label>Campaign <input name="campaign" placeholder="optional" /></label><label>Author ID <input name="authorId" placeholder="optional" /></label><label>Topic ID <input name="topicId" placeholder="optional" /></label><label><input type="checkbox" name="overdue" /> Overdue only</label><button type="submit">Refresh calendar</button></form><div id="calendar" aria-live="polite">Loading…</div><h3>Publication queue</h3><div id="publication-queue">Loading…</div></section>
|
|
9
|
+
<section><h2>Tasks</h2><button data-load="tasks">Refresh</button><pre id="tasks">Loading…</pre></section>
|
|
10
|
+
<section><h2>Redirects</h2><button data-load="redirects">Refresh</button><pre id="redirects">Loading…</pre></section>
|
|
11
|
+
<section><h2>Media library</h2><form id="media-filters"><label>Search <input name="search" placeholder="URL, alt text, caption" /></label><label>MIME <input name="mime" placeholder="image/jpeg" /></label><label>Optimization <select name="optimizationStatus"><option value="">Any status</option><option>pending</option><option>queued</option><option>completed</option><option>failed</option></select></label><button type="submit">Filter media</button></form><p>Use <code>POST /api/ops/media/optimize</code> with a mediaId to generate WebP/AVIF derivatives.</p><pre id="media">Loading…</pre></section>
|
|
12
|
+
<section><h2>Agency workspace</h2><button data-load="workspaces">Refresh</button><pre id="workspaces">Loading…</pre></section>
|
|
13
|
+
<section><h2>SEO audit</h2><button id="run-seo">Run technical/content audit</button><pre id="seo">Not run.</pre></section>
|
|
14
|
+
<section><h2>SEO/GEO insights</h2><button id="load-insights">Load internal links, topics, FAQ and entity insights</button><pre id="insights">Not loaded.</pre></section>
|
|
15
|
+
</OpsLayout>
|
|
16
|
+
<script>
|
|
17
|
+
const renderCalendar = (items: Array<{ scheduled_at: string; campaign?: string; title: string; slug: string; status: string }>) => { const calendar = document.querySelector("#calendar"); const queue = document.querySelector("#publication-queue"); if (!calendar || !queue) return; if (!items.length) calendar.textContent = "No scheduled content."; else { const groups = new Map<string, typeof items>(); items.forEach((item) => { const day = (item.scheduled_at || "").slice(0, 10) || "Unscheduled"; groups.set(day, [...(groups.get(day) || []), item]); }); calendar.replaceChildren(...[...groups].map(([day, entries]) => { const section = document.createElement("section"); const heading = document.createElement("h3"); heading.textContent = day; section.append(heading, ...entries.map((item) => { const card = document.createElement("article"); card.textContent = `${new Date(item.scheduled_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} · ${item.title} · ${item.status}${item.campaign ? ` · ${item.campaign}` : ""}`; return card; })); return section; })); } queue.textContent = items.filter((item) => item.status === "scheduled" || item.status === "approved").map((item) => `${item.title} · ${item.scheduled_at}`).join("\n") || "Queue is empty."; };
|
|
18
|
+
const loadCalendar = async () => { const form = document.querySelector<HTMLFormElement>("#calendar-filters"); const params = new URLSearchParams(form ? Object.fromEntries(new FormData(form)) as Record<string, string> : {}); if (form?.querySelector<HTMLInputElement>("[name=overdue]")?.checked) params.set("overdue", "true"); try { const response = await fetch(`/api/ops/operations?action=calendar&${params}`); const data = await response.json(); renderCalendar(data.items || []); } catch { const target = document.querySelector("#calendar"); if (target) target.textContent = "Unable to load calendar."; } };
|
|
19
|
+
document.querySelector<HTMLFormElement>("#calendar-filters")?.addEventListener("submit", (event) => { event.preventDefault(); loadCalendar(); }); loadCalendar();
|
|
20
|
+
for (const button of document.querySelectorAll<HTMLButtonElement>("button[data-load]")) {
|
|
21
|
+
const key = button.dataset.load || "calendar";
|
|
22
|
+
const target = document.querySelector(`#${key}`);
|
|
23
|
+
const load = () => fetch(`/api/ops/operations?action=${encodeURIComponent(key)}`).then((response) => response.json()).then((data) => { if (target) target.textContent = JSON.stringify(data, null, 2); }).catch(() => { if (target) target.textContent = "Unable to load operation data."; });
|
|
24
|
+
button.addEventListener("click", load); if (key !== "calendar") load();
|
|
25
|
+
}
|
|
26
|
+
const mediaForm = document.querySelector<HTMLFormElement>("#media-filters"); const loadMedia = async () => { const params = new URLSearchParams(mediaForm ? Object.fromEntries(new FormData(mediaForm)) as Record<string, string> : {}); const response = await fetch(`/api/ops/operations?action=media&${params}`); const data = await response.json(); const target = document.querySelector("#media"); if (target) target.textContent = JSON.stringify(data, null, 2); }; mediaForm?.addEventListener("submit", (event) => { event.preventDefault(); loadMedia(); }); loadMedia();
|
|
27
|
+
</script>
|
|
28
|
+
<script>
|
|
29
|
+
document.querySelector<HTMLButtonElement>("#load-insights")?.addEventListener("click", () => fetch("/api/ops/seo/insights").then((response) => response.json()).then((data) => { const target = document.querySelector("#insights"); if (target) target.textContent = JSON.stringify(data, null, 2); }));
|
|
30
|
+
</script>
|
|
31
|
+
<script>
|
|
32
|
+
document.querySelector<HTMLButtonElement>("#run-seo")?.addEventListener("click", () => fetch("/api/ops/seo/audit", { method: "POST" }).then((response) => response.json()).then((data) => { const target = document.querySelector("#seo"); if (target) target.textContent = JSON.stringify(data, null, 2); }));
|
|
33
|
+
</script>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
import OpsLayout from "../../../../layouts/OpsLayout.astro";
|
|
3
|
+
import { siteUrl } from "../../../../lib/repository";
|
|
4
|
+
import { db } from "../../../../lib/db";
|
|
5
|
+
const id = Astro.params.id || "";
|
|
6
|
+
const post = import.meta.env.BLOG_DATA_SOURCE === "sqlite" ? db().prepare("SELECT * FROM posts WHERE id=?").get(id) as any : undefined;
|
|
7
|
+
---
|
|
8
|
+
<OpsLayout title="Edit post" description="Private post editor." canonical={siteUrl("/ops/posts/edit")}>
|
|
9
|
+
<h1>Edit post</h1>
|
|
10
|
+
<p>All writes go through the authenticated server API; publishing remains a separate guarded action.</p>
|
|
11
|
+
{post ? <><p><a href={`/ops/posts/${id}/preview`}>Open noindex preview</a></p><form id="edit-post"><label>Title <input name="title" value={post.title} required /></label><label>Slug <input name="slug" value={post.slug} required /></label><label>Excerpt <textarea name="excerpt" required>{post.excerpt}</textarea></label><label>Canonical URL <input type="url" name="canonicalUrl" value={post.canonical_url} required /></label><label>Content HTML <textarea name="contentHtml" rows="16">{post.content_html || ""}</textarea></label><label>Status <select name="status">{["draft","review","approved","scheduled","published","archived"].map((status) => <option selected={post.status === status}>{status}</option>)}</select></label><button type="submit">Save</button><button id="publish" type="button">Publish approved post</button><p id="result" aria-live="polite"></p></form></> : <p>Post not found in the configured SQLite repository.</p>}
|
|
12
|
+
</OpsLayout>
|
|
13
|
+
{post && <script is:inline define:vars={{ id }}>
|
|
14
|
+
const form = document.querySelector("#edit-post"); const result = document.querySelector("#result");
|
|
15
|
+
form?.addEventListener("submit", async (event) => { event.preventDefault(); const body = Object.fromEntries(new FormData(form)); const response = await fetch(`/api/ops/posts/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); const data = await response.json(); if (result) result.textContent = response.ok ? "Saved." : data.error; });
|
|
16
|
+
document.querySelector("#publish")?.addEventListener("click", async () => { const response = await fetch(`/api/ops/posts/${id}/publish`, { method: "POST" }); const data = await response.json(); if (result) result.textContent = response.ok ? "Published." : data.error; });
|
|
17
|
+
</script>}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
import OpsLayout from "../../../../layouts/OpsLayout.astro";
|
|
3
|
+
import { db } from "../../../../lib/db";
|
|
4
|
+
import { siteUrl } from "../../../../lib/repository";
|
|
5
|
+
const id = Astro.params.id || "";
|
|
6
|
+
const post = import.meta.env.BLOG_DATA_SOURCE === "sqlite" ? db().prepare("SELECT p.*, a.name author_name FROM posts p JOIN authors a ON a.id=p.author_id WHERE p.id=?").get(id) as any : undefined;
|
|
7
|
+
if (!post) { Astro.response.status = 404; return Astro.rewrite("/404"); }
|
|
8
|
+
---
|
|
9
|
+
<OpsLayout title={`Preview · ${post.title}`} description={post.excerpt || "Private preview"} canonical={siteUrl(`/ops/posts/${post.id}/preview`)}>
|
|
10
|
+
<p><strong>Preview only — this page is noindex and is not in the sitemap.</strong></p><article><h1>{post.title}</h1><p>{post.excerpt}</p><p>By {post.author_name}</p><div set:html={post.content_html || "<p>No rendered content yet.</p>"} /></article>
|
|
11
|
+
</OpsLayout>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
import OpsLayout from "../../../layouts/OpsLayout.astro";
|
|
3
|
+
import { publishedPosts, siteUrl } from "../../../lib/repository";
|
|
4
|
+
import { db } from "../../../lib/db";
|
|
5
|
+
const posts = import.meta.env.BLOG_DATA_SOURCE === "sqlite" ? db().prepare("SELECT id,title,status,slug,updated_at FROM posts ORDER BY COALESCE(updated_at,created_at) DESC").all() as any[] : publishedPosts();
|
|
6
|
+
---
|
|
7
|
+
<OpsLayout title="Content" description="Private post inventory." canonical={siteUrl("/ops/posts")}>
|
|
8
|
+
<h1>Posts</h1>
|
|
9
|
+
<p><a href="/ops/posts/new">New draft</a></p>
|
|
10
|
+
<label>Filter <select id="status-filter"><option value="">All</option><option>draft</option><option>review</option><option>approved</option><option>published</option><option>archived</option></select></label>
|
|
11
|
+
<div id="post-list">{posts.map((post) => <article data-status={post.status}><h2><a href={`/ops/posts/${post.id}/edit`}>{post.title}</a></h2><p>{post.status} · {post.slug}</p></article>)}</div>
|
|
12
|
+
</OpsLayout>
|
|
13
|
+
<script>
|
|
14
|
+
document.querySelector("#status-filter")?.addEventListener("change", (event) => { const value = (event.target as HTMLSelectElement).value; document.querySelectorAll<HTMLElement>("#post-list article").forEach((item) => { item.hidden = Boolean(value && item.dataset.status !== value); }); });
|
|
15
|
+
</script>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
import OpsLayout from "../../../layouts/OpsLayout.astro";
|
|
3
|
+
import { siteUrl } from "../../../lib/repository";
|
|
4
|
+
---
|
|
5
|
+
<OpsLayout title="New draft" description="Private post editor." canonical={siteUrl("/ops/posts/new")}>
|
|
6
|
+
<h1>New draft</h1>
|
|
7
|
+
<p>New content starts as draft and must pass validation before review or approval.</p>
|
|
8
|
+
<form id="new-post"><label>Title <input name="title" required /></label><label>Slug <input name="slug" required /></label><label>Excerpt <textarea name="excerpt" required></textarea></label><label>Canonical URL <input type="url" name="canonicalUrl" required /></label><label>Author ID <input name="authorId" required /></label><label>Content HTML <textarea name="contentHtml" rows="12"></textarea></label><button type="submit">Save draft</button><p id="result" aria-live="polite"></p></form>
|
|
9
|
+
</OpsLayout>
|
|
10
|
+
<script>
|
|
11
|
+
document.querySelector("#new-post")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget as HTMLFormElement; const body = Object.fromEntries(new FormData(form)) as Record<string, string>; body.status = "draft"; const response = await fetch("/api/ops/posts", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); const result = document.querySelector("#result"); const data = await response.json(); if (result) result.textContent = response.ok ? `Saved. ID: ${data.id}` : data.error; });
|
|
12
|
+
</script>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
import OpsLayout from "../../layouts/OpsLayout.astro";
|
|
3
|
+
import { siteUrl } from "../../lib/repository";
|
|
4
|
+
---
|
|
5
|
+
<OpsLayout title="Reports" description="Private SEO and content reports." canonical={siteUrl("/ops/reports")}>
|
|
6
|
+
<h1>Reports</h1><p>Run and inspect technical SEO, content quality, visibility, and analytics reports.</p>
|
|
7
|
+
<label>Report type <select id="report-type"><option value="technical_seo">Technical SEO</option><option value="content_quality">Content quality</option><option value="visibility">GSC visibility</option><option value="analytics">GA4 analytics</option></select></label><button id="run-report">Run report</button><p id="report-result" aria-live="polite"></p><pre id="report-list">Loading…</pre>
|
|
8
|
+
</OpsLayout>
|
|
9
|
+
<script>
|
|
10
|
+
const out = document.querySelector("#report-list"); const load = () => fetch("/api/ops/reports").then((r) => r.json()).then((data) => { if (out) out.textContent = JSON.stringify(data.items, null, 2); }); load();
|
|
11
|
+
document.querySelector("#run-report")?.addEventListener("click", async () => { const runType = (document.querySelector("#report-type") as HTMLSelectElement).value; const response = await fetch("/api/ops/reports", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ runType }) }); const data = await response.json(); const result = document.querySelector("#report-result"); if (result) result.textContent = response.ok ? `${data.status}: ${data.id}` : data.error; load(); });
|
|
12
|
+
</script>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import OpsLayout from "../../layouts/OpsLayout.astro";
|
|
3
|
+
import { siteUrl } from "../../lib/repository";
|
|
4
|
+
---
|
|
5
|
+
<OpsLayout title="Rewrite queue" description="Private rewrite queue management." canonical={siteUrl("/ops/rewrite")}>
|
|
6
|
+
<h1>Rewrite queue</h1><p>Queue only sitemap-matched content. The external API remains the rewrite source of truth; this page never publishes a rewrite automatically.</p>
|
|
7
|
+
<form id="rewrite-form"><label>Content ID <input name="contentId" required /></label><label>Reason <input name="reason" value="sitemap-matched rewrite" required /></label><button type="submit">Queue rewrite</button><p id="rewrite-result" aria-live="polite"></p></form>
|
|
8
|
+
<h2>Current queue</h2><pre id="rewrite-queue">Loading…</pre>
|
|
9
|
+
</OpsLayout>
|
|
10
|
+
<script>
|
|
11
|
+
const queue = document.querySelector("#rewrite-queue"); const result = document.querySelector("#rewrite-result"); const load = () => fetch("/api/ops/rewrite/queue").then((response) => response.json()).then((data) => { if (queue) queue.textContent = JSON.stringify(data, null, 2); }); load();
|
|
12
|
+
document.querySelector("#rewrite-form")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget as HTMLFormElement; const fields = Object.fromEntries(new FormData(form)); const response = await fetch("/api/ops/rewrite/queue", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ content_id: fields.contentId, reason: fields.reason }) }); const data = await response.json(); if (result) result.textContent = response.ok ? "Rewrite queued." : data.error || JSON.stringify(data); load(); });
|
|
13
|
+
</script>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import OpsLayout from "../../../layouts/OpsLayout.astro";
|
|
3
|
+
import { siteUrl } from "../../../lib/repository";
|
|
4
|
+
---
|
|
5
|
+
<OpsLayout title="Integrations" description="Private integration settings." canonical={siteUrl("/ops/settings/integrations")}>
|
|
6
|
+
<h1>Integrations</h1><p>AI CMO, GSC, and GA4 settings show configured or verified status only. Secret values are never rendered.</p>
|
|
7
|
+
<form id="settings"><label>Default author ID <input name="defaultAuthorId" /></label><label>AI CMO enabled <input type="checkbox" name="aiCmoEnabled" checked /></label><label>GSC enabled <input type="checkbox" name="gscEnabled" checked /></label><label>GA4 enabled <input type="checkbox" name="ga4Enabled" checked /></label><button type="submit">Save settings</button><button id="sync" type="button">Sync API Pull posts</button><p id="settings-result" aria-live="polite"></p></form><h2>Runtime integration status</h2><pre id="settings-list">Loading…</pre>
|
|
8
|
+
</OpsLayout>
|
|
9
|
+
<script>
|
|
10
|
+
const output = document.querySelector("#settings-list"); fetch("/api/ops/settings").then((r) => r.json()).then((data) => { if (output) output.textContent = JSON.stringify(data, null, 2); });
|
|
11
|
+
document.querySelector("#settings")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget as HTMLFormElement; const fields = Object.fromEntries(new FormData(form)) as Record<string, unknown>; fields.aiCmoEnabled = (form.elements.namedItem("aiCmoEnabled") as HTMLInputElement).checked; fields.gscEnabled = (form.elements.namedItem("gscEnabled") as HTMLInputElement).checked; fields.ga4Enabled = (form.elements.namedItem("ga4Enabled") as HTMLInputElement).checked; const response = await fetch("/api/ops/settings", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(fields) }); const result = document.querySelector("#settings-result"); if (result) result.textContent = response.ok ? "Saved." : (await response.json()).error; });
|
|
12
|
+
document.querySelector("#sync")?.addEventListener("click", async () => { const response = await fetch("/api/ops/pull/sync", { method: "POST" }); const data = await response.json(); const result = document.querySelector("#settings-result"); if (result) result.textContent = response.ok ? `Imported ${data.imported}; skipped ${data.skipped}.` : data.error; });
|
|
13
|
+
</script>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
import OpsLayout from "../../../layouts/OpsLayout.astro";
|
|
3
|
+
import { siteUrl } from "../../../lib/repository";
|
|
4
|
+
---
|
|
5
|
+
<OpsLayout title="Site settings" description="Private site configuration." canonical={siteUrl("/ops/settings/site")}>
|
|
6
|
+
<h1>Site settings</h1><p>Public origin and editorial defaults are stored as non-secret settings. Credentials remain environment-only.</p>
|
|
7
|
+
<form id="site-settings"><label>Site name <input name="siteName" /></label><label>Posts per page <input type="number" min="1" max="100" name="postsPerPage" value="9" /></label><label>Grid columns <input type="number" min="1" max="6" name="gridColumns" value="3" /></label><label>Default author ID <input name="defaultAuthorId" /></label><label>Default CTA ID <input name="defaultCtaId" /></label><button type="submit">Save site settings</button><p id="site-result" aria-live="polite"></p></form>
|
|
8
|
+
</OpsLayout>
|
|
9
|
+
<script>
|
|
10
|
+
document.querySelector("#site-settings")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget as HTMLFormElement; const body = Object.fromEntries(new FormData(form)); const response = await fetch("/api/ops/settings/site", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); const data = await response.json(); const result = document.querySelector("#site-result"); if (result) result.textContent = response.ok ? "Saved." : data.error; });
|
|
11
|
+
</script>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
import OpsLayout from "../../layouts/OpsLayout.astro";
|
|
3
|
+
import { siteUrl } from "../../lib/repository";
|
|
4
|
+
---
|
|
5
|
+
<OpsLayout title="Sitemap & matching" description="Private sitemap management." canonical={siteUrl("/ops/sitemap")}>
|
|
6
|
+
<h1>Sitemap management</h1><p>Review sources, matching history, eligible posts, unmatched URLs, and the last successful generation.</p>
|
|
7
|
+
<form id="sitemap-form"><label>Sitemap URL <input type="url" name="url" required /></label><button type="submit">Save sitemap</button><button id="match" type="button">Match sitemap</button><button id="detect" type="button">Auto-detect</button><p id="sitemap-result" aria-live="polite"></p></form>
|
|
8
|
+
<pre id="sitemap-list">Loading…</pre>
|
|
9
|
+
</OpsLayout>
|
|
10
|
+
<script>
|
|
11
|
+
const result = document.querySelector("#sitemap-result"); const list = document.querySelector("#sitemap-list"); const read = () => fetch("/api/ops/sitemap").then((r) => r.json()).then((data) => { if (list) list.textContent = JSON.stringify(data, null, 2); }); read();
|
|
12
|
+
document.querySelector("#sitemap-form")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget as HTMLFormElement; const data = Object.fromEntries(new FormData(form)); const response = await fetch("/api/ops/sitemap", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(data) }); const body = await response.json(); if (result) result.textContent = response.ok ? "Sitemap saved." : body.error; read(); });
|
|
13
|
+
document.querySelector("#match")?.addEventListener("click", async () => { const input = document.querySelector("[name=url]") as HTMLInputElement; const sitemapUrl = input.value; const response = await fetch("/api/ops/sitemap/match", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sitemapUrl }) }); const body = await response.json(); if (result) result.textContent = response.ok ? "Matching request completed." : body.error || JSON.stringify(body); read(); });
|
|
14
|
+
document.querySelector("#detect")?.addEventListener("click", async () => { const response = await fetch("/api/ops/sitemap/auto-detect", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) }); const body = await response.json(); if (result) result.textContent = response.ok ? "Auto-detect request completed." : body.error || JSON.stringify(body); read(); });
|
|
15
|
+
</script>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import OpsLayout from "../../layouts/OpsLayout.astro";
|
|
3
|
+
import { siteUrl } from "../../lib/repository";
|
|
4
|
+
---
|
|
5
|
+
<OpsLayout title="Topics & FAQs" description="Private topic and FAQ management." canonical={siteUrl("/ops/topics")}>
|
|
6
|
+
<h1>Topics and FAQs</h1><p>Create an indexable topic only when it has a description and enough published posts. FAQ schema requires visible answers.</p>
|
|
7
|
+
<form id="topic-form"><label>Title <input name="title" required /></label><label>Slug <input name="slug" required /></label><label>Description <textarea name="description" required></textarea></label><button type="submit">Create topic</button><p id="topic-result" aria-live="polite"></p></form>
|
|
8
|
+
<section><h2>Existing topics</h2><pre id="topics-list">Loading…</pre></section>
|
|
9
|
+
</OpsLayout>
|
|
10
|
+
<script>
|
|
11
|
+
const list = document.querySelector("#topics-list"); const load = () => fetch("/api/ops/topics").then((r) => r.json()).then((data) => { if (list) list.textContent = JSON.stringify(data.items, null, 2); }); load();
|
|
12
|
+
document.querySelector("#topic-form")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget as HTMLFormElement; const response = await fetch("/api/ops/topics", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(Object.fromEntries(new FormData(form))) }); const data = await response.json(); const result = document.querySelector("#topic-result"); if (result) result.textContent = response.ok ? `Created ${data.id}` : data.error; if (response.ok) load(); });
|
|
13
|
+
</script>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
import OpsLayout from "../../layouts/OpsLayout.astro";
|
|
3
|
+
import { siteUrl } from "../../lib/repository";
|
|
4
|
+
---
|
|
5
|
+
<OpsLayout title="WordPress migration" description="Private WordPress migration manager." canonical={siteUrl("/ops/wordpress")}>
|
|
6
|
+
<h1>WordPress migration</h1>
|
|
7
|
+
<p>Import posts, categories, tags, authors, featured media, and old URL redirects through the WordPress REST API. Preview first; new content is draft by default.</p>
|
|
8
|
+
<ol><li>Connect and detect capabilities</li><li>Choose content and mapping</li><li>Preview URL/SEO impact</li><li>Apply, validate, resume or rollback</li></ol>
|
|
9
|
+
<section aria-labelledby="migration-plan-title"><h2 id="migration-plan-title">Migration plan</h2><p>Confirm the source mapping before starting the import.</p><label>Post types <input id="wp-post-types" value="post" placeholder="post,page" /></label><label>Category → topic mapping JSON <textarea id="wp-category-map">{}</textarea></label><label><input id="wp-include-pages" type="checkbox" /> Include pages</label><button id="wp-plan" type="button">Build migration plan</button><pre id="wp-plan-result"></pre></section>
|
|
10
|
+
<form id="wp-migration"><label>WordPress site URL <input id="wp-source" type="url" name="sourceUrl" placeholder="https://example.com" required /></label><button id="wp-capabilities" type="button">Detect capabilities</button><pre id="wp-capability-result"></pre><label>Mode <select name="mode"><option value="preview">Preview / dry run</option><option value="apply">Apply import</option></select></label><label>Maximum posts <input type="number" name="maxPosts" min="1" max="10000" value="100" /></label><label>Media <select name="mediaMode"><option value="featured">Featured media referenced by posts</option><option value="all">All media library items</option></select></label><label>Incremental mode <select name="syncMode"><option value="all">Full source snapshot</option><option value="modified">Modified posts only</option><option value="new">New posts only</option></select></label><label>Conflict strategy <select name="conflictStrategy"><option value="manual">Manual review</option><option value="wordpress-wins">WordPress wins</option><option value="maggie-wins">Maggie wins</option></select></label><label><input type="checkbox" name="includeDrafts" /> Include drafts (requires WordPress API credentials)</label><label><input type="checkbox" name="preservePublished" /> Preserve WordPress published status</label><label><input type="checkbox" name="detectDeleted" /> Detect removed source posts and archive local copies</label><button type="submit">Start migration</button><p id="wp-result" aria-live="polite"></p></form>
|
|
11
|
+
<h2>Migration history</h2><div id="wp-history">Loading…</div>
|
|
12
|
+
</OpsLayout>
|
|
13
|
+
<script>
|
|
14
|
+
const history = document.querySelector("#wp-history"); const result = document.querySelector("#wp-result"); const capabilityResult = document.querySelector("#wp-capability-result");
|
|
15
|
+
document.querySelector("#wp-capabilities")?.addEventListener("click", async () => { const source = (document.querySelector("#wp-source") as HTMLInputElement)?.value; if (!source) return; const response = await fetch(`/api/ops/wordpress/capabilities?sourceUrl=${encodeURIComponent(source)}`); const data = await response.json(); if (capabilityResult) capabilityResult.textContent = JSON.stringify(data, null, 2); });
|
|
16
|
+
const load = () => fetch("/api/ops/wordpress/migrations").then((response) => response.json()).then((data: { items: Array<{ id: string; status: string; source_url: string; mode: string; counts_json: string; error?: string }> }) => { if (!history) return; history.replaceChildren(); if (!data.items.length) { history.textContent = "No migrations yet."; return; } data.items.forEach((item) => { const article = document.createElement("article"); const heading = document.createElement("strong"); heading.textContent = `${item.status} · ${item.source_url} · ${item.mode}`; const counts = document.createElement("pre"); counts.textContent = item.counts_json; article.append(heading, counts); if (item.error) { const error = document.createElement("p"); error.textContent = item.error; article.append(error); } if (item.status !== "running" && item.mode === "preview") { const button = document.createElement("button"); button.textContent = "Apply this preview"; button.addEventListener("click", async () => { const response = await fetch(`/api/ops/wordpress/migrations/${item.id}/resume`, { method: "POST" }); const body = await response.json(); if (result) result.textContent = response.ok ? `Applied ${body.id}` : body.error; load(); }); article.append(button); } history.append(article); }); });
|
|
17
|
+
document.querySelector<HTMLButtonElement>("#wp-plan")?.addEventListener("click", async () => { const source = (document.querySelector("#wp-source") as HTMLInputElement)?.value; const target = document.querySelector("#wp-plan-result"); if (!source) { if (target) target.textContent = "Enter a WordPress URL first."; return; } let categoryToTopic: unknown = {}; try { categoryToTopic = JSON.parse((document.querySelector("#wp-category-map") as HTMLTextAreaElement)?.value || "{}"); } catch { if (target) target.textContent = "Category mapping must be valid JSON."; return; } const postTypes = ((document.querySelector("#wp-post-types") as HTMLInputElement)?.value || "post").split(",").map((value) => value.trim()).filter(Boolean); const response = await fetch("/api/ops/wordpress/migration-plan", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sourceUrl: source, postTypes, mapping: { categoryToTopic }, resources: { pages: (document.querySelector("#wp-include-pages") as HTMLInputElement)?.checked } }) }); const data = await response.json(); if (target) target.textContent = JSON.stringify(data, null, 2); });
|
|
18
|
+
load();
|
|
19
|
+
document.querySelector("#wp-migration")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget as HTMLFormElement; const fields = Object.fromEntries(new FormData(form)) as Record<string, unknown>; fields.maxPosts = Number(fields.maxPosts); fields.includeDrafts = (form.elements.namedItem("includeDrafts") as HTMLInputElement).checked; fields.preservePublished = (form.elements.namedItem("preservePublished") as HTMLInputElement).checked; const response = await fetch("/api/ops/wordpress/migrations", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(fields) }); const data = await response.json(); if (result) result.textContent = response.ok ? `${data.mode} completed: ${JSON.stringify(data.counts)}` : data.error; load(); });
|
|
20
|
+
</script>
|
|
21
|
+
<script>
|
|
22
|
+
document.querySelector<HTMLFormElement>("#wp-migration")?.addEventListener("submit", () => {
|
|
23
|
+
const result = document.querySelector("#wp-plan-result")?.textContent || "";
|
|
24
|
+
try {
|
|
25
|
+
const plan = JSON.parse(result);
|
|
26
|
+
if (!plan.id) return;
|
|
27
|
+
for (const [name, value] of [["planId", plan.id], ["postTypesJson", JSON.stringify(plan.selections?.postTypes || ["post"])], ["mappingJson", JSON.stringify(plan.selections?.mapping || {})]] as Array<[string, string]>) {
|
|
28
|
+
let input = document.querySelector<HTMLInputElement>(`#wp-migration input[name="${name}"]`);
|
|
29
|
+
if (!input) { input = document.createElement("input"); input.type = "hidden"; input.name = name; input.id = `wp-${name}`; document.querySelector("#wp-migration")?.append(input); }
|
|
30
|
+
input.value = value;
|
|
31
|
+
}
|
|
32
|
+
} catch { /* migration can still run without a plan */ }
|
|
33
|
+
}, true);
|
|
34
|
+
</script>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import BaseLayout from "../../layouts/BaseLayout.astro";
|
|
3
|
+
import { siteUrl } from "../../lib/repository";
|
|
4
|
+
const token = Astro.params.token || "";
|
|
5
|
+
---
|
|
6
|
+
<BaseLayout title="Content review" description="Private content review" canonical={siteUrl(`/review/${token}`)} noindex>
|
|
7
|
+
<main id="review"><p>Loading review…</p></main>
|
|
8
|
+
</BaseLayout>
|
|
9
|
+
<script is:inline define:vars={{ token }}>
|
|
10
|
+
const root = document.querySelector("#review");
|
|
11
|
+
const render = (data) => { if (!root) return; root.replaceChildren(); const heading = document.createElement("h1"); heading.textContent = data.post.title; const meta = document.createElement("p"); meta.textContent = `Review status: ${data.status} · Expires: ${data.expiresAt}`; const article = document.createElement("article"); article.innerHTML = data.post.contentHtml || `<p>${data.post.excerpt}</p>`; const comment = document.createElement("textarea"); comment.placeholder = "Leave feedback"; const approve = document.createElement("button"); approve.textContent = "Approve"; const changes = document.createElement("button"); changes.textContent = "Request changes"; const submit = (decision) => fetch(`/api/approval/${token}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ decision, comment: comment.value }) }).then((response) => response.json()).then((result) => { meta.textContent = result.status ? `Review status: ${result.status}` : result.error; }); approve.addEventListener("click", () => submit("approved")); changes.addEventListener("click", () => submit("changes_requested")); root.append(heading, meta, article, comment, approve, changes); };
|
|
12
|
+
fetch(`/api/approval/${token}`).then((response) => response.json()).then(render).catch(() => { if (root) root.textContent = "This review link is invalid or expired."; });
|
|
13
|
+
</script>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { db } from "../lib/db";
|
|
3
|
+
|
|
4
|
+
export const GET: APIRoute = ({ site }) => { let aiPolicy: { allow?: boolean; userAgents?: string[] } = { allow: true }; try { const row = db().prepare("SELECT value_json FROM blog_settings WHERE key='aiCrawlerPolicy'").get() as { value_json: string } | undefined; if (row) aiPolicy = JSON.parse(row.value_json); } catch { /* demo mode or database unavailable */ } const aiRules = aiPolicy.allow === false ? (aiPolicy.userAgents || ["GPTBot", "Google-Extended", "ClaudeBot", "PerplexityBot"]).map((agent) => `User-agent: ${agent}\nDisallow: /`).join("\n") + "\n" : ""; const origin = new URL(String(site || import.meta.env.PUBLIC_SITE_URL || "http://localhost")).origin; return new Response(`${aiRules}User-agent: *\nAllow: /blog\nAllow: /topics\nDisallow: /ops\nDisallow: /api\nSitemap: ${origin}/sitemap.xml\n`, { headers: { "Content-Type": "text/plain; charset=utf-8" } }); };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import { allTopics, postsForTopic, publishedPosts } from "../lib/repository";
|
|
3
|
+
|
|
4
|
+
export const GET: APIRoute = ({ site }) => {
|
|
5
|
+
const origin = new URL(String(site || import.meta.env.PUBLIC_SITE_URL || "http://localhost")).origin;
|
|
6
|
+
const escapeXml = (value: string) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
7
|
+
const posts = publishedPosts();
|
|
8
|
+
const topics = allTopics().filter((topic) => topic.status === "published" && postsForTopic(topic.slug).length > 0);
|
|
9
|
+
const urls: Array<{ path: string; lastmod?: string }> = [{ path: "/" }, { path: "/about" }, ...(posts.length ? [{ path: "/blog" }] : []), ...(topics.length ? [{ path: "/topics" }] : []), ...posts.map((post) => ({ path: `/blog/${post.slug}`, lastmod: post.updatedAt || post.publishedAt })), ...topics.map((topic) => ({ path: `/topics/${topic.slug}` }))];
|
|
10
|
+
const body = urls.map(({ path, lastmod }) => `<url><loc>${escapeXml(origin + path)}</loc>${lastmod ? `<lastmod>${escapeXml(lastmod)}</lastmod>` : ""}</url>`).join("");
|
|
11
|
+
return new Response(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${body}</urlset>`, { headers: { "Content-Type": "application/xml; charset=utf-8" } });
|
|
12
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
---
|
|
2
|
+
import BaseLayout from "../../../../layouts/BaseLayout.astro";
|
|
3
|
+
import { postsForTopic, siteUrl, topicBySlug } from "../../../../lib/repository";
|
|
4
|
+
const topic = topicBySlug(Astro.params.slug || "");
|
|
5
|
+
if (!topic) { Astro.response.status = 404; return Astro.rewrite("/404"); }
|
|
6
|
+
const pageSize = 12; const pageNumber = Math.max(1, Number(Astro.params.page || 1)); const allPosts = postsForTopic(topic.slug); const pagePosts = allPosts.slice((pageNumber - 1) * pageSize, pageNumber * pageSize); const hasNext = pageNumber * pageSize < allPosts.length;
|
|
7
|
+
---
|
|
8
|
+
<BaseLayout title={`${topic.title} · Page ${pageNumber}`} description={topic.description} canonical={siteUrl(`/topics/${topic.slug}/page/${pageNumber}`)}>
|
|
9
|
+
<h1>{topic.title} · Page {pageNumber}</h1><p>{topic.description}</p><div class="post-grid">{pagePosts.map((post) => <article><h2><a href={`/blog/${post.slug}`}>{post.title}</a></h2><p>{post.excerpt}</p></article>)}</div><nav aria-label="Topic pagination">{pageNumber > 1 && <a href={pageNumber === 2 ? `/topics/${topic.slug}` : `/topics/${topic.slug}/page/${pageNumber - 1}`}>Previous</a>}{hasNext && <a href={`/topics/${topic.slug}/page/${pageNumber + 1}`}>Next</a>}</nav>
|
|
10
|
+
</BaseLayout>
|