@acms-subir/core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/cli.mjs +75 -0
  2. package/integration.mjs +286 -0
  3. package/package.json +89 -0
  4. package/src/actions/index.ts +502 -0
  5. package/src/components/DesignManager.tsx +542 -0
  6. package/src/components/LinksManager.tsx +694 -0
  7. package/src/components/Modal.astro +65 -0
  8. package/src/components/NavManager.tsx +168 -0
  9. package/src/components/WidgetRenderer.astro +84 -0
  10. package/src/components/builder/PageBuilder.tsx +575 -0
  11. package/src/components/editor/ImagePicker.tsx +215 -0
  12. package/src/components/editor/PostEditor.tsx +1011 -0
  13. package/src/components/editor/PreviewOverlay.tsx +77 -0
  14. package/src/components/editor/extensions/calendar.tsx +230 -0
  15. package/src/components/editor/extensions/cards.tsx +488 -0
  16. package/src/components/editor/extensions/codeBlock.tsx +70 -0
  17. package/src/components/editor/extensions/infoCards.tsx +270 -0
  18. package/src/components/editor/extensions/mediaCards.tsx +376 -0
  19. package/src/components/editor/extensions/postList.tsx +130 -0
  20. package/src/components/editor/extensions/slash.tsx +250 -0
  21. package/src/components/editor/extensions/tableTyping.ts +63 -0
  22. package/src/components/editor/imaging.ts +146 -0
  23. package/src/components/media/CropModal.tsx +229 -0
  24. package/src/components/media/MediaLibrary.tsx +295 -0
  25. package/src/layouts/Admin.astro +119 -0
  26. package/src/layouts/Site.astro +218 -0
  27. package/src/lib/auth.ts +94 -0
  28. package/src/lib/backup/unzip.ts +38 -0
  29. package/src/lib/backup/zip.ts +105 -0
  30. package/src/lib/blocksToHtml.ts +68 -0
  31. package/src/lib/brand.ts +26 -0
  32. package/src/lib/calendar/ics.ts +402 -0
  33. package/src/lib/calendar/render.ts +275 -0
  34. package/src/lib/calendar/src.ts +68 -0
  35. package/src/lib/content.ts +53 -0
  36. package/src/lib/dates.ts +34 -0
  37. package/src/lib/dynamicBlocks.ts +115 -0
  38. package/src/lib/env.ts +6 -0
  39. package/src/lib/highlightCode.ts +44 -0
  40. package/src/lib/i18n.ts +40 -0
  41. package/src/lib/markdown.ts +7 -0
  42. package/src/lib/media/store.ts +95 -0
  43. package/src/lib/net.ts +51 -0
  44. package/src/lib/preview.ts +28 -0
  45. package/src/lib/repo/d1.ts +44 -0
  46. package/src/lib/repo/index.ts +23 -0
  47. package/src/lib/repo/migrations.ts +231 -0
  48. package/src/lib/repo/search.ts +14 -0
  49. package/src/lib/repo/sql.ts +721 -0
  50. package/src/lib/repo/sqlite.ts +43 -0
  51. package/src/lib/repo/types.ts +132 -0
  52. package/src/lib/schema.ts +286 -0
  53. package/src/lib/seo.ts +37 -0
  54. package/src/lib/theme.ts +206 -0
  55. package/src/lib/widgets.ts +108 -0
  56. package/src/middleware.ts +109 -0
  57. package/src/pages/[...slug].astro +43 -0
  58. package/src/pages/admin/c/[collection]/[id].astro +56 -0
  59. package/src/pages/admin/c/[collection]/index.astro +163 -0
  60. package/src/pages/admin/design.astro +42 -0
  61. package/src/pages/admin/index.astro +145 -0
  62. package/src/pages/admin/links.astro +21 -0
  63. package/src/pages/admin/login.astro +83 -0
  64. package/src/pages/admin/logout.ts +7 -0
  65. package/src/pages/admin/media.astro +13 -0
  66. package/src/pages/admin/navigation.astro +17 -0
  67. package/src/pages/admin/pages/[id].astro +49 -0
  68. package/src/pages/admin/pages/index.astro +100 -0
  69. package/src/pages/admin/posts/[id].astro +6 -0
  70. package/src/pages/admin/posts/index.astro +7 -0
  71. package/src/pages/admin/settings.astro +512 -0
  72. package/src/pages/admin/tags.astro +101 -0
  73. package/src/pages/admin/users.astro +149 -0
  74. package/src/pages/api/backup.ts +63 -0
  75. package/src/pages/api/drafts.ts +106 -0
  76. package/src/pages/api/media/upload.ts +89 -0
  77. package/src/pages/api/restore.ts +102 -0
  78. package/src/pages/author/[slug].astro +66 -0
  79. package/src/pages/blog/[slug].astro +240 -0
  80. package/src/pages/blog/index.astro +51 -0
  81. package/src/pages/blog/tag/[slug].astro +83 -0
  82. package/src/pages/embed/[pageId]/[blockId].ts +45 -0
  83. package/src/pages/go/[id].ts +10 -0
  84. package/src/pages/index.astro +52 -0
  85. package/src/pages/links.astro +110 -0
  86. package/src/pages/llms.txt.ts +47 -0
  87. package/src/pages/media/[...key].ts +18 -0
  88. package/src/pages/preview/[type]/[id].astro +52 -0
  89. package/src/pages/robots.txt.ts +28 -0
  90. package/src/pages/rss.xml.ts +58 -0
  91. package/src/pages/search.astro +83 -0
  92. package/src/pages/sitemap.xml.ts +55 -0
  93. package/src/styles/admin.css +652 -0
  94. package/src/styles/builder.css +121 -0
  95. package/src/styles/cards.css +176 -0
  96. package/src/styles/editor.css +596 -0
  97. package/src/styles/site.css +427 -0
  98. package/src/themes/editorial/templates/home.astro +127 -0
  99. package/src/themes/editorial/templates/post-minimal.astro +50 -0
  100. package/src/themes/editorial/theme.css +108 -0
  101. package/src/themes/editorial/theme.json +20 -0
  102. package/src/themes/field/theme.json +17 -0
  103. package/src/themes/ink/theme.json +17 -0
  104. package/src/themes/night/theme.css +7 -0
  105. package/src/themes/night/theme.json +17 -0
  106. package/src/virtual.d.ts +25 -0
  107. package/src/worker.ts +28 -0
