Skip to content

Commit d653e42

Browse files
thunpisitclaude
andauthored
feat(v1.7b): static pages + navigation manager + legal seeder (closes #32) (#39)
Second half of v1.7. With v1.7a (media folders + reusable blocks + cookie consent + legal templates) merged earlier, this completes the v1.7 milestone. Pages ----- - ContentProvider: getPage / getPageBySlug / listPages / createPage / updatePage / deletePage. Soft scheduled-publishing semantics identical to articles (status='published' AND publishedAt <= now). - Public: (www)/[locale]/[...slug] catch-all. SvelteKit prefers the more-specific /[locale]/blog and /[locale]/blog/[slug] routes so the catch-all only triggers for static pages. Three soft templates (default, landing, legal) swap the public wrapper. - Reuses v1.6 <Seo> + v1.7a expandBlocks, so canonical + hreflang + Open Graph + Article-style JSON-LD all just work, and {{block:key}} shortcodes expand inside pages too. - CMS: /cms/pages (list), /cms/pages/new, /cms/pages/[id] (edit). Shared PageForm.svelte modeled on ArticleForm. - Sitemap: per-locale sitemap now lists pages in addition to articles, with hreflang siblings only for locales with real content. Navigation manager ------------------ - ContentProvider: listMenus / getMenuByKey / createMenu / deleteMenu / createNavigationItem / updateNavigationItem / deleteNavigationItem / reorderNavigationItems. - /cms/navigation auto-bootstraps two stock menus (primary, footer) on first load. Per-locale labels stored as JSON. Items target one of article / category / tag / page / custom URL. Position-based ordering with up/down buttons (true drag-drop trees deferred). - $lib/server/content/navigation.ts: loadNavigation() pre-fetches the menus + the slug lookup tables in one pass; navItemHref() resolves each item to a final URL with O(1) lookups. - (www) layout server load consumes loadNavigation() and surfaces data.nav.{primary,footer} as render-ready arrays. Header iterates primary; footer iterates footer. Legal seeder ------------ - $lib/server/content/legal-seed.ts: seedLegalPages(content, authorId) embeds the privacy + cookie policy templates, prefills [Site Name] from site_settings, and creates draft Pages with the 'legal' template. Idempotent: skips slugs that already exist. - /cms/pages empty state shows a "Seed legal templates" button that invokes the seeder. New pages start as drafts (never auto-publish) with explicit "review before publishing" copy on the success banner. Sidebar ------- - Two new entries: Pages and Navigation, both gated to editor+. i18n: 23 new keys (EN + TH). cms_pages_*, cms_navigation_*, cms_pages_seed_legal, cms_pages_seeded. Closes #32 (full v1.7 — both halves merged). Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a85711a commit d653e42

23 files changed

Lines changed: 2297 additions & 25 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ Khao Pad started as a CMS. Through v1.5 it became a complete content layer (writ
281281
| **v1.4** | Full-text search | ✅ Shipped | SQLite FTS5 over per-locale localizations, public `/blog?q=`, CMS list filter |
282282
| **v1.5** | Content versioning | ✅ Shipped | Per-article revision history, line diff, one-click restore, attribution |
283283
| **v1.6** | SEO foundations | ✅ Shipped | Per-page meta, sitemap, robots, JSON-LD, RSS/Atom, slug redirects, SEO scoring hint |
284-
| **v1.7** | Pages, navigation, IA | 🚧 In progress | v1.7a shipped: media folders, reusable blocks, cookie consent, legal templates. v1.7b pending: pages + navigation |
284+
| **v1.7** | Pages, navigation, IA | ✅ Shipped | Media folders, reusable blocks, cookie consent, static pages, navigation manager, seed:legal |
285285
| **v1.8** | Analytics & insight | 🚧 Pending | Privacy-friendly D1 page-views, top articles, search-term insights, per-article sparkline |
286286
| **v1.9** | Performance & trust | 🚧 Pending | Cloudflare Images responsive `srcset`, cache headers, custom 404/500, cookie consent, health check |
287287
| **v2.0** | Engagement & growth | 🚧 Pending | Forms, newsletter, comments, webhooks, public read-only API |

docs/MILESTONES.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,11 +200,13 @@ A blog isn't a website. A website needs static pages (About, Contact, Privacy),
200200

201201
**Legal templates** (v1.7a) — `static/legal-templates/{privacy-policy,cookie-policy}.md` plus a README explaining why we ship templates and not auto-generated text. They cover what a stock Khao Pad install actually does (Cloudflare hosting, Better Auth sessions, the `khaopad_consent` cookie) and have explicit `[Operator Legal Entity]` / `[Contact Email]` / `[Date]` placeholders for the operator to fill in. Once Pages land in v1.7b, a `pnpm seed:legal` script will turn each template into a Page row with the obvious placeholders pre-filled from `site_settings`. Until then they're documentation.
202202

203-
**Pages** (v1.7b — pending) — A new `pages` table separate from `articles`: id, slug, `parentId` for nesting, `template` (default / landing / legal), `publishedAt`, plus `page_localizations` (title, body, seoTitle, seoDescription). Routed at `(www)/[locale]/[...slug]` with a catch-all so nested slugs like `/about/team` work. Reuses the markdown editor + v1.6 `<Seo>` machinery + v1.7a `expandBlocks`.
203+
**Pages** (v1.7b) — New `pages` + `page_localizations` tables, routed at `(www)/[locale]/[...slug]` (catch-all so nested slugs like `/about/team` work). Per-locale title/body/seoTitle/seoDescription, three soft templates (`default` / `landing` / `legal`) that swap the public wrapper width and typography. Status (draft / published) and `publishedAt` honor the same scheduled-publishing semantics articles use. Reuses the markdown editor, the v1.6 `<Seo>` machinery, and v1.7a `expandBlocks` so `{{block:cta-newsletter}}` works in pages too. `/cms/pages` lists everything; `/cms/pages/new` and `/cms/pages/[id]` use a shared `PageForm.svelte` modeled on `ArticleForm`. The sitemap now lists every published page in each locale's per-locale sitemap, with hreflang siblings limited to locales that actually have content.
204204

205-
**Navigation manager** (v1.7b — pending) — `/cms/navigation` admin page. Two stock menus (`primary`, `footer`); each menu is an ordered tree of items pointing at internal entities (article / category / tag / page) or custom URLs. Per-locale labels stored as JSON. Public `<SiteHeader>` and `<SiteFooter>` components read the menu from `locals.navigation`.
205+
**Navigation manager** (v1.7b) — `/cms/navigation` admin (editor+). Two stock menus auto-bootstrapped on first load (`primary`, `footer`); admins can create more. Each item is one of `article` / `category` / `tag` / `page` / `custom`; the form swaps a target picker for a custom-URL field based on `kind`. Per-locale labels stored as JSON. Position is integer-based with up/down reorder buttons (true drag-drop trees deferred — v2.x). Public layout server load uses `loadNavigation()` to pre-fetch `primary` + `footer` and resolve every item to a render-ready `{ id, href, label }` for the active locale; the layout iterates that list with no DB work of its own. `navItemHref()` uses lookup maps so the resolution is O(1) per item.
206206

207-
**i18n** — 18 new keys in v1.7a (EN + TH): cms_blocks_*, cms_media_folder_*, cookie_*.
207+
**Legal page seeder** (v1.7b) — `seedLegalPages(content, authorId)` in `$lib/server/content/legal-seed.ts` reads embedded copies of the privacy and cookie policy markdown templates, prefills `[Site Name]` from `site_settings`, and creates draft Pages with the `legal` template. Idempotent: skips slugs that already exist. Wired into `/cms/pages` as a "Seed legal templates" button on the empty state — one click and you have draft Privacy + Cookie pages ready for the operator to fill in their entity, contact email, retention periods, and processors. Pages start as drafts; never auto-publish.
208+
209+
**i18n** — 18 keys in v1.7a + 23 keys in v1.7b (EN + TH).
208210

209211
### v1.8 — Analytics and insight (pending)
210212

messages/en.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,32 @@
6060
"cookie_cat_analytics_help": "Privacy-friendly page-view counts that help us understand which content is read.",
6161
"cookie_cat_marketing": "Marketing",
6262
"cookie_cat_marketing_help": "Third-party tracking and embedded media. Off by default.",
63+
"cms_pages": "Pages",
64+
"cms_pages_help": "Static pages: About, Contact, Privacy, etc. Reuses the markdown editor and SEO surface.",
65+
"cms_pages_new": "New page",
66+
"cms_pages_edit": "Edit page",
67+
"cms_pages_empty": "No pages yet. Pages are good for permanent content like About, Contact, and Privacy.",
68+
"cms_pages_col_title": "Title",
69+
"cms_pages_col_slug": "Slug",
70+
"cms_pages_col_template": "Template",
71+
"cms_pages_slug_help": "Lowercase ASCII letters, digits, hyphens. Use slashes for nesting (about/team).",
72+
"cms_pages_template": "Template",
73+
"cms_navigation": "Navigation",
74+
"cms_navigation_help": "Build the site's primary header and footer menus. Items can target pages, articles, categories, tags, or custom URLs.",
75+
"cms_navigation_new_menu": "New menu",
76+
"cms_navigation_create_menu": "Create menu",
77+
"cms_navigation_delete_menu": "Delete this menu",
78+
"cms_navigation_menu_key": "Key",
79+
"cms_navigation_menu_label": "Label",
80+
"cms_navigation_items_empty": "No items in this menu yet. Add one below.",
81+
"cms_navigation_add_item": "Add item",
82+
"cms_navigation_label": "Label",
83+
"cms_navigation_kind": "Kind",
84+
"cms_navigation_target": "Target",
85+
"cms_navigation_pick": "pick one",
86+
"cms_navigation_custom_url": "Custom URL",
87+
"cms_pages_seed_legal": "Seed legal templates (Privacy + Cookie policy)",
88+
"cms_pages_seeded": "Created {n} pages from templates. They start as drafts — review before publishing.",
6389
"cms_cover_media": "Cover image",
6490
"cms_cover_media_help": "Paste a media ID from the Media library, or leave blank.",
6591
"cms_cover_media_none": "No cover",

messages/th.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,32 @@
6060
"cookie_cat_analytics_help": "การนับยอดเข้าชมแบบเคารพความเป็นส่วนตัว ช่วยให้เราทราบว่าเนื้อหาใดมีคนอ่าน",
6161
"cookie_cat_marketing": "การตลาด",
6262
"cookie_cat_marketing_help": "การติดตามจากบุคคลที่สามและสื่อฝังตัว ปิดเป็นค่าเริ่มต้น",
63+
"cms_pages": "หน้า",
64+
"cms_pages_help": "หน้าคงที่ เช่น เกี่ยวกับ ติดต่อ นโยบายความเป็นส่วนตัว ใช้เอดิเตอร์ markdown และพื้น SEO เดียวกับบทความ",
65+
"cms_pages_new": "หน้าใหม่",
66+
"cms_pages_edit": "แก้ไขหน้า",
67+
"cms_pages_empty": "ยังไม่มีหน้า เหมาะกับเนื้อหาถาวร เช่น เกี่ยวกับ ติดต่อ และนโยบายความเป็นส่วนตัว",
68+
"cms_pages_col_title": "หัวข้อ",
69+
"cms_pages_col_slug": "สลัก",
70+
"cms_pages_col_template": "เทมเพลต",
71+
"cms_pages_slug_help": "อักษรอังกฤษพิมพ์เล็ก ตัวเลข และขีดกลาง ใช้ / เพื่อทำหน้าซ้อน (about/team)",
72+
"cms_pages_template": "เทมเพลต",
73+
"cms_navigation": "เนวิเกชัน",
74+
"cms_navigation_help": "จัดการเมนูส่วนหัวและส่วนท้ายของเว็บ รายการสามารถลิงก์ไปหน้า บทความ หมวดหมู่ แท็ก หรือ URL แบบกำหนดเอง",
75+
"cms_navigation_new_menu": "เมนูใหม่",
76+
"cms_navigation_create_menu": "สร้างเมนู",
77+
"cms_navigation_delete_menu": "ลบเมนูนี้",
78+
"cms_navigation_menu_key": "คีย์",
79+
"cms_navigation_menu_label": "ป้ายกำกับ",
80+
"cms_navigation_items_empty": "ยังไม่มีรายการในเมนูนี้ เพิ่มได้ด้านล่าง",
81+
"cms_navigation_add_item": "เพิ่มรายการ",
82+
"cms_navigation_label": "ป้ายกำกับ",
83+
"cms_navigation_kind": "ประเภท",
84+
"cms_navigation_target": "เป้าหมาย",
85+
"cms_navigation_pick": "เลือก",
86+
"cms_navigation_custom_url": "URL กำหนดเอง",
87+
"cms_pages_seed_legal": "สร้างจากเทมเพลตกฎหมาย (ความเป็นส่วนตัว + คุกกี้)",
88+
"cms_pages_seeded": "สร้าง {n} หน้าจากเทมเพลต อยู่ในสถานะร่าง โปรดทบทวนก่อนเผยแพร่",
6389
"cms_cover_media": "รูปปก",
6490
"cms_cover_media_help": "วาง ID สื่อจากคลังสื่อ หรือเว้นว่าง",
6591
"cms_cover_media_none": "ไม่มีรูปปก",

src/lib/components/cms/sidebar-nav.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import type { ComponentType } from "svelte";
22
import {
33
LayoutDashboard,
44
FileText,
5+
FilePlus,
56
Image as ImageIcon,
67
Folder,
8+
Menu as MenuIcon,
79
Tag,
810
Users,
911
Settings,
@@ -36,7 +38,19 @@ export const navGroups: ReadonlyArray<NavGroup> = [
3638
items: [
3739
{ href: "/cms/dashboard", label: m.cms_dashboard, icon: LayoutDashboard },
3840
{ href: "/cms/articles", label: m.cms_articles, icon: FileText },
41+
{
42+
href: "/cms/pages",
43+
label: m.cms_pages,
44+
icon: FilePlus,
45+
roles: ["super_admin", "admin", "editor"],
46+
},
3947
{ href: "/cms/media", label: m.cms_media, icon: ImageIcon },
48+
{
49+
href: "/cms/navigation",
50+
label: m.cms_navigation,
51+
icon: MenuIcon,
52+
roles: ["super_admin", "admin", "editor"],
53+
},
4054
],
4155
},
4256
{
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import type { ContentProvider, PageRecord } from "./types";
2+
3+
/**
4+
* Markdown templates for the privacy and cookie policies, embedded
5+
* in source so the CMS can seed them with no filesystem access at
6+
* Worker runtime (R2 / KV would be overkill for a few KB).
7+
*
8+
* The on-disk versions in `static/legal-templates/*.md` are the
9+
* canonical reference; this module is a copy that's served from
10+
* runtime memory. Keep them in sync when editing.
11+
*/
12+
13+
const PRIVACY_POLICY_TEMPLATE_EN = `# Privacy Policy
14+
15+
> **⚠️ This is a template seeded by Khao Pad.** Replace every \`[bracketed placeholder]\` with your actual practices, then have a lawyer review before publishing.
16+
17+
_Last updated: [Date]_
18+
19+
## Who we are
20+
21+
[Site Name] is operated by [Operator Legal Entity], registered at [Operator Address], reachable at [Contact Email].
22+
23+
For questions about your personal data, contact [DPO or Privacy Contact Email].
24+
25+
## What data we collect
26+
27+
- **Account data** — when you create an account: email, name, hashed password.
28+
- **Session data** — a session cookie set by Better Auth so you stay signed in.
29+
- **Cookie consent** — your choices in the cookie banner are stored in a first-party cookie called \`khaopad_consent\`.
30+
- **Analytics data** — _only if you opt in_: counts of which pages are visited, by day. We do not store IP addresses or user agents.
31+
32+
## Why we collect it
33+
34+
- **Account / Session** — to authenticate you and keep you signed in. Legal basis: contract performance.
35+
- **Analytics** — to understand which content is read. Legal basis: consent.
36+
37+
## Who we share it with
38+
39+
We use the following processors:
40+
41+
- **Cloudflare** (Workers, D1, R2, KV) — hosting and storage.
42+
- **Better Auth** — authentication library running inside our worker. No external service.
43+
44+
We do **not** sell personal data.
45+
46+
## Your rights
47+
48+
Depending on your jurisdiction (GDPR, PDPA, CCPA, etc.) you have rights to:
49+
50+
- access the personal data we hold about you,
51+
- ask us to correct it,
52+
- ask us to delete it,
53+
- object to specific processing,
54+
- withdraw consent for analytics at any time via the cookie banner.
55+
56+
To exercise these rights email [DPO or Privacy Contact Email].
57+
58+
## Cookies
59+
60+
See the separate [Cookie Policy](/[locale]/cookie-policy) for the full list of cookies.
61+
62+
## Contact
63+
64+
[Operator Legal Entity]
65+
[Operator Address]
66+
[Contact Email]
67+
`;
68+
69+
const COOKIE_POLICY_TEMPLATE_EN = `# Cookie Policy
70+
71+
> **⚠️ This is a template seeded by Khao Pad.** Edit the cookie list to match what your specific deployment actually sets. The defaults reflect a stock Khao Pad install.
72+
73+
_Last updated: [Date]_
74+
75+
## What are cookies?
76+
77+
A cookie is a small text file stored on your device by your browser. We use them to keep you signed in, remember your language preference, and (if you opt in) count page views.
78+
79+
## Functional cookies (always on)
80+
81+
| Cookie name | Purpose | Lifetime |
82+
| --------------------- | ------------------------------------------------------------- | -------- |
83+
| \`better-auth.session\` | Authentication session — keeps you signed in after login | 30 days |
84+
| \`PARAGLIDE_LOCALE\` | Remembers your language choice | 1 year |
85+
| \`khaopad_consent\` | Stores your decision in the cookie banner | 1 year |
86+
87+
## Analytics cookies (off until you accept)
88+
89+
Set only if you check **Analytics** in the banner.
90+
91+
We do **not** use Google Analytics, Facebook Pixel, or any other third-party tracker by default.
92+
93+
## Marketing cookies (off until you accept)
94+
95+
Set only if you check **Marketing** in the banner. (None by default.)
96+
97+
## Changing your choices
98+
99+
Use the cookie banner that appears the first time you visit, or clear the \`khaopad_consent\` cookie to see the banner again.
100+
101+
## Questions
102+
103+
[Contact Email]
104+
`;
105+
106+
/**
107+
* Seed legal pages (privacy-policy + cookie-policy) for a fresh
108+
* install. Idempotent: skips templates whose slug already exists.
109+
*
110+
* Pre-fills `[Site Name]` and `[Contact Email]` from `site_settings`
111+
* when those values are present; everything else stays bracketed for
112+
* the operator to edit. The new pages start in draft status — they
113+
* never auto-publish, even if the seed runs.
114+
*/
115+
export async function seedLegalPages(
116+
content: ContentProvider,
117+
authorId: string,
118+
): Promise<{
119+
created: PageRecord[];
120+
skipped: Array<{ slug: string; reason: string }>;
121+
}> {
122+
const settings = await content.getSettings().catch(() => null);
123+
const siteName = settings?.siteName ?? "[Site Name]";
124+
125+
const templates = [
126+
{
127+
slug: "privacy-policy",
128+
title: "Privacy Policy",
129+
template: "legal" as const,
130+
body: PRIVACY_POLICY_TEMPLATE_EN.replace(/\[Site Name\]/g, siteName),
131+
},
132+
{
133+
slug: "cookie-policy",
134+
title: "Cookie Policy",
135+
template: "legal" as const,
136+
body: COOKIE_POLICY_TEMPLATE_EN,
137+
},
138+
];
139+
140+
const created: PageRecord[] = [];
141+
const skipped: Array<{ slug: string; reason: string }> = [];
142+
for (const t of templates) {
143+
const existing = await content.getPageBySlug(t.slug);
144+
if (existing) {
145+
skipped.push({ slug: t.slug, reason: "already exists" });
146+
continue;
147+
}
148+
const page = await content.createPage({
149+
slug: t.slug,
150+
template: t.template,
151+
status: "draft",
152+
authorId,
153+
localizations: {
154+
en: {
155+
title: t.title,
156+
body: t.body,
157+
seoTitle: t.title,
158+
seoDescription: `${t.title} for ${siteName}.`,
159+
},
160+
},
161+
});
162+
created.push(page);
163+
}
164+
return { created, skipped };
165+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import type {
2+
ContentProvider,
3+
Locale,
4+
NavigationItemRecord,
5+
NavigationMenuRecord,
6+
} from "./types";
7+
8+
/**
9+
* Resolve a `NavigationItemRecord`'s target into a public URL for
10+
* the given locale. Pure function — does no I/O. Caller must already
11+
* have the article/page/category/tag slugs handy if it wants nice
12+
* URLs; we accept lookup maps so the caller does it once per request.
13+
*/
14+
export function navItemHref(
15+
item: NavigationItemRecord,
16+
locale: Locale,
17+
lookup: {
18+
pageSlugById: Map<string, string>;
19+
articleSlugById: Map<string, string>;
20+
categorySlugById: Map<string, string>;
21+
tagSlugById: Map<string, string>;
22+
},
23+
): string {
24+
switch (item.kind) {
25+
case "custom":
26+
return item.customUrl ?? "#";
27+
case "page": {
28+
const slug = item.targetId
29+
? lookup.pageSlugById.get(item.targetId)
30+
: undefined;
31+
return slug ? `/${locale}/${slug}` : "#";
32+
}
33+
case "article": {
34+
const slug = item.targetId
35+
? lookup.articleSlugById.get(item.targetId)
36+
: undefined;
37+
return slug ? `/${locale}/blog/${slug}` : "#";
38+
}
39+
case "category": {
40+
const slug = item.targetId
41+
? lookup.categorySlugById.get(item.targetId)
42+
: undefined;
43+
return slug ? `/${locale}/blog?category=${slug}` : "#";
44+
}
45+
case "tag": {
46+
const slug = item.targetId
47+
? lookup.tagSlugById.get(item.targetId)
48+
: undefined;
49+
return slug ? `/${locale}/blog?tag=${slug}` : "#";
50+
}
51+
}
52+
}
53+
54+
/**
55+
* Pre-fetch the menus needed by the public layout, plus the slug
56+
* lookup tables a renderer needs to turn `NavigationItemRecord` →
57+
* URL. Single helper so loaders don't repeat themselves. Best-effort
58+
* (errors return empty data so a misconfigured site still serves
59+
* pages).
60+
*/
61+
export async function loadNavigation(
62+
content: ContentProvider,
63+
keys: string[],
64+
): Promise<{
65+
menus: Record<string, NavigationMenuRecord | null>;
66+
pageSlugById: Map<string, string>;
67+
articleSlugById: Map<string, string>;
68+
categorySlugById: Map<string, string>;
69+
tagSlugById: Map<string, string>;
70+
}> {
71+
try {
72+
const allMenus = await content.listMenus();
73+
const menus: Record<string, NavigationMenuRecord | null> = {};
74+
for (const k of keys) {
75+
menus[k] = allMenus.find((m) => m.key === k) ?? null;
76+
}
77+
78+
// Build slug lookups from the published surface only.
79+
const [pages, articleResult, categories, tags] = await Promise.all([
80+
content.listPages({ status: "published", onlyPublished: true }),
81+
content.listArticles({
82+
status: "published",
83+
onlyPublished: true,
84+
limit: 200,
85+
}),
86+
content.listCategories(),
87+
content.listTags(),
88+
]);
89+
90+
return {
91+
menus,
92+
pageSlugById: new Map(pages.map((p) => [p.id, p.slug])),
93+
articleSlugById: new Map(
94+
articleResult.items.map((a) => [a.id, a.slug]),
95+
),
96+
categorySlugById: new Map(categories.map((c) => [c.id, c.slug])),
97+
tagSlugById: new Map(tags.map((t) => [t.id, t.slug])),
98+
};
99+
} catch {
100+
return {
101+
menus: {},
102+
pageSlugById: new Map(),
103+
articleSlugById: new Map(),
104+
categorySlugById: new Map(),
105+
tagSlugById: new Map(),
106+
};
107+
}
108+
}

0 commit comments

Comments
 (0)