@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,206 @@
1
+ import { z } from 'zod';
2
+ import type { Settings } from './schema';
3
+
4
+ // Code-first theme engine. A theme is a folder under src/themes/<slug>/:
5
+ // theme.json — { name, description, tokens } (tokens below)
6
+ // theme.css — optional extra CSS beyond what tokens express
7
+ // templates/home.astro — optional coded homepage; wins over the isHome
8
+ // content page unless settings force 'content'
9
+ // Themes are discovered at build time via import.meta.glob, so adding one is
10
+ // just adding a folder — no registry edit. The admin Design screen layers
11
+ // sparse per-token overrides (settings.designOverrides) on top; resolveDesign
12
+ // merges the two and emits CSS variables consumed by site.css.
13
+
14
+ export const ThemeTokens = z.object({
15
+ bg: z.string().default('#faf9f7'),
16
+ ink: z.string().default('#1e1f24'),
17
+ muted: z.string().default('#6b6e78'),
18
+ line: z.string().default('#e5e3de'),
19
+ accent: z.string().default('#4f46e5'),
20
+ panel: z.string().default('#ffffff'),
21
+ soft: z.string().default('#eceae5'),
22
+ headingFont: z.string().default("'Inter', system-ui, sans-serif"),
23
+ bodyFont: z.string().default("'Inter', system-ui, sans-serif"),
24
+ // the `family=` query for fonts.googleapis.com/css2; empty = no webfont
25
+ googleFonts: z.string().default('family=Inter:wght@400;600;700'),
26
+ width: z.coerce.number().default(760),
27
+ });
28
+ export type ThemeTokens = z.infer<typeof ThemeTokens>;
29
+
30
+ // Token keys the Design screen may override individually.
31
+ export const OVERRIDE_KEYS = ['bg', 'ink', 'muted', 'line', 'accent', 'panel', 'soft', 'width'] as const;
32
+
33
+ // Post-page display options. Themes set defaults in theme.json under `post`;
34
+ // the Design screen overrides individual fields via designOverrides
35
+ // (postCover: 'normal'|'full'|'hidden', the rest as 'show'|'hide' strings).
36
+ export const PostOptions = z.object({
37
+ cover: z.enum(['normal', 'full', 'overlay', 'hidden']).default('normal'),
38
+ showDate: z.boolean().default(true),
39
+ showReadingTime: z.boolean().default(true),
40
+ byline: z.boolean().default(true),
41
+ authorCard: z.boolean().default(true),
42
+ share: z.boolean().default(true),
43
+ related: z.boolean().default(true),
44
+ });
45
+ export type PostOptions = z.infer<typeof PostOptions>;
46
+
47
+ // override key in designOverrides → PostOptions boolean field
48
+ const POST_TOGGLES: [string, keyof PostOptions][] = [
49
+ ['postDate', 'showDate'],
50
+ ['postReadingTime', 'showReadingTime'],
51
+ ['postByline', 'byline'],
52
+ ['postAuthorCard', 'authorCard'],
53
+ ['postShare', 'share'],
54
+ ['postRelated', 'related'],
55
+ ];
56
+
57
+ // Curated heading/body pairings the UI can swap in (override key: fontPairing).
58
+ // Agent-authored themes set headingFont/bodyFont/googleFonts directly instead.
59
+ export const FONT_PAIRINGS = [
60
+ { key: 'inter', label: 'Inter (sans)', headingFont: "'Inter', system-ui, sans-serif", bodyFont: "'Inter', system-ui, sans-serif", googleFonts: 'family=Inter:wght@400;600;700' },
61
+ { key: 'fraunces', label: 'Fraunces + Inter (editorial serif)', headingFont: "'Fraunces', Georgia, serif", bodyFont: "'Inter', system-ui, sans-serif", googleFonts: 'family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;1,9..144,400&family=Inter:wght@400;500;600' },
62
+ { key: 'playfair', label: 'Playfair Display + Inter', headingFont: "'Playfair Display', Georgia, serif", bodyFont: "'Inter', system-ui, sans-serif", googleFonts: 'family=Playfair+Display:ital,wght@0,500;0,600;1,500&family=Inter:wght@400;500;600' },
63
+ { key: 'spectral', label: 'Spectral (serif)', headingFont: "'Spectral', Georgia, serif", bodyFont: "'Spectral', Georgia, serif", googleFonts: 'family=Spectral:ital,wght@0,400;0,600;1,400' },
64
+ { key: 'space', label: 'Space Grotesk + Inter', headingFont: "'Space Grotesk', system-ui, sans-serif", bodyFont: "'Inter', system-ui, sans-serif", googleFonts: 'family=Space+Grotesk:wght@500;700&family=Inter:wght@400;500;600' },
65
+ { key: 'lora', label: 'Lora + Inter', headingFont: "'Lora', Georgia, serif", bodyFont: "'Inter', system-ui, sans-serif", googleFonts: 'family=Lora:ital,wght@0,500;0,600;1,500&family=Inter:wght@400;500;600' },
66
+ { key: 'system', label: 'System UI (no webfont)', headingFont: 'system-ui, sans-serif', bodyFont: 'system-ui, sans-serif', googleFonts: '' },
67
+ ] as const;
68
+
69
+ export interface Theme {
70
+ slug: string;
71
+ name: string;
72
+ description: string;
73
+ tokens: ThemeTokens;
74
+ post: PostOptions;
75
+ css: string;
76
+ // Astro component (module default) when the theme ships templates/home.astro
77
+ HomeTemplate: unknown | null;
78
+ // optional per-document templates: templates/post-<name>.astro and
79
+ // templates/page-<name>.astro, selectable in the editor sidebar
80
+ postTemplates: Record<string, unknown>;
81
+ pageTemplates: Record<string, unknown>;
82
+ }
83
+
84
+ interface ThemeJson { name?: string; description?: string; tokens?: Record<string, unknown>; post?: Record<string, unknown> }
85
+
86
+ // Built-in themes ship inside the package (relative globs); project themes
87
+ // come from the consumer's src/themes via the integration's virtual module
88
+ // (root-absolute globs — a package can't glob outside itself). A project
89
+ // theme whose slug matches a built-in replaces it.
90
+ import * as user from 'virtual:acms-user-themes';
91
+
92
+ const jsons = import.meta.glob('../themes/*/theme.json', { eager: true, import: 'default' }) as Record<string, ThemeJson>;
93
+ const csses = import.meta.glob('../themes/*/theme.css', { eager: true, query: '?raw', import: 'default' }) as Record<string, string>;
94
+ const homes = import.meta.glob('../themes/*/templates/home.astro', { eager: true }) as Record<string, { default: unknown }>;
95
+ const postTpls = import.meta.glob('../themes/*/templates/post-*.astro', { eager: true }) as Record<string, { default: unknown }>;
96
+ const pageTpls = import.meta.glob('../themes/*/templates/page-*.astro', { eager: true }) as Record<string, { default: unknown }>;
97
+
98
+ // works for both '../themes/<slug>/…' and '/src/themes/<slug>/…'
99
+ const slugOf = (path: string) => /themes\/([^/]+)\//.exec(path)?.[1] ?? path.split('/')[2];
100
+ const tplName = (path: string, kind: 'post' | 'page') =>
101
+ path.split('/').pop()!.replace(`${kind}-`, '').replace('.astro', '');
102
+
103
+ function templatesFor(slug: string, glob: Record<string, { default: unknown }>, kind: 'post' | 'page') {
104
+ return Object.fromEntries(
105
+ Object.entries(glob)
106
+ .filter(([p]) => slugOf(p) === slug)
107
+ .map(([p, mod]) => [tplName(p, kind), mod.default])
108
+ );
109
+ }
110
+
111
+ export const DEFAULT_THEME = 'ink';
112
+
113
+ function buildThemes(
114
+ jsonMap: Record<string, ThemeJson>,
115
+ cssMap: Record<string, string>,
116
+ homeMap: Record<string, { default: unknown }>,
117
+ postMap: Record<string, { default: unknown }>,
118
+ pageMap: Record<string, { default: unknown }>
119
+ ): Theme[] {
120
+ return Object.entries(jsonMap).map(([path, raw]) => {
121
+ const slug = slugOf(path);
122
+ return {
123
+ slug,
124
+ name: raw.name ?? slug,
125
+ description: raw.description ?? '',
126
+ tokens: ThemeTokens.parse(raw.tokens ?? {}),
127
+ post: PostOptions.parse(raw.post ?? {}),
128
+ css: Object.entries(cssMap).find(([p]) => slugOf(p) === slug)?.[1] ?? '',
129
+ HomeTemplate: Object.entries(homeMap).find(([p]) => slugOf(p) === slug)?.[1]?.default ?? null,
130
+ postTemplates: templatesFor(slug, postMap, 'post'),
131
+ pageTemplates: templatesFor(slug, pageMap, 'page'),
132
+ };
133
+ });
134
+ }
135
+
136
+ const builtins = buildThemes(jsons, csses, homes, postTpls, pageTpls);
137
+ const projectThemes = buildThemes(
138
+ user.jsons as Record<string, ThemeJson>,
139
+ user.csses,
140
+ user.homes,
141
+ user.postTpls,
142
+ user.pageTpls
143
+ );
144
+ const projectSlugs = new Set(projectThemes.map((t) => t.slug));
145
+
146
+ const themes: Theme[] = [...builtins.filter((t) => !projectSlugs.has(t.slug)), ...projectThemes].sort(
147
+ (a, b) => (a.slug === DEFAULT_THEME ? -1 : b.slug === DEFAULT_THEME ? 1 : a.name.localeCompare(b.name))
148
+ );
149
+
150
+ export function listThemes(): Theme[] {
151
+ return themes;
152
+ }
153
+
154
+ export function getTheme(slug: string): Theme {
155
+ return themes.find((t) => t.slug === slug) ?? themes.find((t) => t.slug === DEFAULT_THEME) ?? themes[0];
156
+ }
157
+
158
+ export interface ResolvedDesign {
159
+ theme: Theme;
160
+ tokens: ThemeTokens;
161
+ post: PostOptions;
162
+ css: string; // ready-to-inline <style> content: :root vars + theme.css
163
+ fontsHref: string | null;
164
+ homeMode: 'auto' | 'content';
165
+ HomeTemplate: unknown | null;
166
+ }
167
+
168
+ export function resolveDesign(settings: Settings): ResolvedDesign {
169
+ const theme = getTheme(settings.activeTheme || DEFAULT_THEME);
170
+ const o = (settings.designOverrides ?? {}) as Record<string, unknown>;
171
+ const merged: Record<string, unknown> = { ...theme.tokens };
172
+ for (const k of OVERRIDE_KEYS) {
173
+ const v = o[k];
174
+ if (v !== undefined && v !== null && v !== '') merged[k] = v;
175
+ }
176
+ if (typeof o.fontPairing === 'string' && o.fontPairing) {
177
+ const p = FONT_PAIRINGS.find((f) => f.key === o.fontPairing);
178
+ if (p) {
179
+ merged.headingFont = p.headingFont;
180
+ merged.bodyFont = p.bodyFont;
181
+ merged.googleFonts = p.googleFonts;
182
+ }
183
+ }
184
+ const tokens = ThemeTokens.parse(merged);
185
+ const post: PostOptions = { ...theme.post };
186
+ if (o.postCover === 'normal' || o.postCover === 'full' || o.postCover === 'overlay' || o.postCover === 'hidden') post.cover = o.postCover;
187
+ for (const [key, field] of POST_TOGGLES) {
188
+ if (o[key] === 'show') (post[field] as boolean) = true;
189
+ if (o[key] === 'hide') (post[field] as boolean) = false;
190
+ }
191
+ const vars = [
192
+ `--bg:${tokens.bg}`, `--ink:${tokens.ink}`, `--muted:${tokens.muted}`,
193
+ `--line:${tokens.line}`, `--accent:${tokens.accent}`, `--panel:${tokens.panel}`,
194
+ `--soft:${tokens.soft}`, `--font-heading:${tokens.headingFont}`,
195
+ `--font-body:${tokens.bodyFont}`, `--content-width:${tokens.width}px`,
196
+ ].join(';');
197
+ return {
198
+ theme,
199
+ tokens,
200
+ post,
201
+ css: `:root{${vars}}\n${theme.css}`,
202
+ fontsHref: tokens.googleFonts ? `https://fonts.googleapis.com/css2?${tokens.googleFonts}&display=swap` : null,
203
+ homeMode: o.homeMode === 'content' ? 'content' : 'auto',
204
+ HomeTemplate: theme.HomeTemplate,
205
+ };
206
+ }
@@ -0,0 +1,108 @@
1
+ import { z } from 'zod';
2
+ import { widgetConfigs, type WidgetType } from './schema';
3
+
4
+ // Client-safe widget registry: display metadata for the builder palette and
5
+ // field specs derived from the Zod configs (single source of truth — adding
6
+ // a field to a schema makes it appear in the inspector automatically).
7
+
8
+ export interface WidgetMeta {
9
+ label: string;
10
+ description: string;
11
+ group: 'Layout' | 'Content' | 'Advanced';
12
+ icon: string; // svg path, 24 viewBox, stroked
13
+ }
14
+
15
+ export const widgetMeta: Record<WidgetType, WidgetMeta> = {
16
+ hero: {
17
+ label: 'Hero',
18
+ description: 'Headline, subtitle + CTA',
19
+ group: 'Layout',
20
+ icon: 'M4 5h16M6 10h12M9 15h6M10.5 19h3',
21
+ },
22
+ text: {
23
+ label: 'Text',
24
+ description: 'Markdown prose',
25
+ group: 'Content',
26
+ icon: 'M4 6h16M4 11h16M4 16h10',
27
+ },
28
+ image: {
29
+ label: 'Image',
30
+ description: 'Single image + caption',
31
+ group: 'Content',
32
+ icon: 'M3 5h18v14H3zM3 15l5-5 4 4 3-3 6 6M15.5 9.5h0',
33
+ },
34
+ button: {
35
+ label: 'Button',
36
+ description: 'Call-to-action link',
37
+ group: 'Content',
38
+ icon: 'M4 8h16v8H4zM8 12h8',
39
+ },
40
+ spacer: {
41
+ label: 'Spacer',
42
+ description: 'Vertical breathing room',
43
+ group: 'Layout',
44
+ icon: 'M12 4v16M8 7l4-3 4 3M8 17l4 3 4-3',
45
+ },
46
+ embed: {
47
+ label: 'Embed',
48
+ description: 'Iframe any URL',
49
+ group: 'Advanced',
50
+ icon: 'M4 5h16v14H4zM10 9.5l5 2.5-5 2.5z',
51
+ },
52
+ customHtml: {
53
+ label: 'Custom HTML',
54
+ description: 'Sandboxed HTML/CSS/JS',
55
+ group: 'Advanced',
56
+ icon: 'M8 6l-5 6 5 6M16 6l5 6-5 6',
57
+ },
58
+ };
59
+
60
+ export interface FieldSpec {
61
+ key: string;
62
+ label: string;
63
+ kind: 'text' | 'textarea' | 'code' | 'number' | 'select' | 'image';
64
+ options?: string[];
65
+ }
66
+
67
+ const TEXTAREA_KEYS = new Set(['markdown']);
68
+ const CODE_KEYS = new Set(['html', 'css', 'js']);
69
+ const IMAGE_KEYS = new Set(['src']);
70
+
71
+ function humanize(key: string): string {
72
+ return key
73
+ .replace(/([A-Z])/g, ' $1')
74
+ .replace(/^./, (c) => c.toUpperCase())
75
+ .replace('Cta', 'CTA')
76
+ .replace('Href', 'link')
77
+ .replace(/\bUrl\b/i, 'URL');
78
+ }
79
+
80
+ function unwrap(schema: z.ZodTypeAny): z.ZodTypeAny {
81
+ let s = schema;
82
+ while (s instanceof z.ZodDefault || s instanceof z.ZodOptional || s instanceof z.ZodEffects) {
83
+ s = s instanceof z.ZodEffects ? s.innerType() : s._def.innerType;
84
+ }
85
+ return s;
86
+ }
87
+
88
+ export function fieldSpecs(type: WidgetType): FieldSpec[] {
89
+ const shape = (widgetConfigs[type] as z.ZodObject<z.ZodRawShape>).shape;
90
+ return Object.entries(shape).map(([key, schema]) => {
91
+ const inner = unwrap(schema as z.ZodTypeAny);
92
+ let kind: FieldSpec['kind'] = 'text';
93
+ let options: string[] | undefined;
94
+ if (inner instanceof z.ZodEnum) {
95
+ kind = 'select';
96
+ options = inner.options as string[];
97
+ } else if (inner instanceof z.ZodNumber) {
98
+ kind = 'number';
99
+ } else if (TEXTAREA_KEYS.has(key)) {
100
+ kind = 'textarea';
101
+ } else if (CODE_KEYS.has(key)) {
102
+ kind = 'code';
103
+ } else if (IMAGE_KEYS.has(key)) {
104
+ kind = 'image';
105
+ }
106
+ return { key, label: humanize(key), kind, options };
107
+ });
108
+ }
@@ -0,0 +1,109 @@
1
+ import { defineMiddleware } from 'astro:middleware';
2
+ import { verifySessionToken, SESSION_COOKIE } from './lib/auth';
3
+ import { getRepo } from './lib/repo';
4
+ import { CONTENT } from './lib/content';
5
+
6
+ // Legacy content-URL redirects, checked only on would-be 404s (below) so they
7
+ // can never shadow real content. Tags moved from `${base}/tag/` to a
8
+ // standalone base for every site, so tag sources include every collection
9
+ // base; per-collection sources only exist when a base was renamed away from a
10
+ // legacy one ('/blog' is the built-in collection's historic default).
11
+ const TAG_SOURCES = [
12
+ ...new Set([
13
+ ...Object.values(CONTENT.collections).map((c) => c.base),
14
+ '/blog',
15
+ ...Object.values(CONTENT.collections).flatMap((c) => c.redirectFrom),
16
+ ]),
17
+ ];
18
+ const COLLECTION_SOURCES: Array<{ from: string; to: string }> = Object.entries(CONTENT.collections).flatMap(
19
+ ([key, c]) =>
20
+ [...new Set([...(key === 'posts' ? ['/blog'] : []), ...c.redirectFrom])]
21
+ .filter((from) => from !== c.base)
22
+ .map((from) => ({ from, to: c.base }))
23
+ );
24
+
25
+ export function legacyContentRedirect(pathname: string): string | null {
26
+ for (const src of TAG_SOURCES) {
27
+ if (pathname.startsWith(`${src}/tag/`)) {
28
+ return `${CONTENT.tags.base}/${pathname.slice(src.length + '/tag/'.length)}`;
29
+ }
30
+ }
31
+ for (const { from, to } of COLLECTION_SOURCES) {
32
+ if (pathname === from) return to;
33
+ if (pathname.startsWith(`${from}/`)) return to + pathname.slice(from.length);
34
+ }
35
+ return null;
36
+ }
37
+
38
+ // Scheduled publishing fallback: on normal page traffic, at most once a
39
+ // minute per isolate, flip due scheduled posts to published. On Workers the
40
+ // cron trigger in src/worker.ts is the primary path; this sweep covers the
41
+ // Node target and local dev.
42
+ let lastPublishCheck = 0;
43
+
44
+ export const onRequest = defineMiddleware(async (context, next) => {
45
+ const { pathname } = context.url;
46
+
47
+ // one canonical URL per page: trailing-slash variants 301 to the bare path.
48
+ // GET pages only — the astro:actions client POSTs to /_actions/<name>/ with
49
+ // a trailing slash, and a 301 would downgrade that POST to a GET.
50
+ if (
51
+ pathname !== '/' &&
52
+ pathname.endsWith('/') &&
53
+ context.request.method === 'GET' &&
54
+ !pathname.startsWith('/_')
55
+ ) {
56
+ return context.redirect(pathname.replace(/\/+$/, '') + context.url.search, 301);
57
+ }
58
+
59
+ if (pathname.startsWith('/admin') && pathname !== '/admin/login') {
60
+ const token = context.cookies.get(SESSION_COOKIE)?.value;
61
+ if (!verifySessionToken(token)) {
62
+ return context.redirect('/admin/login');
63
+ }
64
+ }
65
+
66
+ const now = Date.now();
67
+ if (now - lastPublishCheck > 60_000 && !pathname.startsWith('/media/')) {
68
+ lastPublishCheck = now;
69
+ try {
70
+ const repo = await getRepo(context.locals);
71
+ await repo.publishDuePosts(now);
72
+ } catch {
73
+ // never let the publish sweep break a request
74
+ }
75
+ }
76
+
77
+ const response = await next();
78
+
79
+ // Redirects fire only when the request would otherwise 404, so a rule can
80
+ // never shadow real content. The table is cached per isolate for a minute.
81
+ if (response.status === 404 && !pathname.startsWith('/admin') && !pathname.startsWith('/api')) {
82
+ try {
83
+ const hit = (await getRedirects(context.locals, now)).get(pathname);
84
+ if (hit) return context.redirect(hit.toPath + context.url.search, hit.code);
85
+ } catch {
86
+ // a broken redirect lookup must never break the 404 itself
87
+ }
88
+ // admin-authored rules win; config-derived legacy moves are the fallback
89
+ const legacy = legacyContentRedirect(pathname);
90
+ if (legacy) return context.redirect(legacy + context.url.search, 301);
91
+ }
92
+
93
+ return response;
94
+ });
95
+
96
+ let redirectCache: { at: number; map: Map<string, { toPath: string; code: 301 | 302 }> } | null = null;
97
+
98
+ // called by the admin mutations so new rules fire immediately in this isolate
99
+ export function invalidateRedirectCache() {
100
+ redirectCache = null;
101
+ }
102
+
103
+ async function getRedirects(locals: App.Locals, now: number) {
104
+ if (redirectCache && now - redirectCache.at < 60_000) return redirectCache.map;
105
+ const repo = await getRepo(locals);
106
+ const map = new Map((await repo.listRedirects()).map((r) => [r.fromPath, { toPath: r.toPath, code: r.code }]));
107
+ redirectCache = { at: now, map };
108
+ return map;
109
+ }
@@ -0,0 +1,43 @@
1
+ ---
2
+ import Site from '../layouts/Site.astro';
3
+ import WidgetRenderer from '../components/WidgetRenderer.astro';
4
+ import { getRepo } from '../lib/repo';
5
+ import { verifySessionToken, SESSION_COOKIE } from '../lib/auth';
6
+ import { expandDynamicBlocks } from '../lib/dynamicBlocks';
7
+ import { highlightCodeHtml } from '../lib/highlightCode';
8
+ import { resolveDesign } from '../lib/theme';
9
+
10
+ const { slug } = Astro.params;
11
+ const repo = await getRepo(Astro.locals);
12
+ const page = slug ? await repo.getPageBySlug(slug) : null;
13
+
14
+ // Drafts are visible only to a logged-in admin (doubles as draft preview).
15
+ const isAdmin = verifySessionToken(Astro.cookies.get(SESSION_COOKIE)?.value);
16
+ if (!page || (page.status !== 'published' && !isAdmin)) {
17
+ return new Response(null, { status: 404, statusText: 'Not found' });
18
+ }
19
+ const bodyHtml = page.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo)) : '';
20
+ const settings = await repo.getSettings();
21
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
+ const PageTemplate = (page.template ? resolveDesign(settings).theme.pageTemplates[page.template] : null) as any;
23
+ ---
24
+
25
+ <Site
26
+ title={page.seoTitle || page.title}
27
+ exactTitle={Boolean(page.seoTitle)}
28
+ description={page.seoDesc || undefined}
29
+ noindex={page.status !== 'published'}
30
+ >
31
+ {page.status !== 'published' && (
32
+ <p style="background:#fff3cd;border:1px solid #ffe08a;border-radius:8px;padding:8px 14px;font-size:14px;">
33
+ Draft preview — only you can see this page.
34
+ </p>
35
+ )}
36
+ {PageTemplate ? (
37
+ <PageTemplate page={page} html={bodyHtml} settings={settings} />
38
+ ) : page.bodyHtml ? (
39
+ <article class="page-body" set:html={bodyHtml} />
40
+ ) : (
41
+ <WidgetRenderer pageId={page.id} blocks={page.blocks} />
42
+ )}
43
+ </Site>
@@ -0,0 +1,56 @@
1
+ ---
2
+ import { getRepo } from '../../../../lib/repo';
3
+ import { renderMarkdown } from '../../../../lib/markdown';
4
+ import { createPreviewToken } from '../../../../lib/preview';
5
+ import { resolveDesign } from '../../../../lib/theme';
6
+ import { env } from '../../../../lib/env';
7
+ import { BRAND } from '../../../../lib/brand';
8
+ import { CONTENT } from '../../../../lib/content';
9
+ import PostEditor from '../../../../components/editor/PostEditor';
10
+ import '../../../../styles/admin.css';
11
+ import '../../../../styles/editor.css';
12
+
13
+ // Shared editor page for every collection. The editor itself reads the
14
+ // collection off the post row; the URL segment is canonicalized so links
15
+ // always point at the collection the post actually lives in.
16
+ const key = Astro.params.collection!;
17
+ if (!CONTENT.collections[key]) return new Response(null, { status: 404, statusText: 'Not found' });
18
+
19
+ const repo = await getRepo(Astro.locals);
20
+ const id = Astro.params.id!;
21
+ const post = await repo.getPost(id);
22
+ if (!post) return new Response(null, { status: 404 });
23
+ if (post.collection !== key) return Astro.redirect(`/admin/c/${post.collection}/${post.id}`);
24
+
25
+ // Canonical content is the TipTap doc (as HTML). Legacy markdown posts are
26
+ // converted on first open and become rich content on next save.
27
+ const initialHTML = post.bodyHtml || (post.body ? renderMarkdown(post.body) : '');
28
+ const previewPath = `/preview/post/${post.id}`;
29
+ const sharePath = `${previewPath}?token=${createPreviewToken('post', post.id)}`;
30
+ const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
31
+ const settings = await repo.getSettings();
32
+ const templates = Object.keys(resolveDesign(settings).theme.postTemplates);
33
+ ---
34
+
35
+ <!doctype html>
36
+ <html lang="en">
37
+ <head>
38
+ <meta charset="utf-8" />
39
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
40
+ <meta name="robots" content="noindex" />
41
+ <title>{post.title || 'Untitled'} · {BRAND.name} editor</title>
42
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
43
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
44
+ </head>
45
+ <body>
46
+ <PostEditor
47
+ client:only="react"
48
+ post={post}
49
+ initialHTML={initialHTML}
50
+ unsplashEnabled={unsplashEnabled}
51
+ previewPath={previewPath}
52
+ sharePath={sharePath}
53
+ templates={templates}
54
+ />
55
+ </body>
56
+ </html>