@@ -0,0 +1,43 @@
1
+ import type { Client, InArgs, InStatement } from '@libsql/client';
2
+ import { SqlRepository, type SqlDriver, type SqlStatement } from './sql';
3
+ import { env } from '../env';
4
+
5
+ // libSQL file adapter (plain Node self-hosting). All queries and row mapping
6
+ // live in the shared SqlRepository — this file only adapts the libSQL
7
+ // execute/batch API and handles client creation.
8
+
9
+ class LibsqlDriver implements SqlDriver {
10
+ constructor(private db: Client) {}
11
+
12
+ private stmt({ sql, args }: SqlStatement): InStatement {
13
+ return { sql, args: (args ?? []) as InArgs };
14
+ }
15
+ async all(sql: string, args?: unknown[]) {
16
+ const r = await this.db.execute(this.stmt({ sql, args }));
17
+ return r.rows as unknown as Record<string, unknown>[];
18
+ }
19
+ async run(sql: string, args?: unknown[]) {
20
+ await this.db.execute(this.stmt({ sql, args }));
21
+ }
22
+ async batch(stmts: SqlStatement[]) {
23
+ if (!stmts.length) return;
24
+ await this.db.batch(stmts.map((s) => this.stmt(s)), 'write');
25
+ }
26
+ }
27
+
28
+ export class SqliteRepository extends SqlRepository {
29
+ constructor() {
30
+ super(async () => {
31
+ // Dynamic (bundler-ignored) imports keep the native libSQL client and
32
+ // node:fs out of the Cloudflare Workers bundle — this adapter only ever
33
+ // runs on plain Node (self-hosted / astro dev without wrangler).
34
+ const { createClient } = await import(/* @vite-ignore */ '@libsql/client');
35
+ const url = env('SQLITE_URL', 'file:./data/acms.db');
36
+ if (url.startsWith('file:')) {
37
+ const { mkdirSync } = await import(/* @vite-ignore */ 'node:fs');
38
+ mkdirSync('./data', { recursive: true });
39
+ }
40
+ return new LibsqlDriver(createClient({ url }));
41
+ });
42
+ }
43
+ }
@@ -0,0 +1,132 @@
1
+ import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Tag, User, Role, WidgetInstance } from '../schema';
2
+
3
+ export interface Stats {
4
+ publishedPages: number;
5
+ posts: number;
6
+ drafts: number;
7
+ linkClicks: number;
8
+ }
9
+
10
+ // The single contract both backends implement. The app only ever imports
11
+ // this — never a concrete adapter.
12
+ export interface ContentRepository {
13
+ // pages
14
+ listPages(): Promise<Page[]>;
15
+ getPage(id: string): Promise<Page | null>;
16
+ getPageBySlug(slug: string): Promise<Page | null>;
17
+ // omitted/'' = the default locale's home; non-default locales pass their code
18
+ getHomePage(locale?: string): Promise<Page | null>;
19
+ createPage(data: { title: string; slug: string }): Promise<Page>;
20
+ updatePage(
21
+ id: string,
22
+ data: Partial<Pick<Page, 'title' | 'slug' | 'status' | 'isHome' | 'locale' | 'seoTitle' | 'seoDesc' | 'bodyJson' | 'bodyHtml' | 'template'>> & {
23
+ blocks?: WidgetInstance[];
24
+ }
25
+ ): Promise<void>;
26
+ deletePage(id: string): Promise<void>;
27
+
28
+ // posts
29
+ // collection filters take the acms({ content.collections }) key; omitted =
30
+ // all collections (feeds, search). Slugs are unique per collection, so
31
+ // getPostBySlug needs the collection to be unambiguous — omitted matches any
32
+ // (single-collection sites, legacy callers).
33
+ listPosts(collection?: string): Promise<Post[]>;
34
+ listPublishedPosts(collection?: string): Promise<Post[]>;
35
+ getPost(id: string): Promise<Post | null>;
36
+ getPostBySlug(slug: string, collection?: string): Promise<Post | null>;
37
+ createPost(data: { title: string; slug: string; collection?: string }): Promise<Post>;
38
+ updatePost(
39
+ id: string,
40
+ data: Partial<
41
+ Pick<
42
+ Post,
43
+ | 'title' | 'slug' | 'collection' | 'body' | 'bodyJson' | 'bodyHtml' | 'excerpt'
44
+ | 'tags' | 'cover' | 'coverAlt' | 'seoTitle' | 'seoDesc'
45
+ | 'status' | 'featured' | 'authorId' | 'authorIds' | 'template'
46
+ | 'publishedAt' | 'scheduledAt'
47
+ >
48
+ >
49
+ ): Promise<void>;
50
+ deletePost(id: string): Promise<void>;
51
+ // flips scheduled posts whose time has passed to published; returns count
52
+ publishDuePosts(now: number): Promise<number>;
53
+
54
+ // media
55
+ listMedia(): Promise<Media[]>;
56
+ getMedia(id: string): Promise<Media | null>;
57
+ createMedia(data: Omit<Media, 'createdAt'>): Promise<Media>;
58
+ updateMedia(id: string, data: Partial<Pick<Media, 'alt'>>): Promise<void>;
59
+ deleteMedia(id: string): Promise<void>;
60
+
61
+ // links — typed link-in-bio blocks, one ordered list
62
+ listLinks(): Promise<Link[]>;
63
+ createLink(data: {
64
+ label: string;
65
+ url: string;
66
+ icon?: string;
67
+ kind?: Link['kind'];
68
+ html?: string;
69
+ meta?: Record<string, unknown>;
70
+ }): Promise<Link>;
71
+ updateLink(id: string, data: Partial<Pick<Link, 'label' | 'url' | 'icon' | 'html' | 'meta'>>): Promise<void>;
72
+ moveLink(id: string, dir: 'up' | 'down'): Promise<void>;
73
+ // full ordering in one call (drag-and-drop); ids in desired display order
74
+ reorderLinks(ids: string[]): Promise<void>;
75
+ deleteLink(id: string): Promise<void>;
76
+ trackClick(id: string): Promise<string | null>; // returns url to redirect to
77
+
78
+ // tags — registry of tag entities; posts store tag names, this table adds
79
+ // slugs/descriptions and powers archives
80
+ listTags(): Promise<Tag[]>;
81
+ getTagBySlug(slug: string): Promise<Tag | null>;
82
+ createTag(data: { name: string; slug: string; description?: string }): Promise<Tag>;
83
+ updateTag(id: string, data: Partial<Pick<Tag, 'name' | 'slug' | 'description'>>): Promise<void>;
84
+ deleteTag(id: string): Promise<void>;
85
+ // auto-register any names missing from the registry (called on post save)
86
+ ensureTags(names: string[]): Promise<void>;
87
+
88
+ // users — passHash stays inside the repo; getUserAuth is for login only
89
+ listUsers(): Promise<User[]>;
90
+ getUser(id: string): Promise<User | null>;
91
+ createUser(data: { name: string; email: string; role: Role; passHash: string }): Promise<User>;
92
+ updateUser(id: string, data: { name?: string; email?: string; role?: Role; passHash?: string; bio?: string; avatar?: string; slug?: string }): Promise<void>;
93
+ deleteUser(id: string): Promise<void>;
94
+ getUserAuth(email: string): Promise<{ user: User; passHash: string } | null>;
95
+
96
+ // navigation — managed header/footer/social items; empty header falls back
97
+ // to the legacy auto-list of published pages in Site.astro
98
+ listNavItems(): Promise<NavItem[]>;
99
+ createNavItem(data: { label: string; url: string; area: NavItem['area'] }): Promise<NavItem>;
100
+ updateNavItem(id: string, data: Partial<Pick<NavItem, 'label' | 'url' | 'area'>>): Promise<void>;
101
+ reorderNavItems(ids: string[]): Promise<void>;
102
+ deleteNavItem(id: string): Promise<void>;
103
+
104
+ // public search — ranked LIKE over published posts and pages (title >
105
+ // excerpt > body). Behind this method so FTS can replace it transparently.
106
+ searchPublished(query: string): Promise<{ posts: Post[]; pages: Page[] }>;
107
+
108
+ // backup restore — id-preserving replace-all from a backup's content.json.
109
+ // Users restore WITHOUT password hashes (backups never contain them).
110
+ restoreAll(content: {
111
+ settings: Partial<Settings>;
112
+ pages: Page[];
113
+ posts: Post[];
114
+ media: Media[];
115
+ links: Link[];
116
+ tags: Tag[];
117
+ navItems: NavItem[];
118
+ users: User[];
119
+ redirects: Redirect[];
120
+ }): Promise<void>;
121
+
122
+ // redirects — exact-path forwards, consulted when a request would 404.
123
+ // createRedirect upserts on fromPath and prunes self/loop entries.
124
+ listRedirects(): Promise<Redirect[]>;
125
+ createRedirect(data: { fromPath: string; toPath: string; code?: 301 | 302 }): Promise<Redirect>;
126
+ deleteRedirect(id: string): Promise<void>;
127
+
128
+ // settings & dashboard
129
+ getSettings(): Promise<Settings>;
130
+ updateSettings(data: Partial<Settings>): Promise<void>;
131
+ getStats(): Promise<Stats>;
132
+ }
@@ -0,0 +1,286 @@
1
+ import { z } from 'zod';
2
+
3
+ // ---- Widgets -------------------------------------------------------------
4
+ // Each widget type has a zod config schema; pages store an ordered array of
5
+ // WidgetInstance. Adding a widget type = extend this union + a render
6
+ // component in src/components/widgets/ + an editor section in the admin.
7
+
8
+ export const widgetConfigs = {
9
+ hero: z.object({
10
+ heading: z.string().default(''),
11
+ sub: z.string().default(''),
12
+ ctaLabel: z.string().default(''),
13
+ ctaHref: z.string().default(''),
14
+ align: z.enum(['left', 'center']).default('center'),
15
+ }),
16
+ text: z.object({
17
+ markdown: z.string().default(''),
18
+ }),
19
+ image: z.object({
20
+ src: z.string().default(''),
21
+ alt: z.string().default(''),
22
+ caption: z.string().default(''),
23
+ }),
24
+ button: z.object({
25
+ label: z.string().default(''),
26
+ href: z.string().default(''),
27
+ variant: z.enum(['primary', 'outline']).default('primary'),
28
+ }),
29
+ spacer: z.object({
30
+ size: z.enum(['s', 'm', 'l']).default('m'),
31
+ }),
32
+ embed: z.object({
33
+ url: z.string().default(''),
34
+ height: z.coerce.number().default(400),
35
+ }),
36
+ customHtml: z.object({
37
+ html: z.string().default(''),
38
+ css: z.string().default(''),
39
+ js: z.string().default(''),
40
+ height: z.coerce.number().default(300),
41
+ }),
42
+ } as const;
43
+
44
+ export type WidgetType = keyof typeof widgetConfigs;
45
+ export const widgetTypes = Object.keys(widgetConfigs) as WidgetType[];
46
+
47
+ export const WidgetInstance = z.object({
48
+ id: z.string(),
49
+ type: z.enum(Object.keys(widgetConfigs) as [WidgetType, ...WidgetType[]]),
50
+ config: z.record(z.any()),
51
+ });
52
+ export type WidgetInstance = z.infer<typeof WidgetInstance>;
53
+
54
+ export function parseConfig<T extends WidgetType>(type: T, raw: unknown): z.infer<(typeof widgetConfigs)[T]> {
55
+ return widgetConfigs[type].parse(raw ?? {}) as z.infer<(typeof widgetConfigs)[T]>;
56
+ }
57
+
58
+ // ---- Content -------------------------------------------------------------
59
+
60
+ export const Status = z.enum(['draft', 'scheduled', 'published', 'archived']);
61
+ export type Status = z.infer<typeof Status>;
62
+
63
+ export const Page = z.object({
64
+ id: z.string(),
65
+ slug: z.string(),
66
+ title: z.string(),
67
+ status: Status,
68
+ isHome: z.boolean().default(false),
69
+ // acms({ i18n }) locale code; '' = the default locale. Home pages are
70
+ // unique per locale, so a site can have one home per language.
71
+ locale: z.string().default(''),
72
+ // blocks is the legacy widget document; bodyJson (TipTap doc) is canonical
73
+ // once present, with bodyHtml the pre-rendered public markup.
74
+ blocks: z.array(WidgetInstance).default([]),
75
+ bodyJson: z.string().default(''),
76
+ bodyHtml: z.string().default(''),
77
+ seoTitle: z.string().default(''),
78
+ seoDesc: z.string().default(''),
79
+ // theme template name (templates/page-<name>.astro); empty = default layout
80
+ template: z.string().default(''),
81
+ createdAt: z.number(),
82
+ updatedAt: z.number(),
83
+ });
84
+ export type Page = z.infer<typeof Page>;
85
+
86
+ export const Post = z.object({
87
+ id: z.string(),
88
+ // config key from acms({ content.collections }); 'posts' is the built-in.
89
+ // Permanent identifier — the collection's labels/base rename freely, this
90
+ // value never does.
91
+ collection: z.string().default('posts'),
92
+ slug: z.string(),
93
+ title: z.string(),
94
+ // body is legacy markdown; bodyJson (TipTap doc) is canonical once present,
95
+ // with bodyHtml the pre-rendered public markup.
96
+ body: z.string().default(''),
97
+ bodyJson: z.string().default(''),
98
+ bodyHtml: z.string().default(''),
99
+ excerpt: z.string().default(''),
100
+ tags: z.array(z.string()).default([]),
101
+ cover: z.string().default(''),
102
+ coverAlt: z.string().default(''),
103
+ seoTitle: z.string().default(''),
104
+ seoDesc: z.string().default(''),
105
+ status: Status,
106
+ featured: z.boolean().default(false),
107
+ // authorId stays as the primary author (first of authorIds) for
108
+ // backward compatibility; authorIds is the full ordered list
109
+ authorId: z.string().nullable().default(null),
110
+ authorIds: z.array(z.string()).default([]),
111
+ // theme template name (templates/post-<name>.astro); empty = default layout
112
+ template: z.string().default(''),
113
+ publishedAt: z.number().nullable(),
114
+ scheduledAt: z.number().nullable().default(null),
115
+ createdAt: z.number(),
116
+ updatedAt: z.number(),
117
+ });
118
+ export type Post = z.infer<typeof Post>;
119
+
120
+ // Ghost-style internal tags: a leading '#' keeps a tag out of all public
121
+ // surfaces (chips, archives, sitemap) while it stays usable for organizing
122
+ // and for post-list blocks.
123
+ export const isInternalTag = (name: string) => name.startsWith('#');
124
+ export const publicTags = (names: string[]) => names.filter((n) => !isInternalTag(n));
125
+
126
+ export const MediaVariant = z.object({ w: z.number(), key: z.string() });
127
+ export type MediaVariant = z.infer<typeof MediaVariant>;
128
+
129
+ export const Media = z.object({
130
+ id: z.string(),
131
+ key: z.string(), // storage key of the original upload
132
+ filename: z.string(),
133
+ mime: z.string(),
134
+ width: z.number(),
135
+ height: z.number(),
136
+ size: z.number(),
137
+ alt: z.string().default(''),
138
+ variants: z.array(MediaVariant).default([]),
139
+ createdAt: z.number(),
140
+ });
141
+ export type Media = z.infer<typeof Media>;
142
+
143
+ // Tags are entities (Ghost-style): posts keep storing an array of tag NAMES
144
+ // for backward compatibility, while the tags table is the registry that adds
145
+ // slugs + descriptions and powers /blog/tag/[slug] archives. Unknown names
146
+ // are auto-registered on post save.
147
+ export const Tag = z.object({
148
+ id: z.string(),
149
+ name: z.string(),
150
+ slug: z.string(),
151
+ description: z.string().default(''),
152
+ });
153
+ export type Tag = z.infer<typeof Tag>;
154
+
155
+ export const Role = z.enum(['owner', 'editor']);
156
+ export type Role = z.infer<typeof Role>;
157
+
158
+ // Password hashes never leave the repo layer — User is the public shape.
159
+ export const User = z.object({
160
+ id: z.string(),
161
+ name: z.string(),
162
+ email: z.string(),
163
+ role: Role,
164
+ // public author profile: bio + avatar render on bylines/author cards;
165
+ // slug powers /author/<slug> (falls back to slugify(name) when empty)
166
+ bio: z.string().default(''),
167
+ avatar: z.string().default(''),
168
+ slug: z.string().default(''),
169
+ createdAt: z.number(),
170
+ });
171
+ export type User = z.infer<typeof User>;
172
+
173
+ export const authorSlug = (u: Pick<User, 'name' | 'slug'>) => u.slug || slugify(u.name);
174
+
175
+ // The links page is an ordered list of typed blocks: link buttons (custom or
176
+ // brand-preset), mailto/tel buttons, a downloadable vcard, headings, static
177
+ // text, spacers, and raw HTML/JS. `icon` holds an emoji, an image URL, inline
178
+ // `<svg…>` markup (brand icons), or the keyword 'favicon'. `meta` is per-kind
179
+ // extras: { color, textColor } button colors, { size } spacer height, vcard
180
+ // fields, etc.
181
+ export const LinkKind = z.enum(['link', 'email', 'phone', 'heading', 'text', 'spacer', 'vcard', 'html']);
182
+ export type LinkKind = z.infer<typeof LinkKind>;
183
+
184
+ export const Link = z.object({
185
+ id: z.string(),
186
+ kind: LinkKind.default('link'),
187
+ label: z.string(),
188
+ url: z.string(),
189
+ icon: z.string().default(''),
190
+ html: z.string().default(''),
191
+ meta: z.record(z.any()).default({}),
192
+ order: z.number(),
193
+ clicks: z.number().default(0),
194
+ });
195
+ export type Link = z.infer<typeof Link>;
196
+
197
+ // Site navigation, managed at /admin/navigation. `header` renders in the top
198
+ // nav (replacing the old auto-list of published pages), `footer` above the
199
+ // footer text, `social` as the footer social row (also reused by share UIs).
200
+ export const NavArea = z.enum(['header', 'footer', 'social']);
201
+ export type NavArea = z.infer<typeof NavArea>;
202
+
203
+ export const NavItem = z.object({
204
+ id: z.string(),
205
+ label: z.string(),
206
+ url: z.string(),
207
+ area: NavArea.default('header'),
208
+ order: z.number(),
209
+ });
210
+ export type NavItem = z.infer<typeof NavItem>;
211
+
212
+ // URL redirects: exact-path forwards consulted when a request would 404.
213
+ // Created by hand in Settings → Redirects, or automatically when a published
214
+ // post/page slug changes (so old links never rot).
215
+ export const Redirect = z.object({
216
+ id: z.string(),
217
+ fromPath: z.string(),
218
+ toPath: z.string(),
219
+ code: z.union([z.literal(301), z.literal(302)]).default(301),
220
+ createdAt: z.number(),
221
+ });
222
+ export type Redirect = z.infer<typeof Redirect>;
223
+
224
+ // normalize a redirect path: leading slash, no trailing slash, no origin
225
+ export function normalizeRedirectPath(input: string): string {
226
+ let p = input.trim();
227
+ if (!p) return '';
228
+ try {
229
+ if (/^https?:\/\//.test(p)) return p; // external target URLs pass through
230
+ } catch { /* fall through */ }
231
+ if (!p.startsWith('/')) p = `/${p}`;
232
+ if (p.length > 1) p = p.replace(/\/+$/, '');
233
+ return p;
234
+ }
235
+
236
+ export const Settings = z.object({
237
+ siteTitle: z.string().default('My Site'),
238
+ tagline: z.string().default(''),
239
+ // site-wide footer, markdown allowed; empty falls back to "title — tagline"
240
+ footerText: z.string().default(''),
241
+ // links-page identity; falls back to siteTitle/tagline until set
242
+ linksName: z.string().default(''),
243
+ linksBio: z.string().default(''),
244
+ linksAvatar: z.string().default(''),
245
+ // theme engine: which src/themes/ folder is active, plus sparse token
246
+ // overrides from the Design screen layered on top of the theme's defaults
247
+ activeTheme: z.string().default(''),
248
+ designOverrides: z.record(z.any()).default({}),
249
+ siteLogo: z.string().default(''),
250
+ // SEO foundation: canonical origin for absolute URLs (sitemap, feeds, OG
251
+ // tags — empty falls back to the request origin), favicon, and the default
252
+ // social-share image used when a post has no cover
253
+ siteUrl: z.string().default(''),
254
+ siteFavicon: z.string().default(''),
255
+ siteCover: z.string().default(''),
256
+ // code injection: raw HTML appended to <head> / before </body> (owner-only)
257
+ headCode: z.string().default(''),
258
+ footerCode: z.string().default(''),
259
+ // publication timezone (IANA name) and locale (BCP 47); empty = UTC / en-US
260
+ timezone: z.string().default(''),
261
+ locale: z.string().default(''),
262
+ // site-wide announcement bar above the header
263
+ announcement: z
264
+ .object({
265
+ enabled: z.boolean().default(false),
266
+ text: z.string().default(''),
267
+ link: z.string().default(''),
268
+ dismissible: z.boolean().default(true),
269
+ })
270
+ .default({}),
271
+ // external drafts API: bearer key for POST /api/drafts (empty = endpoint
272
+ // off) and the browser origins allowed to call it ('*' entry allows all,
273
+ // 'http://localhost:*' matches any port). Managed in /admin/settings.
274
+ draftsApiKey: z.string().default(''),
275
+ draftsAllowedOrigins: z.array(z.string()).default([]),
276
+ });
277
+ export type Settings = z.infer<typeof Settings>;
278
+
279
+ export function slugify(input: string): string {
280
+ return input
281
+ .toLowerCase()
282
+ .trim()
283
+ .replace(/[^a-z0-9\s-]/g, '')
284
+ .replace(/[\s_-]+/g, '-')
285
+ .replace(/^-+|-+$/g, '') || 'untitled';
286
+ }
package/src/lib/seo.ts ADDED
@@ -0,0 +1,37 @@
1
+ import type { Settings } from './schema';
2
+
3
+ // Absolute-URL helpers shared by the layout head, sitemap, RSS, and robots
4
+ // routes. The canonical origin is the siteUrl setting; a fresh install falls
5
+ // back to the request origin so links still work before it's configured.
6
+
7
+ export function siteOrigin(settings: Settings, requestUrl: URL): string {
8
+ const configured = settings.siteUrl.trim().replace(/\/+$/, '');
9
+ if (!configured) return requestUrl.origin;
10
+ try {
11
+ return new URL(configured.includes('://') ? configured : `https://${configured}`).origin;
12
+ } catch {
13
+ return requestUrl.origin;
14
+ }
15
+ }
16
+
17
+ export function absUrl(pathOrUrl: string, origin: string): string {
18
+ try {
19
+ return new URL(pathOrUrl, origin).href;
20
+ } catch {
21
+ return pathOrUrl;
22
+ }
23
+ }
24
+
25
+ export function xmlEscape(s: string): string {
26
+ return s
27
+ .replace(/&/g, '&amp;')
28
+ .replace(/</g, '&lt;')
29
+ .replace(/>/g, '&gt;')
30
+ .replace(/"/g, '&quot;')
31
+ .replace(/'/g, '&apos;');
32
+ }
33
+
34
+ // canonical path: no trailing slash except the root
35
+ export function canonicalPath(pathname: string): string {
36
+ return pathname !== '/' && pathname.endsWith('/') ? pathname.replace(/\/+$/, '') : pathname;
37
+ }