@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,240 @@
1
+ ---
2
+ import Site from '../../layouts/Site.astro';
3
+ import { getRepo } from '../../lib/repo';
4
+ import { renderMarkdown } from '../../lib/markdown';
5
+ import { expandDynamicBlocks } from '../../lib/dynamicBlocks';
6
+ import { highlightCodeHtml } from '../../lib/highlightCode';
7
+ import { verifySessionToken, SESSION_COOKIE } from '../../lib/auth';
8
+ import { slugify, authorSlug, publicTags } from '../../lib/schema';
9
+ import { resolveDesign } from '../../lib/theme';
10
+ import { siteOrigin } from '../../lib/seo';
11
+ import { formatDate } from '../../lib/dates';
12
+ import { collectionFromPath, collectionUrl, tagUrl } from '../../lib/content';
13
+
14
+ // shared post page for every collection — resolved from the request path
15
+ const col = collectionFromPath(Astro.url.pathname);
16
+ if (!col) return new Response(null, { status: 404, statusText: 'Not found' });
17
+
18
+ const { slug } = Astro.params;
19
+ const repo = await getRepo(Astro.locals);
20
+ const post = slug ? await repo.getPostBySlug(slug, col.key) : null;
21
+
22
+ const isAdmin = verifySessionToken(Astro.cookies.get(SESSION_COOKIE)?.value);
23
+ if (!post || (post.status !== 'published' && !isAdmin)) {
24
+ return new Response(null, { status: 404, statusText: 'Not found' });
25
+ }
26
+ const authorIds = post.authorIds.length ? post.authorIds : post.authorId ? [post.authorId] : [];
27
+ const [tags, allUsers, allPosts, settings, allMedia] = await Promise.all([
28
+ repo.listTags(),
29
+ authorIds.length ? repo.listUsers() : Promise.resolve([]),
30
+ repo.listPublishedPosts(col.key), // related reading stays within the collection
31
+ repo.getSettings(),
32
+ post.cover.startsWith('/media/') ? repo.listMedia() : Promise.resolve([]),
33
+ ]);
34
+ // ordered author objects; the first is the primary (gets the author card)
35
+ const authors = authorIds
36
+ .map((id) => allUsers.find((u) => u.id === id))
37
+ .filter((u): u is NonNullable<typeof u> => Boolean(u));
38
+ const author = authors[0] ?? null;
39
+ const visibleTags = publicTags(post.tags);
40
+ // intrinsic dimensions + srcset for the cover (CLS + responsive loading);
41
+ // only resolvable for library uploads, external cover URLs render as-is
42
+ const coverMedia = post.cover.startsWith('/media/')
43
+ ? allMedia.find((m) => `/media/${m.key}` === post.cover) ?? null
44
+ : null;
45
+ const coverSrcset = coverMedia?.variants.length
46
+ ? [...coverMedia.variants.map((v) => `/media/${v.key} ${v.w}w`), `${post.cover} ${coverMedia.width}w`].join(', ')
47
+ : undefined;
48
+ // post-page display options: theme defaults, overridable in /admin/design
49
+ const design = resolveDesign(settings);
50
+ const po = design.post;
51
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
52
+ const PostTemplate = (post.template ? design.theme.postTemplates[post.template] : null) as any;
53
+ const tagSlug = (name: string) =>
54
+ tags.find((t) => t.name.toLowerCase() === name.toLowerCase())?.slug ?? slugify(name);
55
+ const html = highlightCodeHtml(await expandDynamicBlocks(post.bodyHtml || renderMarkdown(post.body), repo));
56
+ const fmt = (ts: number | null) => formatDate(ts, settings);
57
+ const words = (post.bodyHtml || post.body).replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length;
58
+ const readMins = Math.max(1, Math.round(words / 220));
59
+ const metaBits = [
60
+ po.showDate ? fmt(post.publishedAt) : '',
61
+ po.showReadingTime ? `${readMins} min read` : '',
62
+ ].filter(Boolean).join(' \u00b7 ');
63
+
64
+ // related reading: shared tags first (most overlap wins), newest as tiebreak;
65
+ // topped up with the latest other posts so the section is never empty
66
+ const byOverlap = allPosts
67
+ .filter((p) => p.id !== post.id)
68
+ .map((p) => ({ p, shared: p.tags.filter((t) => post.tags.some((pt) => pt.toLowerCase() === t.toLowerCase())).length }))
69
+ .sort((a, b) => b.shared - a.shared || (b.p.publishedAt ?? 0) - (a.p.publishedAt ?? 0))
70
+ .map((r) => r.p);
71
+ const related = byOverlap.slice(0, 3);
72
+
73
+ const heroOverlay = po.cover === 'overlay' && Boolean(post.cover);
74
+ const iso = (ts: number | null) => (ts ? new Date(ts).toISOString() : undefined);
75
+ const articleJsonLd = {
76
+ '@context': 'https://schema.org',
77
+ '@type': 'Article',
78
+ headline: post.title,
79
+ ...(post.excerpt ? { description: post.excerpt } : {}),
80
+ ...(post.cover ? { image: [new URL(post.cover, siteOrigin(settings, Astro.url)).href] } : {}),
81
+ ...(post.publishedAt ? { datePublished: iso(post.publishedAt) } : {}),
82
+ dateModified: iso(post.updatedAt),
83
+ ...(authors.length
84
+ ? { author: authors.map((a) => ({ '@type': 'Person', name: a.name, url: new URL(`/author/${authorSlug(a)}`, siteOrigin(settings, Astro.url)).href })) }
85
+ : {}),
86
+ ...(visibleTags.length ? { keywords: visibleTags.join(', ') } : {}),
87
+ publisher: { '@type': 'Organization', name: settings.siteTitle },
88
+ mainEntityOfPage: new URL(Astro.url.pathname, siteOrigin(settings, Astro.url)).href,
89
+ };
90
+ const shareUrl = new URL(Astro.url.pathname, siteOrigin(settings, Astro.url)).href;
91
+ const shareX = `https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(post.title)}`;
92
+ const shareLi = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`;
93
+ ---
94
+
95
+ <Site
96
+ title={post.seoTitle || post.title}
97
+ exactTitle={Boolean(post.seoTitle)}
98
+ description={post.seoDesc || post.excerpt || undefined}
99
+ type="article"
100
+ image={post.cover || undefined}
101
+ imageAlt={post.coverAlt || undefined}
102
+ publishedAt={post.publishedAt}
103
+ modifiedAt={post.updatedAt}
104
+ authorName={author?.name}
105
+ jsonLd={articleJsonLd}
106
+ noindex={post.status !== 'published'}
107
+ >
108
+ {PostTemplate ? (
109
+ <PostTemplate post={post} html={html} authors={authors} settings={settings} related={related} />
110
+ ) : (
111
+ <article>
112
+ {post.status !== 'published' && (
113
+ <p style="background:#fff3cd;border:1px solid #ffe08a;border-radius:8px;padding:8px 14px;font-size:14px;">
114
+ Draft preview — only you can see this post.
115
+ </p>
116
+ )}
117
+ {heroOverlay ? (
118
+ <header class="post-hero">
119
+ <img
120
+ class="post-hero-img"
121
+ src={post.cover}
122
+ alt={post.coverAlt}
123
+ width={coverMedia?.width || undefined}
124
+ height={coverMedia?.height || undefined}
125
+ srcset={coverSrcset}
126
+ sizes={coverSrcset ? '100vw' : undefined}
127
+ />
128
+ <div class="post-hero-scrim" aria-hidden="true" />
129
+ <div class="post-hero-content">
130
+ <div class="post-kicker">
131
+ {visibleTags.map((t) => <a class="tag" href={tagUrl(tagSlug(t))}>{t}</a>)}
132
+ </div>
133
+ <h1>{post.title}</h1>
134
+ {(metaBits || (po.byline && author)) && (
135
+ <div class="post-meta">
136
+ {po.showDate && post.publishedAt && <time datetime={iso(post.publishedAt)}>{fmt(post.publishedAt)}</time>}
137
+ {po.showDate && post.publishedAt && po.showReadingTime && ' \u00b7 '}
138
+ {po.showReadingTime && `${readMins} min read`}
139
+ {po.byline && author && (
140
+ <span class="byline">{metaBits && '\u00b7 '}by {authors.map((a, i) => (
141
+ <>{i > 0 && (i === authors.length - 1 ? ' & ' : ', ')}<a href={`/author/${authorSlug(a)}`}>{a.name}</a></>
142
+ ))}</span>
143
+ )}
144
+ </div>
145
+ )}
146
+ </div>
147
+ </header>
148
+ ) : (
149
+ <header class="post-header">
150
+ <div class="post-kicker">
151
+ {visibleTags.map((t) => <a class="tag" href={tagUrl(tagSlug(t))}>{t}</a>)}
152
+ </div>
153
+ <h1>{post.title}</h1>
154
+ {(metaBits || (po.byline && author)) && (
155
+ <div class="post-meta">
156
+ {po.showDate && post.publishedAt && <time datetime={iso(post.publishedAt)}>{fmt(post.publishedAt)}</time>}
157
+ {po.showDate && post.publishedAt && po.showReadingTime && ' \u00b7 '}
158
+ {po.showReadingTime && `${readMins} min read`}
159
+ {po.byline && author && (
160
+ <span class="byline">{metaBits && '\u00b7 '}by {authors.map((a, i) => (
161
+ <>{i > 0 && (i === authors.length - 1 ? ' & ' : ', ')}<a href={`/author/${authorSlug(a)}`}>{a.name}</a></>
162
+ ))}</span>
163
+ )}
164
+ </div>
165
+ )}
166
+ </header>
167
+ )}
168
+ {post.cover && !heroOverlay && po.cover !== 'hidden' && (
169
+ <img
170
+ class={`post-cover ${po.cover === 'full' ? 'is-full' : ''}`}
171
+ src={post.cover}
172
+ alt={post.coverAlt}
173
+ width={coverMedia?.width || undefined}
174
+ height={coverMedia?.height || undefined}
175
+ srcset={coverSrcset}
176
+ sizes={coverSrcset ? '(max-width: 760px) 100vw, 760px' : undefined}
177
+ />
178
+ )}
179
+
180
+ <div class="post-layout">
181
+ <div class="post-content" set:html={html} />
182
+ {po.share && (
183
+ <aside class="post-share" aria-label="Share this post">
184
+ <span class="post-share-label">Share</span>
185
+ <a href={shareX} target="_blank" rel="noopener" aria-label="Share on X">
186
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M18.9 2H22l-6.8 7.8L23.2 22h-6.3l-4.9-6.4L6.4 22H3.3l7.3-8.3L1.6 2H8l4.4 5.9L18.9 2zm-1.1 18h1.7L7.1 3.7H5.3L17.8 20z"/></svg>
187
+ </a>
188
+ <a href={shareLi} target="_blank" rel="noopener" aria-label="Share on LinkedIn">
189
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M20.4 20.4h-3.5v-5.6c0-1.3 0-3-1.9-3s-2.1 1.4-2.1 2.9v5.7H9.4V9h3.4v1.6a3.7 3.7 0 0 1 3.3-1.8c3.6 0 4.2 2.3 4.2 5.4v6.2zM5.3 7.4a2 2 0 1 1 0-4.1 2 2 0 0 1 0 4.1zM7.1 20.4H3.6V9h3.5v11.4z"/></svg>
190
+ </a>
191
+ <button type="button" class="post-share-copy" data-url={shareUrl} aria-label="Copy link">
192
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1.5 1.5M14 10a4 4 0 0 0-6 0l-3 3a4 4 0 0 0 6 6l1.5-1.5"/></svg>
193
+ </button>
194
+ </aside>
195
+ )}
196
+ </div>
197
+
198
+ {po.authorCard && author && (
199
+ <aside class="author-card">
200
+ {author.avatar ? (
201
+ <img class="author-avatar" src={author.avatar} alt={author.name} />
202
+ ) : (
203
+ <span class="author-avatar author-initial" aria-hidden="true">{author.name.slice(0, 1)}</span>
204
+ )}
205
+ <div>
206
+ <h2 class="author-card-name"><a href={`/author/${authorSlug(author)}`}>{author.name}</a></h2>
207
+ {author.bio && <p class="author-card-bio">{author.bio}</p>}
208
+ <a class="author-card-more" href={`/author/${authorSlug(author)}`}>More posts</a>
209
+ </div>
210
+ </aside>
211
+ )}
212
+
213
+ {po.related && related.length > 0 && (
214
+ <section class="related">
215
+ <h2 class="related-title">Related reading</h2>
216
+ <div class="related-grid">
217
+ {related.map((p) => (
218
+ <article class="related-card">
219
+ {p.cover && (
220
+ <a class="related-thumb" href={collectionUrl(col.key, p.slug)}><img src={p.cover} alt={p.coverAlt} loading="lazy" /></a>
221
+ )}
222
+ <div class="post-meta">{p.publishedAt && <time datetime={iso(p.publishedAt)}>{fmt(p.publishedAt)}</time>}</div>
223
+ <h3><a href={collectionUrl(col.key, p.slug)}>{p.title}</a></h3>
224
+ {p.excerpt && <p class="related-excerpt">{p.excerpt}</p>}
225
+ </article>
226
+ ))}
227
+ </div>
228
+ </section>
229
+ )}
230
+ </article>
231
+ )}
232
+ <script>
233
+ document.querySelector<HTMLButtonElement>('.post-share-copy')?.addEventListener('click', async (e) => {
234
+ const btn = e.currentTarget as HTMLButtonElement;
235
+ await navigator.clipboard.writeText(btn.dataset.url ?? location.href);
236
+ btn.classList.add('is-copied');
237
+ setTimeout(() => btn.classList.remove('is-copied'), 1200);
238
+ });
239
+ </script>
240
+ </Site>
@@ -0,0 +1,51 @@
1
+ ---
2
+ import Site from '../../layouts/Site.astro';
3
+ import { getRepo } from '../../lib/repo';
4
+ import { publicTags, slugify } from '../../lib/schema';
5
+ import { formatDate } from '../../lib/dates';
6
+ import { collectionFromPath, collectionUrl, tagUrl } from '../../lib/content';
7
+
8
+ // One file serves every collection's index — injectRoute can't pass props,
9
+ // so the collection is resolved from the request path.
10
+ const col = collectionFromPath(Astro.url.pathname);
11
+ if (!col) return new Response(null, { status: 404, statusText: 'Not found' });
12
+
13
+ const PAGE_SIZE = 10;
14
+ const repo = await getRepo(Astro.locals);
15
+ const [allPosts, tags, users, settings] = await Promise.all([repo.listPublishedPosts(col.key), repo.listTags(), repo.listUsers(), repo.getSettings()]);
16
+
17
+ const pageCount = Math.max(1, Math.ceil(allPosts.length / PAGE_SIZE));
18
+ const pageNum = Math.min(pageCount, Math.max(1, Number(Astro.url.searchParams.get('page')) || 1));
19
+ const posts = allPosts.slice((pageNum - 1) * PAGE_SIZE, pageNum * PAGE_SIZE);
20
+
21
+ const tagSlug = (name: string) =>
22
+ tags.find((t) => t.name.toLowerCase() === name.toLowerCase())?.slug ?? slugify(name);
23
+ const authorName = (id: string | null) => (id ? users.find((u) => u.id === id)?.name : undefined);
24
+ const fmt = (ts: number | null) =>
25
+ formatDate(ts, settings, 'short');
26
+ ---
27
+
28
+ <Site title={col.plural}>
29
+ <h1>{col.plural}</h1>
30
+ {posts.length === 0 && <p>No {col.plural.toLowerCase()} yet.</p>}
31
+ <ul class="post-list">
32
+ {posts.map((p) => (
33
+ <li>
34
+ <a href={collectionUrl(col.key, p.slug)}>{p.title}</a>
35
+ <div class="post-meta">
36
+ {fmt(p.publishedAt)}
37
+ {authorName(p.authorId) && <span class="byline">· {authorName(p.authorId)}</span>}
38
+ {publicTags(p.tags).map((t) => <a class="tag" href={tagUrl(tagSlug(t))}>{t}</a>)}
39
+ </div>
40
+ {p.excerpt && <p class="post-excerpt">{p.excerpt}</p>}
41
+ </li>
42
+ ))}
43
+ </ul>
44
+ {pageCount > 1 && (
45
+ <nav class="pager" aria-label="Pagination">
46
+ {pageNum > 1 ? <a href={pageNum === 2 ? col.base : `${col.base}?page=${pageNum - 1}`}>← Newer</a> : <span />}
47
+ <span class="pager-status">Page {pageNum} of {pageCount}</span>
48
+ {pageNum < pageCount ? <a href={`${col.base}?page=${pageNum + 1}`}>Older →</a> : <span />}
49
+ </nav>
50
+ )}
51
+ </Site>
@@ -0,0 +1,83 @@
1
+ ---
2
+ import Site from '../../../layouts/Site.astro';
3
+ import { getRepo } from '../../../lib/repo';
4
+ import { isInternalTag } from '../../../lib/schema';
5
+ import { formatDate } from '../../../lib/dates';
6
+ import { CONTENT, collectionUrl, postsIndexUrl, tagUrl } from '../../../lib/content';
7
+
8
+ const PAGE_SIZE = 10;
9
+ const { slug } = Astro.params;
10
+ const repo = await getRepo(Astro.locals);
11
+ const [tag, settings] = await Promise.all([slug ? repo.getTagBySlug(slug) : Promise.resolve(null), repo.getSettings()]);
12
+ // internal (#-prefixed) tags never get a public archive
13
+ if (!tag || isInternalTag(tag.name)) return new Response(null, { status: 404, statusText: 'Not found' });
14
+
15
+ // Tags are shared across collections — the archive lists every match.
16
+ // Posts store tag names; the registry maps slug → name for filtering.
17
+ const allPosts = (await repo.listPublishedPosts()).filter((p) =>
18
+ p.tags.some((t) => t.toLowerCase() === tag.name.toLowerCase())
19
+ );
20
+ const pageCount = Math.max(1, Math.ceil(allPosts.length / PAGE_SIZE));
21
+ const pageNum = Math.min(pageCount, Math.max(1, Number(Astro.url.searchParams.get('page')) || 1));
22
+ const posts = allPosts.slice((pageNum - 1) * PAGE_SIZE, pageNum * PAGE_SIZE);
23
+ const base = tagUrl(tag.slug);
24
+
25
+ // With several collections configured, the page groups entries under each
26
+ // collection's heading (config order); a single collection renders the flat
27
+ // list exactly as before.
28
+ const multi = Object.keys(CONTENT.collections).length > 1;
29
+ const groups = multi
30
+ ? Object.entries(CONTENT.collections)
31
+ .map(([key, c]) => ({ key, plural: c.plural, posts: posts.filter((p) => p.collection === key) }))
32
+ .filter((g) => g.posts.length > 0)
33
+ : [{ key: 'posts', plural: '', posts }];
34
+
35
+ const countNoun = multi
36
+ ? allPosts.length === 1 ? 'entry' : 'entries'
37
+ : (allPosts.length === 1 ? CONTENT.posts.singular : CONTENT.posts.plural).toLowerCase();
38
+
39
+ const fmt = (ts: number | null) =>
40
+ formatDate(ts, settings, 'short');
41
+ const readMins = (p: (typeof posts)[number]) =>
42
+ Math.max(1, Math.round((p.bodyHtml || p.body).replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length / 220));
43
+ ---
44
+
45
+ <Site title={tag.name} description={tag.description || `Tagged ${tag.name}`}>
46
+ <header class="tag-hero">
47
+ <p class="tag-kicker">Collection · {allPosts.length} {countNoun}</p>
48
+ <h1>{tag.name}</h1>
49
+ {tag.description && <p class="tag-desc">{tag.description}</p>}
50
+ </header>
51
+ {posts.length === 0 && <p>Nothing with this tag yet.</p>}
52
+ {groups.map((g) => (
53
+ <>
54
+ {multi && <h2 class="tag-group">{g.plural}</h2>}
55
+ <ul class="post-list">
56
+ {g.posts.map((p) => (
57
+ <li class="post-item">
58
+ {p.cover && (
59
+ <a class="post-thumb" href={collectionUrl(p.collection, p.slug)}><img src={p.cover} alt={p.coverAlt} loading="lazy" /></a>
60
+ )}
61
+ <div>
62
+ <a href={collectionUrl(p.collection, p.slug)}>{p.title}</a>
63
+ <div class="post-meta">{fmt(p.publishedAt)} · {readMins(p)} min read</div>
64
+ {p.excerpt && <p class="post-excerpt">{p.excerpt}</p>}
65
+ </div>
66
+ </li>
67
+ ))}
68
+ </ul>
69
+ </>
70
+ ))}
71
+ {pageCount > 1 && (
72
+ <nav class="pager" aria-label="Pagination">
73
+ {pageNum > 1 ? <a href={pageNum === 2 ? base : `${base}?page=${pageNum - 1}`}>← Newer</a> : <span />}
74
+ <span class="pager-status">Page {pageNum} of {pageCount}</span>
75
+ {pageNum < pageCount ? <a href={`${base}?page=${pageNum + 1}`}>Older →</a> : <span />}
76
+ </nav>
77
+ )}
78
+ <p style="margin-top:28px"><a href={postsIndexUrl()}>← All {CONTENT.posts.plural.toLowerCase()}</a></p>
79
+ </Site>
80
+
81
+ <style>
82
+ .tag-group { font-size: 1.05rem; margin: 1.6rem 0 0.2rem; color: var(--muted, inherit); }
83
+ </style>
@@ -0,0 +1,45 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../../lib/repo';
3
+ import { parseConfig } from '../../../lib/schema';
4
+
5
+ // Serves a Custom HTML/JS widget as its own document, rendered inside a
6
+ // sandboxed iframe (sandbox="allow-scripts") on the public page. The script
7
+ // runs here, isolated from the parent page, cookies, and the admin session.
8
+ export const GET: APIRoute = async ({ params, locals }) => {
9
+ const repo = await getRepo(locals);
10
+ const page = await repo.getPage(params.pageId!);
11
+ const block = page?.blocks.find((b) => b.id === params.blockId && b.type === 'customHtml');
12
+ if (!page || !block) return new Response(null, { status: 404 });
13
+
14
+ const cfg = parseConfig('customHtml', block.config);
15
+ const doc = `<!doctype html>
16
+ <html>
17
+ <head>
18
+ <meta charset="utf-8">
19
+ <style>
20
+ html, body { margin: 0; font-family: system-ui, sans-serif; }
21
+ ${cfg.css}
22
+ </style>
23
+ </head>
24
+ <body>
25
+ ${cfg.html}
26
+ <script>${cfg.js}<\/script>
27
+ <script>
28
+ (function () {
29
+ var send = function () {
30
+ parent.postMessage({ acmsEmbedId: ${JSON.stringify(block.id)}, height: document.documentElement.scrollHeight }, '*');
31
+ };
32
+ window.addEventListener('load', send);
33
+ new ResizeObserver(send).observe(document.documentElement);
34
+ })();
35
+ <\/script>
36
+ </body>
37
+ </html>`;
38
+
39
+ return new Response(doc, {
40
+ headers: {
41
+ 'Content-Type': 'text/html; charset=utf-8',
42
+ 'Cache-Control': 'no-store',
43
+ },
44
+ });
45
+ };
@@ -0,0 +1,10 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../../lib/repo';
3
+
4
+ // Tracked redirect for the links page — increments the click counter.
5
+ export const GET: APIRoute = async ({ params, locals }) => {
6
+ const repo = await getRepo(locals);
7
+ const url = await repo.trackClick(params.id!);
8
+ if (!url) return new Response(null, { status: 404 });
9
+ return new Response(null, { status: 302, headers: { Location: url } });
10
+ };
@@ -0,0 +1,52 @@
1
+ ---
2
+ import Site from '../layouts/Site.astro';
3
+ import WidgetRenderer from '../components/WidgetRenderer.astro';
4
+ import { getRepo } from '../lib/repo';
5
+ import { resolveDesign } from '../lib/theme';
6
+ import { expandDynamicBlocks } from '../lib/dynamicBlocks';
7
+ import { I18N, localeFromPath } from '../lib/i18n';
8
+
9
+ const repo = await getRepo(Astro.locals);
10
+ // this file also serves /<locale> for every non-default locale; the locale is
11
+ // resolved from the request path (injectRoute can't pass props)
12
+ const locale = I18N.enabled ? localeFromPath(Astro.url.pathname) : I18N.defaultLocale;
13
+ const wantsLocaleHome = I18N.enabled && locale !== I18N.defaultLocale;
14
+ let [home, settings] = await Promise.all([
15
+ repo.getHomePage(wantsLocaleHome ? locale : undefined),
16
+ repo.getSettings(),
17
+ ]);
18
+ // a locale home that isn't authored yet falls back to the default home —
19
+ // /es must never 404 mid-setup
20
+ if (wantsLocaleHome && !home) home = await repo.getHomePage();
21
+
22
+ // A theme may ship templates/home.astro — a coded homepage that wins over the
23
+ // isHome content page unless the Design screen forces 'content'. The template
24
+ // receives the page (its body renders as the hero) plus published posts/tags.
25
+ const design = resolveDesign(settings);
26
+ const HomeTemplate = design.homeMode !== 'content' ? (design.HomeTemplate as any) : null;
27
+ // theme homepages are blog-shaped — they receive the built-in collection only
28
+ const [posts, tags] = HomeTemplate
29
+ ? await Promise.all([repo.listPublishedPosts('posts'), repo.listTags()])
30
+ : [[], []];
31
+ // expand dynamic blocks (post lists) in the page body for BOTH paths — a theme
32
+ // template injects the body as its hero, so it must receive expanded HTML too
33
+ const homeHtml = home?.bodyHtml ? await expandDynamicBlocks(home.bodyHtml, repo) : '';
34
+ const homeForTemplate = home ? { ...home, bodyHtml: homeHtml } : null;
35
+ ---
36
+
37
+ <Site title={home?.seoTitle || undefined} description={home?.seoDesc || undefined}>
38
+ {HomeTemplate ? (
39
+ <HomeTemplate settings={settings} page={homeForTemplate} posts={posts} tags={tags} />
40
+ ) : home ? (
41
+ home.bodyHtml ? (
42
+ <article class="page-body" set:html={homeHtml} />
43
+ ) : (
44
+ <WidgetRenderer pageId={home.id} blocks={home.blocks} />
45
+ )
46
+ ) : (
47
+ <section class="w-hero center">
48
+ <h1>Welcome</h1>
49
+ <p class="sub">No home page yet — create one in <a href="/admin">the admin</a> and mark it as home.</p>
50
+ </section>
51
+ )}
52
+ </Site>
@@ -0,0 +1,110 @@
1
+ ---
2
+ import Site from '../layouts/Site.astro';
3
+ import { getRepo } from '../lib/repo';
4
+
5
+ const repo = await getRepo(Astro.locals);
6
+ const [links, settings] = await Promise.all([repo.listLinks(), repo.getSettings()]);
7
+
8
+ // links page has its own identity; falls back to the site's until it's set
9
+ const profileName = settings.linksName || settings.siteTitle;
10
+ const profileBio = settings.linksBio || settings.tagline;
11
+
12
+ const SPACER_PX: Record<string, number> = { s: 16, m: 36, l: 72 };
13
+
14
+ // icon column convention: '<svg…' inline markup, http/​/ image URL,
15
+ // 'favicon' derive from the link's host, anything else = emoji/text
16
+ function iconInfo(l: (typeof links)[number]): { type: 'svg' | 'img' | 'emoji' | 'none'; value: string } {
17
+ const icon = l.icon.trim();
18
+ if (!icon) return { type: 'none', value: '' };
19
+ if (icon.startsWith('<svg')) return { type: 'svg', value: icon };
20
+ if (icon === 'favicon') {
21
+ try {
22
+ const host = new URL(l.url).hostname;
23
+ return { type: 'img', value: `https://www.google.com/s2/favicons?domain=${host}&sz=64` };
24
+ } catch {
25
+ return { type: 'none', value: '' };
26
+ }
27
+ }
28
+ if (icon.startsWith('http') || icon.startsWith('/')) return { type: 'img', value: icon };
29
+ return { type: 'emoji', value: icon };
30
+ }
31
+
32
+ const HEX = /^#[0-9a-f]{6}$/i;
33
+
34
+ function buttonStyle(l: (typeof links)[number]): string | undefined {
35
+ const color = typeof l.meta.color === 'string' && HEX.test(l.meta.color) ? l.meta.color : '';
36
+ const textColor = typeof l.meta.textColor === 'string' && HEX.test(l.meta.textColor) ? l.meta.textColor : '';
37
+ const parts: string[] = [];
38
+ if (color) {
39
+ parts.push(`background:${color}`, `border-color:${color}`);
40
+ if (!textColor) {
41
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(color.slice(i, i + 2), 16));
42
+ const light = (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.62;
43
+ parts.push(`color:${light ? '#1e1f24' : '#fff'}`);
44
+ }
45
+ }
46
+ if (textColor) parts.push(`color:${textColor}`);
47
+ return parts.length ? parts.join(';') : undefined;
48
+ }
49
+
50
+ function vcardHref(l: (typeof links)[number]): string {
51
+ const m = l.meta as Record<string, string>;
52
+ const first = m.firstName ?? '';
53
+ const last = m.lastName ?? '';
54
+ const lines = [
55
+ 'BEGIN:VCARD',
56
+ 'VERSION:3.0',
57
+ `N:${last};${first};;;`,
58
+ `FN:${[first, last].filter(Boolean).join(' ')}`,
59
+ m.org ? `ORG:${m.org}` : '',
60
+ m.phone ? `TEL;TYPE=CELL:${m.phone}` : '',
61
+ m.email ? `EMAIL:${m.email}` : '',
62
+ m.url ? `URL:${m.url}` : '',
63
+ 'END:VCARD',
64
+ ].filter(Boolean);
65
+ return `data:text/vcard;charset=utf-8,${encodeURIComponent(lines.join('\r\n'))}`;
66
+ }
67
+
68
+ // incomplete buttons (no destination yet) stay hidden on the public page
69
+ const visible = links.filter((l) => {
70
+ if (l.kind === 'link' || l.kind === 'email' || l.kind === 'phone') return Boolean(l.url.trim());
71
+ if (l.kind === 'heading' || l.kind === 'text') return Boolean((l.kind === 'heading' ? l.label : l.html).trim());
72
+ if (l.kind === 'html') return Boolean(l.html.trim());
73
+ return true;
74
+ });
75
+ ---
76
+
77
+ <Site title="Links" bare>
78
+ <div class="links-page">
79
+ {settings.linksAvatar && <img class="links-avatar" src={settings.linksAvatar} alt={profileName} />}
80
+ <h1>{profileName}</h1>
81
+ {profileBio && <p class="tagline">{profileBio}</p>}
82
+ {visible.map((l) => {
83
+ const ico = iconInfo(l);
84
+ const icon =
85
+ ico.type === 'svg' ? <span class="link-ico" set:html={ico.value} /> :
86
+ ico.type === 'img' ? <span class="link-ico"><img src={ico.value} alt="" loading="lazy" /></span> :
87
+ ico.type === 'emoji' ? <span class="link-ico link-ico-emoji">{ico.value}</span> :
88
+ null;
89
+ switch (l.kind) {
90
+ case 'heading':
91
+ return <h2 class="link-heading">{l.label}</h2>;
92
+ case 'text':
93
+ return <p class="link-text">{l.html}</p>;
94
+ case 'spacer':
95
+ return <div class="link-spacer" style={`height:${SPACER_PX[String(l.meta.size)] ?? SPACER_PX.m}px`} />;
96
+ case 'html':
97
+ return <div class="link-html" set:html={l.html} />;
98
+ case 'email':
99
+ return <a class="link-btn" style={buttonStyle(l)} href={`mailto:${l.url}`}>{icon}<span class="link-label">{l.label || l.url}</span></a>;
100
+ case 'phone':
101
+ return <a class="link-btn" style={buttonStyle(l)} href={`tel:${l.url.replace(/[^+\d]/g, '')}`}>{icon}<span class="link-label">{l.label || l.url}</span></a>;
102
+ case 'vcard':
103
+ return <a class="link-btn" style={buttonStyle(l)} href={vcardHref(l)} download={`${(l.meta.firstName as string) || 'contact'}.vcf`}>{icon}<span class="link-label">{l.label || 'Save contact'}</span></a>;
104
+ default:
105
+ return <a class="link-btn" style={buttonStyle(l)} href={`/go/${l.id}`} rel="noopener">{icon}<span class="link-label">{l.label}</span></a>;
106
+ }
107
+ })}
108
+ {visible.length === 0 && <p>No links yet.</p>}
109
+ </div>
110
+ </Site>
@@ -0,0 +1,47 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getRepo } from '../lib/repo';
3
+ import { siteOrigin } from '../lib/seo';
4
+ import { CONTENT, collectionUrl } from '../lib/content';
5
+
6
+ // llms.txt (https://llmstxt.org): a markdown site summary for AI search and
7
+ // discovery systems — what the site is, plus links to its published content.
8
+
9
+ export const GET: APIRoute = async ({ locals, url }) => {
10
+ const repo = await getRepo(locals);
11
+ const [settings, pages, posts] = await Promise.all([
12
+ repo.getSettings(),
13
+ repo.listPages(),
14
+ repo.listPublishedPosts(),
15
+ ]);
16
+ const origin = siteOrigin(settings, url);
17
+ const publishedPages = pages.filter((p) => p.status === 'published' && !p.isHome);
18
+
19
+ const lines = [
20
+ `# ${settings.siteTitle}`,
21
+ '',
22
+ ...(settings.tagline ? [`> ${settings.tagline}`, ''] : []),
23
+ '## Pages',
24
+ '',
25
+ `- [Home](${origin}/)`,
26
+ ...publishedPages.map((p) => `- [${p.title}](${origin}/${p.slug})`),
27
+ ...Object.values(CONTENT.collections).map((c) => `- [${c.plural}](${origin}${c.base})`),
28
+ // one section per collection, in config order
29
+ ...Object.entries(CONTENT.collections).flatMap(([key, c]) => [
30
+ '',
31
+ `## ${c.plural}`,
32
+ '',
33
+ ...posts
34
+ .filter((p) => p.collection === key)
35
+ .map((p) => `- [${p.title}](${origin}${collectionUrl(key, p.slug)})${p.excerpt ? `: ${p.excerpt}` : ''}`),
36
+ ]),
37
+ '',
38
+ '## Feeds',
39
+ '',
40
+ `- [RSS](${origin}/rss.xml)`,
41
+ `- [Sitemap](${origin}/sitemap.xml)`,
42
+ '',
43
+ ];
44
+ return new Response(lines.join('\n'), {
45
+ headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'public, max-age=3600' },
46
+ });
47
+ };
@@ -0,0 +1,18 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { getMediaStore } from '../../lib/media/store';
3
+
4
+ // Serves uploaded media. Keys are content-addressed (uuid-prefixed), so
5
+ // responses are safely immutable.
6
+ export const GET: APIRoute = async ({ params, locals }) => {
7
+ const key = params.key;
8
+ if (!key || key.includes('..')) return new Response(null, { status: 404 });
9
+ const store = getMediaStore(locals);
10
+ const obj = await store.get(key);
11
+ if (!obj) return new Response(null, { status: 404 });
12
+ return new Response(obj.body, {
13
+ headers: {
14
+ 'Content-Type': obj.contentType,
15
+ 'Cache-Control': 'public, max-age=31536000, immutable',
16
+ },
17
+ });
18
+ };