@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,218 @@
1
+ ---
2
+ import { getRepo } from '../lib/repo';
3
+ import { renderMarkdown } from '../lib/markdown';
4
+ import { resolveDesign } from '../lib/theme';
5
+ import { siteOrigin, absUrl, canonicalPath } from '../lib/seo';
6
+ import { siteLang } from '../lib/dates';
7
+ import { CONTENT } from '../lib/content';
8
+ import { I18N, localeFromPath, localeHomeUrl } from '../lib/i18n';
9
+ import '../styles/site.css';
10
+ import '../styles/cards.css';
11
+
12
+ interface Props {
13
+ title?: string;
14
+ description?: string;
15
+ // standalone pages (the /links link-in-bio page) skip the site nav + footer
16
+ bare?: boolean;
17
+ // a hand-tuned SEO title is used verbatim — no "· Site" suffix
18
+ exactTitle?: boolean;
19
+ // social/OG: share image (site default when empty), og:type, article dates
20
+ // and author for posts; jsonLd is emitted as a schema.org script verbatim
21
+ image?: string;
22
+ imageAlt?: string;
23
+ type?: 'website' | 'article';
24
+ publishedAt?: number | null;
25
+ modifiedAt?: number | null;
26
+ authorName?: string;
27
+ jsonLd?: Record<string, unknown>;
28
+ noindex?: boolean;
29
+ }
30
+ const {
31
+ title, description, bare = false, exactTitle = false,
32
+ image, imageAlt, type = 'website', publishedAt, modifiedAt, authorName, jsonLd, noindex = false,
33
+ } = Astro.props;
34
+
35
+ const repo = await getRepo(Astro.locals);
36
+ const [settings, pages, navItems] = await Promise.all([repo.getSettings(), repo.listPages(), repo.listNavItems()]);
37
+ const design = resolveDesign(settings);
38
+ const navPages = pages.filter((p) => p.status === 'published' && !p.isHome);
39
+ const headerNav = navItems.filter((n) => n.area === 'header');
40
+ const footerNav = navItems.filter((n) => n.area === 'footer');
41
+ const socialNav = navItems.filter((n) => n.area === 'social');
42
+ const isExternal = (url: string) => /^https?:\/\//.test(url);
43
+ const current = Astro.url.pathname;
44
+ const fullTitle = exactTitle && title ? title : title ? `${title} · ${settings.siteTitle}` : settings.siteTitle;
45
+ const footerHtml = settings.footerText
46
+ ? renderMarkdown(settings.footerText)
47
+ : `<p>${settings.siteTitle}${settings.tagline ? ` — ${settings.tagline}` : ''}</p>`;
48
+
49
+ const origin = siteOrigin(settings, Astro.url);
50
+ const ann = settings.announcement;
51
+ // defense in depth against a stored javascript: link (the admin form also
52
+ // rejects these on save)
53
+ const annLink = ann.link.startsWith('/') || /^https?:\/\//i.test(ann.link) ? ann.link : '';
54
+ const showAnnouncement = !bare && ann.enabled && ann.text.trim().length > 0;
55
+ // dismissals are remembered per-text, so editing the message re-shows the bar
56
+ const annKey = showAnnouncement
57
+ ? `acms-ann-${[...ann.text].reduce((h, c) => ((h * 31 + c.charCodeAt(0)) >>> 0), 0).toString(36)}`
58
+ : '';
59
+ const canonical = origin + canonicalPath(current);
60
+ const metaDescription = description || settings.tagline || undefined;
61
+ // with i18n configured, the request path decides the document language; the
62
+ // locale setting (a date-formatting concern) stays the fallback
63
+ const reqLocale = I18N.enabled ? localeFromPath(current) : '';
64
+ const htmlLang = reqLocale || siteLang(settings);
65
+ // hreflang alternates connect the per-language home pages; other routes have
66
+ // no language twins yet, so alternates are emitted only on home paths
67
+ const isHomePath = I18N.enabled && I18N.locales.some((l) => current === localeHomeUrl(l));
68
+ const shareImage = image || settings.siteCover || '';
69
+ const shareImageAbs = shareImage ? absUrl(shareImage, origin) : '';
70
+ const faviconType =
71
+ settings.siteFavicon.endsWith('.svg') ? 'image/svg+xml'
72
+ : settings.siteFavicon.endsWith('.png') || settings.siteFavicon.includes('.webp') ? 'image/png'
73
+ : undefined;
74
+ const homeJsonLd =
75
+ current === '/'
76
+ ? {
77
+ '@context': 'https://schema.org',
78
+ '@type': 'WebSite',
79
+ name: settings.siteTitle,
80
+ ...(settings.tagline ? { description: settings.tagline } : {}),
81
+ url: origin + '/',
82
+ }
83
+ : null;
84
+ ---
85
+
86
+ <!doctype html>
87
+ <html lang={htmlLang}>
88
+ <head>
89
+ <meta charset="utf-8" />
90
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
91
+ <title>{fullTitle}</title>
92
+ {metaDescription && <meta name="description" content={metaDescription} />}
93
+ {noindex && <meta name="robots" content="noindex" />}
94
+ <link rel="canonical" href={canonical} />
95
+ {isHomePath && I18N.locales.map((l) => (
96
+ <link rel="alternate" hreflang={l} href={origin + localeHomeUrl(l)} />
97
+ ))}
98
+ {isHomePath && <link rel="alternate" hreflang="x-default" href={origin + '/'} />}
99
+ {settings.siteFavicon && <link rel="icon" href={settings.siteFavicon} type={faviconType} />}
100
+ <link rel="alternate" type="application/rss+xml" title={settings.siteTitle} href={`${origin}/rss.xml`} />
101
+ <link rel="sitemap" href={`${origin}/sitemap.xml`} />
102
+ <meta property="og:site_name" content={settings.siteTitle} />
103
+ <meta property="og:title" content={title || settings.siteTitle} />
104
+ {metaDescription && <meta property="og:description" content={metaDescription} />}
105
+ <meta property="og:type" content={type} />
106
+ <meta property="og:url" content={canonical} />
107
+ {shareImageAbs && <meta property="og:image" content={shareImageAbs} />}
108
+ {shareImageAbs && imageAlt && <meta property="og:image:alt" content={imageAlt} />}
109
+ {type === 'article' && publishedAt && (
110
+ <meta property="article:published_time" content={new Date(publishedAt).toISOString()} />
111
+ )}
112
+ {type === 'article' && modifiedAt && (
113
+ <meta property="article:modified_time" content={new Date(modifiedAt).toISOString()} />
114
+ )}
115
+ {type === 'article' && authorName && <meta property="article:author" content={authorName} />}
116
+ <meta name="twitter:card" content={shareImageAbs ? 'summary_large_image' : 'summary'} />
117
+ <meta name="twitter:title" content={title || settings.siteTitle} />
118
+ {metaDescription && <meta name="twitter:description" content={metaDescription} />}
119
+ {shareImageAbs && <meta name="twitter:image" content={shareImageAbs} />}
120
+ {homeJsonLd && <script type="application/ld+json" set:html={JSON.stringify(homeJsonLd)} />}
121
+ {jsonLd && <script type="application/ld+json" set:html={JSON.stringify(jsonLd)} />}
122
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
123
+ {design.fontsHref && <link href={design.fontsHref} rel="stylesheet" />}
124
+ <Fragment set:html={`<style>${design.css}</style>`} />
125
+ {settings.headCode && <Fragment set:html={settings.headCode} />}
126
+ </head>
127
+ <body>
128
+ {showAnnouncement && (
129
+ <div class="announcement-bar" data-ann-key={ann.dismissible ? annKey : undefined} hidden={ann.dismissible || undefined}>
130
+ {annLink ? <a href={annLink}>{ann.text}</a> : <span>{ann.text}</span>}
131
+ {ann.dismissible && (
132
+ <button type="button" class="announcement-x" aria-label="Dismiss announcement">✕</button>
133
+ )}
134
+ </div>
135
+ )}
136
+ {showAnnouncement && ann.dismissible && (
137
+ <script is:inline define:vars={{ annKey }}>
138
+ (() => {
139
+ const bar = document.querySelector('.announcement-bar');
140
+ if (!bar) return;
141
+ try {
142
+ if (localStorage.getItem(annKey) === '1') return;
143
+ } catch { /* private mode: always show */ }
144
+ bar.hidden = false;
145
+ bar.querySelector('.announcement-x')?.addEventListener('click', () => {
146
+ bar.remove();
147
+ try { localStorage.setItem(annKey, '1'); } catch { /* ignore */ }
148
+ });
149
+ })();
150
+ </script>
151
+ )}
152
+ {!bare && (
153
+ <header class="site-header">
154
+ <a class="brand" href="/">
155
+ {settings.siteLogo ? <img class="brand-logo" src={settings.siteLogo} alt={settings.siteTitle} /> : settings.siteTitle}
156
+ </a>
157
+ <nav class="site-nav">
158
+ {headerNav.length > 0 ? (
159
+ headerNav.map((n) => (
160
+ <a
161
+ href={n.url}
162
+ aria-current={current === n.url ? 'page' : undefined}
163
+ target={isExternal(n.url) ? '_blank' : undefined}
164
+ rel={isExternal(n.url) ? 'noopener' : undefined}
165
+ >{n.label}</a>
166
+ ))
167
+ ) : (
168
+ <>
169
+ <a href="/" aria-current={current === '/' ? 'page' : undefined}>Home</a>
170
+ {navPages.map((p) => (
171
+ <a href={`/${p.slug}`} aria-current={current === `/${p.slug}` ? 'page' : undefined}>{p.title}</a>
172
+ ))}
173
+ {Object.values(CONTENT.collections).map((c) => (
174
+ <a href={c.base} aria-current={current.startsWith(c.base) ? 'page' : undefined}>{c.plural}</a>
175
+ ))}
176
+ <a href="/links" aria-current={current === '/links' ? 'page' : undefined}>Links</a>
177
+ </>
178
+ )}
179
+ {I18N.enabled && (
180
+ <span class="lang-switch" role="group" aria-label="Language">
181
+ {I18N.locales.map((l) => (
182
+ <a
183
+ href={localeHomeUrl(l)}
184
+ lang={l}
185
+ class:list={[{ on: l === reqLocale }]}
186
+ aria-current={l === reqLocale ? 'true' : undefined}
187
+ >{l.toUpperCase()}</a>
188
+ ))}
189
+ </span>
190
+ )}
191
+ </nav>
192
+ </header>
193
+ )}
194
+ <main class="site-main">
195
+ <slot />
196
+ </main>
197
+ {!bare && (
198
+ <footer class="site-footer">
199
+ {footerNav.length > 0 && (
200
+ <nav class="footer-nav">
201
+ {footerNav.map((n) => (
202
+ <a href={n.url} target={isExternal(n.url) ? '_blank' : undefined} rel={isExternal(n.url) ? 'noopener' : undefined}>{n.label}</a>
203
+ ))}
204
+ </nav>
205
+ )}
206
+ {socialNav.length > 0 && (
207
+ <nav class="social-nav">
208
+ {socialNav.map((n) => (
209
+ <a href={n.url} target={isExternal(n.url) ? '_blank' : undefined} rel={isExternal(n.url) ? 'noopener' : undefined}>{n.label}</a>
210
+ ))}
211
+ </nav>
212
+ )}
213
+ <div class="footer-text" set:html={footerHtml} />
214
+ </footer>
215
+ )}
216
+ {settings.footerCode && <Fragment set:html={settings.footerCode} />}
217
+ </body>
218
+ </html>
@@ -0,0 +1,94 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { env } from './env';
3
+
4
+ const COOKIE = 'acms_session';
5
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
6
+
7
+ function secret(): string {
8
+ const s = env('SESSION_SECRET');
9
+ if (!s) throw new Error('SESSION_SECRET is not set — add it to .env');
10
+ return s;
11
+ }
12
+
13
+ function hmac(payload: string): string {
14
+ return createHmac('sha256', secret()).update(payload).digest('hex');
15
+ }
16
+
17
+ function safeEqual(a: string, b: string): boolean {
18
+ return a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
19
+ }
20
+
21
+ // ---- sessions -------------------------------------------------------------
22
+ // Two token formats coexist:
23
+ // legacy (env-password owner): `${exp}.${hmac(exp)}`
24
+ // user accounts: `u.${userId}.${exp}.${hmac(userId.exp)}`
25
+ // The sentinel OWNER_ENV identifies a session created with ADMIN_PASSWORD
26
+ // (the bootstrap owner — always works, even with zero users in the DB).
27
+
28
+ export const OWNER_ENV = '__owner_env__';
29
+
30
+ export function createSessionToken(userId: string = OWNER_ENV): string {
31
+ const exp = Date.now() + WEEK_MS;
32
+ if (userId === OWNER_ENV) return `${exp}.${hmac(String(exp))}`;
33
+ return `u.${userId}.${exp}.${hmac(`${userId}.${exp}`)}`;
34
+ }
35
+
36
+ // Returns the session's user id (OWNER_ENV for env-password sessions) or
37
+ // null. Truthy/falsy compatible with the old boolean signature.
38
+ export function verifySessionToken(token: string | undefined): string | null {
39
+ if (!token) return null;
40
+ if (token.startsWith('u.')) {
41
+ const parts = token.split('.');
42
+ if (parts.length !== 4) return null;
43
+ const [, userId, expStr, sig] = parts;
44
+ if (!userId || Number(expStr) < Date.now()) return null;
45
+ return safeEqual(sig, hmac(`${userId}.${expStr}`)) ? userId : null;
46
+ }
47
+ const [expStr, sig] = token.split('.');
48
+ if (!expStr || !sig) return null;
49
+ if (Number(expStr) < Date.now()) return null;
50
+ return safeEqual(sig, hmac(expStr)) ? OWNER_ENV : null;
51
+ }
52
+
53
+ // The env master password — bootstrap owner login, kept for backward compat.
54
+ export function checkPassword(input: string): boolean {
55
+ const pw = env('ADMIN_PASSWORD');
56
+ if (!pw) return false;
57
+ return safeEqual(input, pw);
58
+ }
59
+
60
+ // ---- password hashing (user accounts) -------------------------------------
61
+ // PBKDF2-SHA256 via WebCrypto: portable across Workers and Node, no native
62
+ // deps. Format: pbkdf2$<iterations>$<saltHex>$<hashHex>.
63
+
64
+ const PBKDF2_ITERATIONS = 100_000;
65
+
66
+ const toHex = (buf: ArrayBuffer) =>
67
+ [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
68
+ const fromHex = (hex: string) =>
69
+ new Uint8Array(hex.match(/.{2}/g)?.map((b) => parseInt(b, 16)) ?? []);
70
+
71
+ async function pbkdf2(password: string, salt: Uint8Array, iterations: number): Promise<string> {
72
+ const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']);
73
+ const bits = await crypto.subtle.deriveBits(
74
+ { name: 'PBKDF2', hash: 'SHA-256', salt: salt as BufferSource, iterations },
75
+ key,
76
+ 256
77
+ );
78
+ return toHex(bits);
79
+ }
80
+
81
+ export async function hashPassword(password: string): Promise<string> {
82
+ const salt = crypto.getRandomValues(new Uint8Array(16));
83
+ const hash = await pbkdf2(password, salt, PBKDF2_ITERATIONS);
84
+ return `pbkdf2$${PBKDF2_ITERATIONS}$${toHex(salt.buffer)}$${hash}`;
85
+ }
86
+
87
+ export async function verifyPassword(password: string, stored: string): Promise<boolean> {
88
+ const [scheme, itersStr, saltHex, hashHex] = stored.split('$');
89
+ if (scheme !== 'pbkdf2' || !itersStr || !saltHex || !hashHex) return false;
90
+ const hash = await pbkdf2(password, fromHex(saltHex), Number(itersStr));
91
+ return safeEqual(hash, hashHex);
92
+ }
93
+
94
+ export const SESSION_COOKIE = COOKIE;
@@ -0,0 +1,38 @@
1
+ // Minimal ZIP reader for acms backups. Backups are written by zip.ts with
2
+ // the store method (no compression), so this only needs to walk the central
3
+ // directory and slice entries out — deflated archives from other tools are
4
+ // rejected with a clear error.
5
+
6
+ export function unzip(buf: ArrayBuffer): Map<string, Uint8Array> {
7
+ const bytes = new Uint8Array(buf);
8
+ const view = new DataView(buf);
9
+
10
+ // find the end-of-central-directory record (scan back past any comment)
11
+ let eocd = -1;
12
+ for (let i = bytes.length - 22; i >= Math.max(0, bytes.length - 22 - 65536); i--) {
13
+ if (view.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
14
+ }
15
+ if (eocd < 0) throw new Error('Not a ZIP file.');
16
+ const count = view.getUint16(eocd + 10, true);
17
+ let off = view.getUint32(eocd + 16, true);
18
+
19
+ const entries = new Map<string, Uint8Array>();
20
+ for (let n = 0; n < count; n++) {
21
+ if (view.getUint32(off, true) !== 0x02014b50) throw new Error('Corrupt ZIP central directory.');
22
+ const method = view.getUint16(off + 10, true);
23
+ const compSize = view.getUint32(off + 20, true);
24
+ const nameLen = view.getUint16(off + 28, true);
25
+ const extraLen = view.getUint16(off + 30, true);
26
+ const commentLen = view.getUint16(off + 32, true);
27
+ const headerOff = view.getUint32(off + 42, true);
28
+ const name = new TextDecoder().decode(bytes.subarray(off + 46, off + 46 + nameLen));
29
+ if (method !== 0) throw new Error('Compressed ZIP entries are not supported — use an unmodified acms backup.');
30
+ // local header repeats name/extra lengths; data starts after them
31
+ const localNameLen = view.getUint16(headerOff + 26, true);
32
+ const localExtraLen = view.getUint16(headerOff + 28, true);
33
+ const dataStart = headerOff + 30 + localNameLen + localExtraLen;
34
+ entries.set(name, bytes.subarray(dataStart, dataStart + compSize));
35
+ off += 46 + nameLen + extraLen + commentLen;
36
+ }
37
+ return entries;
38
+ }
@@ -0,0 +1,105 @@
1
+ // Minimal ZIP writer (store method, no compression) so the backup endpoint
2
+ // needs no dependency and runs on both Workers and Node. Entries are pulled
3
+ // from an async iterator and emitted as they arrive, so the stream holds at
4
+ // most one entry's bytes at a time (plus the central directory records).
5
+ // Media is already webp/jpeg/png — deflating it again buys nothing.
6
+
7
+ export interface ZipEntry {
8
+ name: string;
9
+ data: Uint8Array;
10
+ }
11
+
12
+ const CRC_TABLE = (() => {
13
+ const t = new Uint32Array(256);
14
+ for (let n = 0; n < 256; n++) {
15
+ let c = n;
16
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
17
+ t[n] = c >>> 0;
18
+ }
19
+ return t;
20
+ })();
21
+
22
+ function crc32(data: Uint8Array): number {
23
+ let c = 0xffffffff;
24
+ for (let i = 0; i < data.length; i++) c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
25
+ return (c ^ 0xffffffff) >>> 0;
26
+ }
27
+
28
+ // MS-DOS date/time pair, the only timestamp format ZIP requires.
29
+ function dosDateTime(ms: number): { time: number; date: number } {
30
+ const d = new Date(ms);
31
+ return {
32
+ time: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1),
33
+ date: (Math.max(0, d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
34
+ };
35
+ }
36
+
37
+ function le(bytes: number, ...fields: [size: 2 | 4, value: number][]): Uint8Array {
38
+ const buf = new Uint8Array(bytes);
39
+ const view = new DataView(buf.buffer);
40
+ let off = 0;
41
+ for (const [size, value] of fields) {
42
+ if (size === 2) view.setUint16(off, value, true);
43
+ else view.setUint32(off, value >>> 0, true);
44
+ off += size;
45
+ }
46
+ return buf;
47
+ }
48
+
49
+ export function zipStream(entries: AsyncIterable<ZipEntry>, mtimeMs: number): ReadableStream<Uint8Array> {
50
+ const { time, date } = dosDateTime(mtimeMs);
51
+
52
+ async function* chunks(): AsyncGenerator<Uint8Array> {
53
+ const central: Uint8Array[] = [];
54
+ let offset = 0;
55
+ for await (const { name, data } of entries) {
56
+ const nameBytes = new TextEncoder().encode(name);
57
+ const crc = crc32(data);
58
+ const headerOffset = offset;
59
+ // local file header: version 2.0, UTF-8 names (bit 11), stored
60
+ yield le(30,
61
+ [4, 0x04034b50], [2, 20], [2, 0x0800], [2, 0],
62
+ [2, time], [2, date], [4, crc], [4, data.length], [4, data.length],
63
+ [2, nameBytes.length], [2, 0],
64
+ );
65
+ yield nameBytes;
66
+ yield data;
67
+ offset += 30 + nameBytes.length + data.length;
68
+ const record = new Uint8Array(46 + nameBytes.length);
69
+ record.set(
70
+ le(46,
71
+ [4, 0x02014b50], [2, 20], [2, 20], [2, 0x0800], [2, 0],
72
+ [2, time], [2, date], [4, crc], [4, data.length], [4, data.length],
73
+ [2, nameBytes.length], [2, 0], [2, 0], [2, 0], [2, 0],
74
+ [4, 0], [4, headerOffset],
75
+ ),
76
+ );
77
+ record.set(nameBytes, 46);
78
+ central.push(record);
79
+ }
80
+ const centralStart = offset;
81
+ let centralSize = 0;
82
+ for (const record of central) {
83
+ yield record;
84
+ centralSize += record.length;
85
+ }
86
+ yield le(22,
87
+ [4, 0x06054b50], [2, 0], [2, 0], [2, central.length], [2, central.length],
88
+ [4, centralSize], [4, centralStart], [2, 0],
89
+ );
90
+ }
91
+
92
+ // pull-driven so a slow download applies backpressure to the entry
93
+ // iterator instead of buffering the whole archive in the stream queue
94
+ const iter = chunks();
95
+ return new ReadableStream<Uint8Array>({
96
+ async pull(controller) {
97
+ const { value, done } = await iter.next();
98
+ if (done) controller.close();
99
+ else controller.enqueue(value);
100
+ },
101
+ async cancel() {
102
+ await iter.return(undefined);
103
+ },
104
+ });
105
+ }
@@ -0,0 +1,68 @@
1
+ import type { WidgetInstance } from './schema';
2
+ import { parseConfig } from './schema';
3
+ import { renderMarkdown } from './markdown';
4
+
5
+ // One-way upgrade path: legacy widget-block pages are converted to TipTap-
6
+ // parseable HTML the first time they open in the editor. The markup below
7
+ // matches the parseHTML rules of the editor's card nodes
8
+ // (src/components/editor/extensions/cards.tsx), so blocks round-trip into
9
+ // real cards. Runs server-side; escaping is ours.
10
+
11
+ const esc = (s: string) =>
12
+ s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
13
+
14
+ export function blocksToHtml(blocks: WidgetInstance[]): string {
15
+ const out: string[] = [];
16
+ for (const b of blocks) {
17
+ switch (b.type) {
18
+ case 'hero': {
19
+ const c = parseConfig('hero', b.config);
20
+ if (c.heading) out.push(`<h2>${esc(c.heading)}</h2>`);
21
+ if (c.sub) out.push(`<p>${esc(c.sub)}</p>`);
22
+ if (c.ctaLabel)
23
+ out.push(
24
+ `<div class="pc-buttoncard" data-align="${c.align === 'left' ? 'left' : 'center'}"><a href="${esc(c.ctaHref)}">${esc(c.ctaLabel)}</a></div>`
25
+ );
26
+ break;
27
+ }
28
+ case 'text': {
29
+ const c = parseConfig('text', b.config);
30
+ if (c.markdown) out.push(renderMarkdown(c.markdown));
31
+ break;
32
+ }
33
+ case 'image': {
34
+ const c = parseConfig('image', b.config);
35
+ if (!c.src) break;
36
+ const caption = c.caption ? `<figcaption>${esc(c.caption)}</figcaption>` : '';
37
+ out.push(`<figure class="pc-image"><img src="${esc(c.src)}" alt="${esc(c.alt)}">${caption}</figure>`);
38
+ break;
39
+ }
40
+ case 'button': {
41
+ const c = parseConfig('button', b.config);
42
+ if (!c.label) break;
43
+ out.push(`<div class="pc-buttoncard" data-align="center"><a href="${esc(c.href)}">${esc(c.label)}</a></div>`);
44
+ break;
45
+ }
46
+ case 'embed': {
47
+ const c = parseConfig('embed', b.config);
48
+ if (!c.url) break;
49
+ out.push(
50
+ `<figure class="pc-embed" data-provider="iframe" data-url="${esc(c.url)}" data-height="${c.height}"><div class="pc-embed-frame" style="height:${c.height}px"><iframe src="${esc(c.url)}"></iframe></div></figure>`
51
+ );
52
+ break;
53
+ }
54
+ case 'customHtml': {
55
+ const c = parseConfig('customHtml', b.config);
56
+ const combined = [c.html, c.css ? `<style>${c.css}</style>` : '', c.js ? `<script>${c.js}</script>` : '']
57
+ .filter(Boolean)
58
+ .join('\n');
59
+ if (combined) out.push(`<div class="pc-html">${combined}</div>`);
60
+ break;
61
+ }
62
+ case 'spacer':
63
+ // prose flow has its own rhythm — spacers don't translate
64
+ break;
65
+ }
66
+ }
67
+ return out.join('\n');
68
+ }
@@ -0,0 +1,26 @@
1
+ // User-visible identity and first-boot seed content, overridable per project
2
+ // via the integration's options in astro.config:
3
+ // acms({ brand: { name: 'My CMS' }, seed: { siteTitle: 'My Blog', ... } })
4
+ // The scaffolder (create-acms-subir) writes those options from its prompts.
5
+ // Internal protocol identifiers (session cookie name, backup format id,
6
+ // localStorage prefixes) intentionally live with their features instead —
7
+ // renaming those would invalidate existing sessions and backups.
8
+ import { userConfig } from 'virtual:acms-config';
9
+
10
+ const b = userConfig.brand ?? {};
11
+ const s = userConfig.seed ?? {};
12
+
13
+ export const BRAND = {
14
+ name: b.name ?? 'Acms',
15
+ version: b.version ?? userConfig.coreVersion,
16
+ stack: b.stack ?? 'Astro + D1/SQLite',
17
+ };
18
+
19
+ // Only read when the database is empty on first boot — editing these after
20
+ // a site has content has no effect.
21
+ export const SEED = {
22
+ siteTitle: s.siteTitle ?? b.name ?? 'Acms',
23
+ tagline: s.tagline ?? 'A widget-based CMS on Astro + Cloudflare',
24
+ heroHeading: s.heroHeading ?? `Welcome to ${s.siteTitle ?? b.name ?? 'Acms'}`,
25
+ heroSub: s.heroSub ?? 'Your new site, assembled from widgets.',
26
+ };