@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.
- package/cli.mjs +75 -0
- package/integration.mjs +286 -0
- package/package.json +89 -0
- package/src/actions/index.ts +502 -0
- package/src/components/DesignManager.tsx +542 -0
- package/src/components/LinksManager.tsx +694 -0
- package/src/components/Modal.astro +65 -0
- package/src/components/NavManager.tsx +168 -0
- package/src/components/WidgetRenderer.astro +84 -0
- package/src/components/builder/PageBuilder.tsx +575 -0
- package/src/components/editor/ImagePicker.tsx +215 -0
- package/src/components/editor/PostEditor.tsx +1011 -0
- package/src/components/editor/PreviewOverlay.tsx +77 -0
- package/src/components/editor/extensions/calendar.tsx +230 -0
- package/src/components/editor/extensions/cards.tsx +488 -0
- package/src/components/editor/extensions/codeBlock.tsx +70 -0
- package/src/components/editor/extensions/infoCards.tsx +270 -0
- package/src/components/editor/extensions/mediaCards.tsx +376 -0
- package/src/components/editor/extensions/postList.tsx +130 -0
- package/src/components/editor/extensions/slash.tsx +250 -0
- package/src/components/editor/extensions/tableTyping.ts +63 -0
- package/src/components/editor/imaging.ts +146 -0
- package/src/components/media/CropModal.tsx +229 -0
- package/src/components/media/MediaLibrary.tsx +295 -0
- package/src/layouts/Admin.astro +119 -0
- package/src/layouts/Site.astro +218 -0
- package/src/lib/auth.ts +94 -0
- package/src/lib/backup/unzip.ts +38 -0
- package/src/lib/backup/zip.ts +105 -0
- package/src/lib/blocksToHtml.ts +68 -0
- package/src/lib/brand.ts +26 -0
- package/src/lib/calendar/ics.ts +402 -0
- package/src/lib/calendar/render.ts +275 -0
- package/src/lib/calendar/src.ts +68 -0
- package/src/lib/content.ts +53 -0
- package/src/lib/dates.ts +34 -0
- package/src/lib/dynamicBlocks.ts +115 -0
- package/src/lib/env.ts +6 -0
- package/src/lib/highlightCode.ts +44 -0
- package/src/lib/i18n.ts +40 -0
- package/src/lib/markdown.ts +7 -0
- package/src/lib/media/store.ts +95 -0
- package/src/lib/net.ts +51 -0
- package/src/lib/preview.ts +28 -0
- package/src/lib/repo/d1.ts +44 -0
- package/src/lib/repo/index.ts +23 -0
- package/src/lib/repo/migrations.ts +231 -0
- package/src/lib/repo/search.ts +14 -0
- package/src/lib/repo/sql.ts +721 -0
- package/src/lib/repo/sqlite.ts +43 -0
- package/src/lib/repo/types.ts +132 -0
- package/src/lib/schema.ts +286 -0
- package/src/lib/seo.ts +37 -0
- package/src/lib/theme.ts +206 -0
- package/src/lib/widgets.ts +108 -0
- package/src/middleware.ts +109 -0
- package/src/pages/[...slug].astro +43 -0
- package/src/pages/admin/c/[collection]/[id].astro +56 -0
- package/src/pages/admin/c/[collection]/index.astro +163 -0
- package/src/pages/admin/design.astro +42 -0
- package/src/pages/admin/index.astro +145 -0
- package/src/pages/admin/links.astro +21 -0
- package/src/pages/admin/login.astro +83 -0
- package/src/pages/admin/logout.ts +7 -0
- package/src/pages/admin/media.astro +13 -0
- package/src/pages/admin/navigation.astro +17 -0
- package/src/pages/admin/pages/[id].astro +49 -0
- package/src/pages/admin/pages/index.astro +100 -0
- package/src/pages/admin/posts/[id].astro +6 -0
- package/src/pages/admin/posts/index.astro +7 -0
- package/src/pages/admin/settings.astro +512 -0
- package/src/pages/admin/tags.astro +101 -0
- package/src/pages/admin/users.astro +149 -0
- package/src/pages/api/backup.ts +63 -0
- package/src/pages/api/drafts.ts +106 -0
- package/src/pages/api/media/upload.ts +89 -0
- package/src/pages/api/restore.ts +102 -0
- package/src/pages/author/[slug].astro +66 -0
- package/src/pages/blog/[slug].astro +240 -0
- package/src/pages/blog/index.astro +51 -0
- package/src/pages/blog/tag/[slug].astro +83 -0
- package/src/pages/embed/[pageId]/[blockId].ts +45 -0
- package/src/pages/go/[id].ts +10 -0
- package/src/pages/index.astro +52 -0
- package/src/pages/links.astro +110 -0
- package/src/pages/llms.txt.ts +47 -0
- package/src/pages/media/[...key].ts +18 -0
- package/src/pages/preview/[type]/[id].astro +52 -0
- package/src/pages/robots.txt.ts +28 -0
- package/src/pages/rss.xml.ts +58 -0
- package/src/pages/search.astro +83 -0
- package/src/pages/sitemap.xml.ts +55 -0
- package/src/styles/admin.css +652 -0
- package/src/styles/builder.css +121 -0
- package/src/styles/cards.css +176 -0
- package/src/styles/editor.css +596 -0
- package/src/styles/site.css +427 -0
- package/src/themes/editorial/templates/home.astro +127 -0
- package/src/themes/editorial/templates/post-minimal.astro +50 -0
- package/src/themes/editorial/theme.css +108 -0
- package/src/themes/editorial/theme.json +20 -0
- package/src/themes/field/theme.json +17 -0
- package/src/themes/ink/theme.json +17 -0
- package/src/themes/night/theme.css +7 -0
- package/src/themes/night/theme.json +17 -0
- package/src/virtual.d.ts +25 -0
- package/src/worker.ts +28 -0
package/src/lib/theme.ts
ADDED
|
@@ -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>
|