@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
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Calendar source normalization: turn whatever the author pastes — a Google
|
|
2
|
+
// Calendar embed/share URL, a bare calendar ID, a webcal:// link, any .ics
|
|
3
|
+
// feed — into a canonical ICS URL, plus the reverse (a human-facing Google
|
|
4
|
+
// Calendar page for the widget footer). Pure string logic, imported by both
|
|
5
|
+
// the editor card (client) and the server-side fetcher.
|
|
6
|
+
|
|
7
|
+
const googleIcs = (id: string) =>
|
|
8
|
+
`https://calendar.google.com/calendar/ical/${encodeURIComponent(id)}/public/basic.ics`;
|
|
9
|
+
|
|
10
|
+
export function normalizeCalendarSrc(raw: string): string | null {
|
|
11
|
+
const input = raw.trim();
|
|
12
|
+
if (!input) return null;
|
|
13
|
+
|
|
14
|
+
// bare calendar ID (email-shaped): church@gmail.com, xyz@group.calendar.google.com
|
|
15
|
+
if (!input.includes('/') && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input)) return googleIcs(input);
|
|
16
|
+
|
|
17
|
+
let url: URL;
|
|
18
|
+
try {
|
|
19
|
+
url = new URL(input.replace(/^webcal:\/\//i, 'https://'));
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
|
|
24
|
+
|
|
25
|
+
if (url.hostname === 'calendar.google.com') {
|
|
26
|
+
if (url.pathname.includes('/ical/')) return url.href; // already a feed
|
|
27
|
+
const src = url.searchParams.get('src');
|
|
28
|
+
if (src) return googleIcs(src);
|
|
29
|
+
const cid = url.searchParams.get('cid'); // share links: base64url-encoded calendar ID
|
|
30
|
+
if (cid) {
|
|
31
|
+
try {
|
|
32
|
+
return googleIcs(atob(cid.replace(/-/g, '+').replace(/_/g, '/')));
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// any other host must point at an actual ICS document
|
|
41
|
+
if (/\.ics$/i.test(url.pathname) || url.pathname.includes('/ical/')) return url.href;
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Google ICS feed URL → the calendar's public embed page, for the footer
|
|
46
|
+
// link. Non-Google feeds have no canonical page; callers hide the link.
|
|
47
|
+
export function calendarPageUrl(icsUrl: string): string {
|
|
48
|
+
const m = icsUrl.match(/^https:\/\/calendar\.google\.com\/calendar\/ical\/([^/]+)\//);
|
|
49
|
+
if (!m) return '';
|
|
50
|
+
return `https://calendar.google.com/calendar/embed?src=${m[1]}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Short human label for a source, used in the editor's source chips.
|
|
54
|
+
export function calendarSrcLabel(icsUrl: string): string {
|
|
55
|
+
const m = icsUrl.match(/^https:\/\/calendar\.google\.com\/calendar\/ical\/([^/]+)\//);
|
|
56
|
+
if (m) {
|
|
57
|
+
try {
|
|
58
|
+
return decodeURIComponent(m[1]);
|
|
59
|
+
} catch {
|
|
60
|
+
return m[1];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
return new URL(icsUrl).hostname;
|
|
65
|
+
} catch {
|
|
66
|
+
return icsUrl;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Content collections: user-visible naming and public URL structure,
|
|
2
|
+
// configured per project via the integration's options in astro.config:
|
|
3
|
+
// acms({ content: { collections: { posts: {...}, docs: { base: '/docs' } } } })
|
|
4
|
+
// integration.mjs validates and fills defaults before serializing, so the
|
|
5
|
+
// values here are always complete; the fallback only guards stale builds.
|
|
6
|
+
// Collection keys are permanent internal identifiers (DB rows, backup format,
|
|
7
|
+
// /admin/c/<key>) — same rule as brand.ts: renaming protocol identifiers
|
|
8
|
+
// would invalidate existing sessions and backups. Only labels and bases are
|
|
9
|
+
// renameable.
|
|
10
|
+
import { userConfig } from 'virtual:acms-config';
|
|
11
|
+
|
|
12
|
+
export interface CollectionConfig {
|
|
13
|
+
singular: string;
|
|
14
|
+
plural: string;
|
|
15
|
+
base: string;
|
|
16
|
+
redirectFrom: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const FALLBACK_POSTS: CollectionConfig = { singular: 'Post', plural: 'Posts', base: '/blog', redirectFrom: [] };
|
|
20
|
+
|
|
21
|
+
const collections: Record<string, CollectionConfig> =
|
|
22
|
+
userConfig.content?.collections ?? { posts: FALLBACK_POSTS };
|
|
23
|
+
|
|
24
|
+
export const CONTENT = {
|
|
25
|
+
collections,
|
|
26
|
+
// the built-in collection, always present — most call sites only need this
|
|
27
|
+
posts: collections.posts ?? FALLBACK_POSTS,
|
|
28
|
+
tags: { base: userConfig.content?.tags?.base ?? '/tag' },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const collectionUrl = (key: string, slug?: string) => {
|
|
32
|
+
const base = (collections[key] ?? CONTENT.posts).base;
|
|
33
|
+
return slug ? `${base}/${slug}` : base;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Which collection a public URL belongs to — injected routes share one page
|
|
37
|
+
// file per shape (blog/index.astro, blog/[slug].astro), and Astro's
|
|
38
|
+
// injectRoute can't attach props, so pages resolve their collection from the
|
|
39
|
+
// request path. Longest base wins so nested bases can't shadow each other.
|
|
40
|
+
export function collectionFromPath(pathname: string): (CollectionConfig & { key: string }) | null {
|
|
41
|
+
let best: (CollectionConfig & { key: string }) | null = null;
|
|
42
|
+
for (const [key, c] of Object.entries(collections)) {
|
|
43
|
+
if (pathname === c.base || pathname.startsWith(`${c.base}/`)) {
|
|
44
|
+
if (!best || c.base.length > best.base.length) best = { key, ...c };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return best;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Aliases for the built-in collection — the bulk of call sites.
|
|
51
|
+
export const postsIndexUrl = () => CONTENT.posts.base;
|
|
52
|
+
export const postUrl = (slug: string) => `${CONTENT.posts.base}/${slug}`;
|
|
53
|
+
export const tagUrl = (slug: string) => `${CONTENT.tags.base}/${slug}`;
|
package/src/lib/dates.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Settings } from './schema';
|
|
2
|
+
|
|
3
|
+
// One date formatter for every public surface, honoring the publication
|
|
4
|
+
// timezone + locale settings (Settings → "Timezone & language"). Before this,
|
|
5
|
+
// each page hardcoded its own locale (a mix of en-US and en-GB).
|
|
6
|
+
|
|
7
|
+
const FALLBACK_LOCALE = 'en-US';
|
|
8
|
+
|
|
9
|
+
export function siteLocale(settings: Pick<Settings, 'locale'>): string {
|
|
10
|
+
return settings.locale || FALLBACK_LOCALE;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function siteLang(settings: Pick<Settings, 'locale'>): string {
|
|
14
|
+
return siteLocale(settings).split('-')[0] || 'en';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function formatDate(
|
|
18
|
+
ts: number | null,
|
|
19
|
+
settings: Pick<Settings, 'timezone' | 'locale'>,
|
|
20
|
+
style: 'long' | 'short' = 'long'
|
|
21
|
+
): string {
|
|
22
|
+
if (!ts) return '';
|
|
23
|
+
try {
|
|
24
|
+
return new Date(ts).toLocaleDateString(siteLocale(settings), {
|
|
25
|
+
year: 'numeric',
|
|
26
|
+
month: style === 'long' ? 'long' : 'short',
|
|
27
|
+
day: 'numeric',
|
|
28
|
+
...(settings.timezone ? { timeZone: settings.timezone } : {}),
|
|
29
|
+
});
|
|
30
|
+
} catch {
|
|
31
|
+
// bad timezone/locale string: never break rendering over a setting
|
|
32
|
+
return new Date(ts).toLocaleDateString(FALLBACK_LOCALE, { year: 'numeric', month: 'long', day: 'numeric' });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { ContentRepository } from './repo';
|
|
2
|
+
import type { Post, Settings } from './schema';
|
|
3
|
+
import { slugify } from './schema';
|
|
4
|
+
import { formatDate } from './dates';
|
|
5
|
+
import { CONTENT, collectionUrl } from './content';
|
|
6
|
+
import { renderCalendarBlock } from './calendar/render';
|
|
7
|
+
|
|
8
|
+
// Dynamic blocks: bodyHtml is a save-time cache, so cards whose content must
|
|
9
|
+
// stay fresh (post lists, calendars) serialize as placeholder divs and get
|
|
10
|
+
// expanded here at request time, right before set:html. Same trusted-author
|
|
11
|
+
// model as the rest of bodyHtml.
|
|
12
|
+
//
|
|
13
|
+
// <div data-dyn="post-list" data-source="tag" data-tag="projects"
|
|
14
|
+
// data-layout="grid" data-limit="3" data-show-image="1" data-show-excerpt="1"></div>
|
|
15
|
+
// <div data-dyn="calendar" data-sources="[…ics urls]" data-view="month"
|
|
16
|
+
// data-title="…" data-subtitle="…" data-limit="6" data-open-link="1"></div>
|
|
17
|
+
|
|
18
|
+
const PLACEHOLDER = /<div[^>]*data-dyn="(?:post-list|calendar)"[^>]*>\s*<\/div>/g;
|
|
19
|
+
|
|
20
|
+
const esc = (s: string) =>
|
|
21
|
+
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
22
|
+
|
|
23
|
+
const attr = (tag: string, name: string) => {
|
|
24
|
+
const m = tag.match(new RegExp(`data-${name}="([^"]*)"`));
|
|
25
|
+
return m ? m[1].replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') : '';
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const readMins = (p: Post) =>
|
|
29
|
+
Math.max(1, Math.round((p.bodyHtml || p.body).replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length / 220));
|
|
30
|
+
|
|
31
|
+
function renderList(posts: Post[], layout: string, showImage: boolean, showExcerpt: boolean, settings: Settings, plural: string): string {
|
|
32
|
+
if (!posts.length) {
|
|
33
|
+
return `<section class="dyn-postlist is-empty"><p>No ${esc(plural.toLowerCase())} yet.</p></section>`;
|
|
34
|
+
}
|
|
35
|
+
const items = posts
|
|
36
|
+
.map((p) => {
|
|
37
|
+
const url = collectionUrl(p.collection, esc(p.slug));
|
|
38
|
+
const img =
|
|
39
|
+
showImage && p.cover
|
|
40
|
+
? `<a class="dp-thumb" href="${url}"><img src="${esc(p.cover)}" alt="${esc(p.coverAlt)}" loading="lazy" /></a>`
|
|
41
|
+
: '';
|
|
42
|
+
const excerpt = showExcerpt && p.excerpt ? `<p class="dp-excerpt">${esc(p.excerpt)}</p>` : '';
|
|
43
|
+
return (
|
|
44
|
+
`<article class="dp-item">${img}<div class="dp-body">` +
|
|
45
|
+
`<div class="dp-meta">${[formatDate(p.publishedAt, settings, 'short'), `${readMins(p)} min read`].filter(Boolean).join(' · ')}</div>` +
|
|
46
|
+
`<h3 class="dp-title"><a href="${url}">${esc(p.title)}</a></h3>` +
|
|
47
|
+
`${excerpt}</div></article>`
|
|
48
|
+
);
|
|
49
|
+
})
|
|
50
|
+
.join('');
|
|
51
|
+
return `<section class="dyn-postlist" data-layout="${esc(layout)}">${items}</section>`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function calendarAttrs(tag: string) {
|
|
55
|
+
let sources: string[] = [];
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(attr(tag, 'sources') || '[]');
|
|
58
|
+
if (Array.isArray(parsed)) sources = parsed.filter((s): s is string => typeof s === 'string' && s.startsWith('http'));
|
|
59
|
+
} catch {
|
|
60
|
+
/* malformed attr: render the empty state rather than break the page */
|
|
61
|
+
}
|
|
62
|
+
const view = ['month', 'week', 'upcoming'].includes(attr(tag, 'view')) ? attr(tag, 'view') : 'month';
|
|
63
|
+
return {
|
|
64
|
+
sources,
|
|
65
|
+
view: view as 'month' | 'week' | 'upcoming',
|
|
66
|
+
title: attr(tag, 'title'),
|
|
67
|
+
subtitle: attr(tag, 'subtitle'),
|
|
68
|
+
limit: Number(attr(tag, 'limit')) || 6,
|
|
69
|
+
showOpenLink: attr(tag, 'open-link') !== '0',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function expandDynamicBlocks(html: string, repo: ContentRepository): Promise<string> {
|
|
74
|
+
const hasPostList = html.includes('data-dyn="post-list"');
|
|
75
|
+
const hasCalendar = html.includes('data-dyn="calendar"');
|
|
76
|
+
if (!hasPostList && !hasCalendar) return html;
|
|
77
|
+
const [posts, settings] = await Promise.all([
|
|
78
|
+
hasPostList ? repo.listPublishedPosts() : Promise.resolve([] as Post[]), // newest first; shared by all blocks on the page
|
|
79
|
+
repo.getSettings(),
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
// String.replace can't await, so calendar blocks (which fetch feeds) are
|
|
83
|
+
// rendered up front, then spliced in during the synchronous pass.
|
|
84
|
+
const calendarHtml = new Map<string, string>();
|
|
85
|
+
if (hasCalendar) {
|
|
86
|
+
const tags = [...new Set((html.match(PLACEHOLDER) ?? []).filter((t) => t.includes('data-dyn="calendar"')))];
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
await Promise.all(
|
|
89
|
+
tags.map(async (tag) => calendarHtml.set(tag, await renderCalendarBlock(calendarAttrs(tag), settings, now)))
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return html.replace(PLACEHOLDER, (tag) => {
|
|
94
|
+
if (tag.includes('data-dyn="calendar"')) return calendarHtml.get(tag) ?? '';
|
|
95
|
+
const source = attr(tag, 'source') || 'latest';
|
|
96
|
+
const tagName = attr(tag, 'tag');
|
|
97
|
+
// which collection the block draws from; 'posts' unless the card says otherwise
|
|
98
|
+
const collection = attr(tag, 'collection') || 'posts';
|
|
99
|
+
const layout = ['list', 'grid', 'feature'].includes(attr(tag, 'layout')) ? attr(tag, 'layout') : 'list';
|
|
100
|
+
const limit = Math.min(12, Math.max(1, Number(attr(tag, 'limit')) || 3));
|
|
101
|
+
const showImage = attr(tag, 'show-image') !== '0';
|
|
102
|
+
const showExcerpt = attr(tag, 'show-excerpt') !== '0';
|
|
103
|
+
let filtered = posts.filter((p) => p.collection === collection);
|
|
104
|
+
if (source === 'featured') filtered = filtered.filter((p) => p.featured);
|
|
105
|
+
if (source === 'tag' && tagName) {
|
|
106
|
+
filtered = filtered.filter((p) =>
|
|
107
|
+
p.tags.some((t) => t.toLowerCase() === tagName.toLowerCase() || slugify(t) === slugify(tagName))
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return renderList(
|
|
111
|
+
filtered.slice(0, limit), layout, showImage, showExcerpt, settings,
|
|
112
|
+
CONTENT.collections[collection]?.plural ?? CONTENT.posts.plural
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
}
|
package/src/lib/env.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createLowlight, common } from 'lowlight';
|
|
2
|
+
import { toHtml } from 'hast-util-to-html';
|
|
3
|
+
|
|
4
|
+
// Server-side syntax highlighting for saved post/page HTML. Both the TipTap
|
|
5
|
+
// code block (extensions/codeBlock.tsx) and marked emit
|
|
6
|
+
// `<pre><code class="language-x">…</code></pre>` with entity-escaped text, so
|
|
7
|
+
// one regex pass covers the editor and legacy-markdown pipelines. Runs on
|
|
8
|
+
// Workers — lowlight is pure JS. The published page ships no highlighter JS;
|
|
9
|
+
// token colors come from site.css.
|
|
10
|
+
|
|
11
|
+
const lowlight = createLowlight(common);
|
|
12
|
+
|
|
13
|
+
// Text inside <code> was escaped by the serializer; & must be decoded last.
|
|
14
|
+
const decode = (s: string) =>
|
|
15
|
+
s
|
|
16
|
+
.replace(/</g, '<')
|
|
17
|
+
.replace(/>/g, '>')
|
|
18
|
+
.replace(/"/g, '"')
|
|
19
|
+
.replace(/�?39;/g, "'")
|
|
20
|
+
.replace(/'/gi, "'")
|
|
21
|
+
.replace(/&/g, '&');
|
|
22
|
+
|
|
23
|
+
// grammar name → reader-facing badge label
|
|
24
|
+
const BADGE: Record<string, string> = {
|
|
25
|
+
xml: 'html',
|
|
26
|
+
javascript: 'js',
|
|
27
|
+
typescript: 'ts',
|
|
28
|
+
python: 'py',
|
|
29
|
+
markdown: 'md',
|
|
30
|
+
csharp: 'c#',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function highlightCodeHtml(html: string): string {
|
|
34
|
+
if (!html.includes('<pre><code class="language-')) return html;
|
|
35
|
+
return html.replace(
|
|
36
|
+
/<pre><code class="language-([\w#+-]+)">([\s\S]*?)<\/code><\/pre>/g,
|
|
37
|
+
(match, lang: string, code: string) => {
|
|
38
|
+
if (!lowlight.registered(lang)) return match;
|
|
39
|
+
const tree = lowlight.highlight(lang, decode(code));
|
|
40
|
+
// data-language feeds the pure-CSS corner badge on the public page
|
|
41
|
+
return `<pre data-language="${BADGE[lang] ?? lang}"><code class="hljs language-${lang}">${toHtml(tree)}</code></pre>`;
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
}
|
package/src/lib/i18n.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Site languages, configured per project via the integration's options:
|
|
2
|
+
// acms({ i18n: { locales: ['en', 'es'], defaultLocale: 'en' } })
|
|
3
|
+
// integration.mjs validates before serializing (locale format, URL prefix
|
|
4
|
+
// collisions), so the values here are always trustworthy. The default locale
|
|
5
|
+
// owns the root URL; every other locale is served under /<locale>. On page
|
|
6
|
+
// rows the default locale is stored as '' (the pre-i18n value), so existing
|
|
7
|
+
// databases need no data migration.
|
|
8
|
+
import { userConfig } from 'virtual:acms-config';
|
|
9
|
+
|
|
10
|
+
const cfg = userConfig.i18n ?? { locales: [], defaultLocale: '' };
|
|
11
|
+
|
|
12
|
+
export const I18N = {
|
|
13
|
+
locales: cfg.locales,
|
|
14
|
+
defaultLocale: cfg.defaultLocale,
|
|
15
|
+
// one configured locale is legal but changes nothing user-visible
|
|
16
|
+
enabled: cfg.locales.length > 1,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Which locale a public URL belongs to — same path-derivation trick as
|
|
20
|
+
// collectionFromPath: injected routes can't carry props, so /es resolves its
|
|
21
|
+
// locale from the request path. Non-home paths fall through to the default.
|
|
22
|
+
export function localeFromPath(pathname: string): string {
|
|
23
|
+
for (const l of I18N.locales) {
|
|
24
|
+
if (l === I18N.defaultLocale) continue;
|
|
25
|
+
if (pathname === `/${l}` || pathname.startsWith(`/${l}/`)) return l;
|
|
26
|
+
}
|
|
27
|
+
return I18N.defaultLocale;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// the home URL for a locale: '/' for the default, '/<locale>' otherwise
|
|
31
|
+
export function localeHomeUrl(locale: string): string {
|
|
32
|
+
return !locale || locale === I18N.defaultLocale ? '/' : `/${locale}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// page rows store '' for the default locale; normalize admin input to that
|
|
36
|
+
// convention and drop values that aren't configured locales
|
|
37
|
+
export function normalizePageLocale(locale: string): string {
|
|
38
|
+
if (!locale || locale === I18N.defaultLocale) return '';
|
|
39
|
+
return I18N.locales.includes(locale) ? locale : '';
|
|
40
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { marked } from 'marked';
|
|
2
|
+
|
|
3
|
+
// Single-admin CMS: content authors are trusted, so raw HTML in markdown is
|
|
4
|
+
// allowed (matches the trusted-widget model). Revisit if multi-user lands.
|
|
5
|
+
export function renderMarkdown(md: string): string {
|
|
6
|
+
return marked.parse(md, { async: false }) as string;
|
|
7
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Binary storage behind the media rows — R2 on Workers, ./data/media on
|
|
2
|
+
// plain Node. Mirrors the repo adapter pattern: routes only ever import
|
|
3
|
+
// getMediaStore().
|
|
4
|
+
|
|
5
|
+
export interface StoredObject {
|
|
6
|
+
body: BodyInit;
|
|
7
|
+
contentType: string;
|
|
8
|
+
size?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MediaStore {
|
|
12
|
+
put(key: string, data: ArrayBuffer, contentType: string): Promise<void>;
|
|
13
|
+
get(key: string): Promise<StoredObject | null>;
|
|
14
|
+
delete(key: string): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface R2Bucket {
|
|
18
|
+
put(key: string, value: ArrayBuffer, opts?: { httpMetadata?: { contentType?: string } }): Promise<unknown>;
|
|
19
|
+
get(key: string): Promise<{ body: ReadableStream; httpMetadata?: { contentType?: string }; size: number } | null>;
|
|
20
|
+
delete(key: string): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class R2Store implements MediaStore {
|
|
24
|
+
constructor(private bucket: R2Bucket) {}
|
|
25
|
+
async put(key: string, data: ArrayBuffer, contentType: string) {
|
|
26
|
+
await this.bucket.put(key, data, { httpMetadata: { contentType } });
|
|
27
|
+
}
|
|
28
|
+
async get(key: string) {
|
|
29
|
+
const obj = await this.bucket.get(key);
|
|
30
|
+
if (!obj) return null;
|
|
31
|
+
return {
|
|
32
|
+
body: obj.body,
|
|
33
|
+
contentType: obj.httpMetadata?.contentType ?? contentTypeFromKey(key),
|
|
34
|
+
size: obj.size,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
async delete(key: string) {
|
|
38
|
+
await this.bucket.delete(key);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
class FsStore implements MediaStore {
|
|
43
|
+
private root = './data/media';
|
|
44
|
+
private safe(key: string) {
|
|
45
|
+
if (key.includes('..') || key.startsWith('/')) throw new Error('bad media key');
|
|
46
|
+
return `${this.root}/${key}`;
|
|
47
|
+
}
|
|
48
|
+
async put(key: string, data: ArrayBuffer, _contentType: string) {
|
|
49
|
+
const fs = await import(/* @vite-ignore */ 'node:fs/promises');
|
|
50
|
+
const path = this.safe(key);
|
|
51
|
+
await fs.mkdir(path.slice(0, path.lastIndexOf('/')), { recursive: true });
|
|
52
|
+
await fs.writeFile(path, new Uint8Array(data));
|
|
53
|
+
}
|
|
54
|
+
async get(key: string) {
|
|
55
|
+
const fs = await import(/* @vite-ignore */ 'node:fs/promises');
|
|
56
|
+
try {
|
|
57
|
+
const buf = await fs.readFile(this.safe(key));
|
|
58
|
+
return {
|
|
59
|
+
body: new Uint8Array(buf).buffer as ArrayBuffer,
|
|
60
|
+
contentType: contentTypeFromKey(key),
|
|
61
|
+
size: buf.byteLength,
|
|
62
|
+
};
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async delete(key: string) {
|
|
68
|
+
const fs = await import(/* @vite-ignore */ 'node:fs/promises');
|
|
69
|
+
await fs.rm(this.safe(key), { force: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function contentTypeFromKey(key: string): string {
|
|
74
|
+
const ext = key.slice(key.lastIndexOf('.') + 1).toLowerCase();
|
|
75
|
+
const map: Record<string, string> = {
|
|
76
|
+
webp: 'image/webp', jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
|
|
77
|
+
gif: 'image/gif', svg: 'image/svg+xml', avif: 'image/avif',
|
|
78
|
+
pdf: 'application/pdf', zip: 'application/zip', json: 'application/json',
|
|
79
|
+
txt: 'text/plain', csv: 'text/csv', md: 'text/markdown',
|
|
80
|
+
doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
81
|
+
xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
82
|
+
ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
83
|
+
epub: 'application/epub+zip',
|
|
84
|
+
};
|
|
85
|
+
return map[ext] ?? 'application/octet-stream';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let store: MediaStore | null = null;
|
|
89
|
+
|
|
90
|
+
export function getMediaStore(locals?: unknown): MediaStore {
|
|
91
|
+
if (store) return store;
|
|
92
|
+
const bucket = (locals as { runtime?: { env?: { MEDIA?: unknown } } } | undefined)?.runtime?.env?.MEDIA;
|
|
93
|
+
store = bucket ? new R2Store(bucket as R2Bucket) : new FsStore();
|
|
94
|
+
return store;
|
|
95
|
+
}
|
package/src/lib/net.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// SSRF guard shared by every server-side fetch of a user-supplied URL (the
|
|
2
|
+
// bookmark card's metadata scrape, the calendar card's ICS feed): block
|
|
3
|
+
// loopback, RFC1918, CGNAT, link-local and IPv6 ULA hosts so a pasted URL —
|
|
4
|
+
// or a redirect hop — can never reach the Worker's own network.
|
|
5
|
+
export function isPrivateHost(hostname: string): boolean {
|
|
6
|
+
const h = hostname.toLowerCase();
|
|
7
|
+
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) return true;
|
|
8
|
+
const v4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
9
|
+
if (v4) {
|
|
10
|
+
const [a, b] = [Number(v4[1]), Number(v4[2])];
|
|
11
|
+
return (
|
|
12
|
+
a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) ||
|
|
13
|
+
(a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
if (h.includes(':')) return h === '::1' || h.startsWith('fe80') || h.startsWith('fc') || h.startsWith('fd') || h.startsWith('[');
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Fetch that follows redirects manually so EVERY hop is re-checked against the
|
|
21
|
+
// private-host guard (a public URL 302ing to 127.0.0.1 or a cloud metadata IP
|
|
22
|
+
// must not slip through). Returns null on unreachable/blocked.
|
|
23
|
+
export async function fetchPublicUrl(
|
|
24
|
+
url: string,
|
|
25
|
+
init: { accept: string; userAgent: string; timeoutMs?: number }
|
|
26
|
+
): Promise<{ res: Response; finalUrl: string } | null> {
|
|
27
|
+
let current = url;
|
|
28
|
+
for (let hop = 0; hop < 5; hop++) {
|
|
29
|
+
let u: URL;
|
|
30
|
+
try {
|
|
31
|
+
u = new URL(current);
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (!/^https?:$/.test(u.protocol) || isPrivateHost(u.hostname)) return null;
|
|
36
|
+
const res = await fetch(u.href, {
|
|
37
|
+
redirect: 'manual',
|
|
38
|
+
signal: AbortSignal.timeout(init.timeoutMs ?? 8000),
|
|
39
|
+
headers: { 'User-Agent': init.userAgent, Accept: init.accept },
|
|
40
|
+
}).catch(() => null);
|
|
41
|
+
if (!res) return null;
|
|
42
|
+
if (res.status >= 300 && res.status < 400) {
|
|
43
|
+
const loc = res.headers.get('location');
|
|
44
|
+
if (!loc) return null;
|
|
45
|
+
current = new URL(loc, u.href).href;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
return { res, finalUrl: current };
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { env } from './env';
|
|
3
|
+
|
|
4
|
+
// Signed, shareable preview links: /preview/<type>/<id>?token=<exp>.<sig>
|
|
5
|
+
// Works logged-out (send to a reviewer), expires after a week by default.
|
|
6
|
+
|
|
7
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
function sig(payload: string): string {
|
|
10
|
+
const secret = env('SESSION_SECRET');
|
|
11
|
+
if (!secret) throw new Error('SESSION_SECRET is not set — add it to .env');
|
|
12
|
+
return createHmac('sha256', secret).update(`preview.${payload}`).digest('hex');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createPreviewToken(type: 'post' | 'page', id: string, ttlMs = WEEK_MS): string {
|
|
16
|
+
const exp = Date.now() + ttlMs;
|
|
17
|
+
return `${exp}.${sig(`${type}.${id}.${exp}`)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function verifyPreviewToken(type: string, id: string, token: string | undefined): boolean {
|
|
21
|
+
if (!token) return false;
|
|
22
|
+
const [expStr, s] = token.split('.');
|
|
23
|
+
if (!expStr || !s) return false;
|
|
24
|
+
if (Number(expStr) < Date.now()) return false;
|
|
25
|
+
const expected = sig(`${type}.${id}.${expStr}`);
|
|
26
|
+
if (s.length !== expected.length) return false;
|
|
27
|
+
return timingSafeEqual(Buffer.from(s), Buffer.from(expected));
|
|
28
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { SqlRepository, type SqlDriver, type SqlStatement } from './sql';
|
|
2
|
+
|
|
3
|
+
// Cloudflare D1 (SQLite on Workers). All queries and row mapping live in the
|
|
4
|
+
// shared SqlRepository — this file only adapts D1's prepare/bind/batch API.
|
|
5
|
+
|
|
6
|
+
type Row = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
interface D1Result { results: Row[] }
|
|
9
|
+
interface D1Stmt {
|
|
10
|
+
bind(...args: unknown[]): D1Stmt;
|
|
11
|
+
all(): Promise<D1Result>;
|
|
12
|
+
first<T = Row>(): Promise<T | null>;
|
|
13
|
+
run(): Promise<unknown>;
|
|
14
|
+
}
|
|
15
|
+
export interface D1Database {
|
|
16
|
+
prepare(sql: string): D1Stmt;
|
|
17
|
+
batch(stmts: D1Stmt[]): Promise<unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class D1Driver implements SqlDriver {
|
|
21
|
+
constructor(private db: D1Database) {}
|
|
22
|
+
|
|
23
|
+
private stmt({ sql, args }: SqlStatement): D1Stmt {
|
|
24
|
+
const s = this.db.prepare(sql);
|
|
25
|
+
return args?.length ? s.bind(...args) : s;
|
|
26
|
+
}
|
|
27
|
+
async all(sql: string, args?: unknown[]) {
|
|
28
|
+
const { results } = await this.stmt({ sql, args }).all();
|
|
29
|
+
return results;
|
|
30
|
+
}
|
|
31
|
+
async run(sql: string, args?: unknown[]) {
|
|
32
|
+
await this.stmt({ sql, args }).run();
|
|
33
|
+
}
|
|
34
|
+
async batch(stmts: SqlStatement[]) {
|
|
35
|
+
if (!stmts.length) return;
|
|
36
|
+
await this.db.batch(stmts.map((s) => this.stmt(s)));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class D1Repository extends SqlRepository {
|
|
41
|
+
constructor(db: D1Database) {
|
|
42
|
+
super(new D1Driver(db));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ContentRepository } from './types';
|
|
2
|
+
|
|
3
|
+
let repo: ContentRepository | null = null;
|
|
4
|
+
|
|
5
|
+
// Adapter priority: Cloudflare D1 (when the Worker binding exists —
|
|
6
|
+
// production on Workers and local wrangler/astro dev via platformProxy)
|
|
7
|
+
// → libSQL file (plain Node self-hosting).
|
|
8
|
+
// Pass Astro.locals (or the endpoint's `locals`) so the D1 binding is
|
|
9
|
+
// reachable; the chosen adapter is cached for the isolate's lifetime.
|
|
10
|
+
export async function getRepo(locals?: unknown): Promise<ContentRepository> {
|
|
11
|
+
if (repo) return repo;
|
|
12
|
+
const d1 = (locals as { runtime?: { env?: { DB?: unknown } } } | undefined)?.runtime?.env?.DB;
|
|
13
|
+
if (d1) {
|
|
14
|
+
const { D1Repository } = await import('./d1');
|
|
15
|
+
repo = new D1Repository(d1 as import('./d1').D1Database);
|
|
16
|
+
} else {
|
|
17
|
+
const { SqliteRepository } = await import('./sqlite');
|
|
18
|
+
repo = new SqliteRepository();
|
|
19
|
+
}
|
|
20
|
+
return repo;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type { ContentRepository };
|