@acms-subir/core 0.0.2 → 0.2.0
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/CHANGELOG.md +75 -0
- package/cli.mjs +128 -16
- package/integration.mjs +96 -3
- package/package.json +14 -12
- package/src/actions/index.ts +184 -6
- package/src/components/DesignManager.tsx +53 -17
- package/src/components/LinksManager.tsx +110 -48
- package/src/components/NavManager.tsx +83 -37
- package/src/components/editor/PostEditor.tsx +228 -55
- package/src/components/editor/extensions/cards.tsx +17 -1
- package/src/components/editor/extensions/codeBlock.tsx +1 -1
- package/src/components/editor/extensions/infoCards.tsx +16 -1
- package/src/components/editor/extensions/mediaCards.tsx +36 -2
- package/src/components/editor/extensions/slash.tsx +70 -8
- package/src/components/editor/extensions/toc.tsx +89 -0
- package/src/components/editor/extensions/toggle.tsx +70 -0
- package/src/layouts/Site.astro +122 -1
- package/src/lib/analytics.ts +121 -0
- package/src/lib/designPreview.ts +14 -0
- package/src/lib/drafts.ts +77 -0
- package/src/lib/dynamicBlocks.ts +43 -3
- package/src/lib/plaintext.ts +22 -0
- package/src/lib/repo/migrations.ts +52 -0
- package/src/lib/repo/sql.ts +289 -30
- package/src/lib/repo/types.ts +40 -4
- package/src/lib/schema.ts +53 -0
- package/src/lib/search-config.ts +10 -0
- package/src/lib/search-i18n.ts +45 -0
- package/src/pages/[...slug].astro +3 -1
- package/src/pages/admin/c/[collection]/[id].astro +10 -3
- package/src/pages/admin/c/[collection]/index.astro +1 -0
- package/src/pages/admin/design.astro +6 -1
- package/src/pages/admin/index.astro +27 -10
- package/src/pages/admin/pages/[id].astro +10 -2
- package/src/pages/admin/pages/index.astro +1 -0
- package/src/pages/admin/settings.astro +148 -0
- package/src/pages/api/search.ts +44 -0
- package/src/pages/blog/[slug].astro +4 -1
- package/src/pages/index.astro +4 -1
- package/src/pages/links.astro +10 -4
- package/src/pages/preview/[type]/[id].astro +9 -4
- package/src/pages/search.astro +83 -18
- package/src/styles/admin.css +4 -0
- package/src/styles/cards.css +23 -0
- package/src/styles/editor.css +53 -1
- package/src/styles/site.css +88 -0
- package/src/lib/repo/search.ts +0 -14
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Analytics providers (Umami, Meta Pixel, TikTok Pixel): builds the script
|
|
2
|
+
// markup Site.astro renders into the public <head> via set:html. Because the
|
|
3
|
+
// output bypasses Astro's escaping, nothing is emitted unless the stored
|
|
4
|
+
// ID/URL passes its validator — a bad value renders nothing, never broken or
|
|
5
|
+
// injected markup. The admin form rejects invalid values with the same rules.
|
|
6
|
+
import type { Settings } from './schema';
|
|
7
|
+
|
|
8
|
+
export const UMAMI_ID_RE = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
|
|
9
|
+
export const META_PIXEL_ID_RE = /^\d{5,20}$/;
|
|
10
|
+
export const TIKTOK_PIXEL_ID_RE = /^[A-Z0-9_-]{8,40}$/i;
|
|
11
|
+
|
|
12
|
+
// https only (self-hosted instances included); returns the normalized href,
|
|
13
|
+
// whose serialization percent-encodes quotes/angle brackets, so the value is
|
|
14
|
+
// attribute-safe. Belt and braces: reject anything still risky post-encode.
|
|
15
|
+
export function normalizeScriptUrl(url: string): string {
|
|
16
|
+
try {
|
|
17
|
+
const href = new URL(url).href;
|
|
18
|
+
if (new URL(url).protocol !== 'https:' || /["'<>\s]/.test(href)) return '';
|
|
19
|
+
return href;
|
|
20
|
+
} catch {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Analytics = Settings['analytics'];
|
|
26
|
+
|
|
27
|
+
function umamiTag(a: Analytics['umami']): string {
|
|
28
|
+
const src = normalizeScriptUrl(a.scriptUrl);
|
|
29
|
+
if (!a.enabled || !UMAMI_ID_RE.test(a.websiteId) || !src) return '';
|
|
30
|
+
return `<script defer src="${src}" data-website-id="${a.websiteId.toLowerCase()}"></script>`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The replay recorder lives next to the tracker on the same instance:
|
|
34
|
+
// swap the URL's last path segment for recorder.js and re-validate.
|
|
35
|
+
export function umamiRecorderUrl(scriptUrl: string): string {
|
|
36
|
+
const src = normalizeScriptUrl(scriptUrl);
|
|
37
|
+
if (!src) return '';
|
|
38
|
+
const url = new URL(src);
|
|
39
|
+
url.pathname = url.pathname.replace(/[^/]*$/, 'recorder.js');
|
|
40
|
+
return normalizeScriptUrl(url.href);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Session replay (Umami v3.1+): a second tag mirroring the dashboard-generated
|
|
44
|
+
// snippet. Attributes are re-clamped here because, like the rest of this file,
|
|
45
|
+
// the output bypasses Astro escaping — schema-invalid values render defaults,
|
|
46
|
+
// never broken markup.
|
|
47
|
+
function umamiReplayTag(a: Analytics['umami']): string {
|
|
48
|
+
const src = umamiRecorderUrl(a.scriptUrl);
|
|
49
|
+
if (!a.enabled || !a.replay.enabled || !UMAMI_ID_RE.test(a.websiteId) || !src) return '';
|
|
50
|
+
const rate =
|
|
51
|
+
Number.isFinite(a.replay.sampleRate) && a.replay.sampleRate >= 0.01 && a.replay.sampleRate <= 1
|
|
52
|
+
? a.replay.sampleRate
|
|
53
|
+
: 0.15;
|
|
54
|
+
const mask = a.replay.maskLevel === 'strict' ? 'strict' : 'moderate';
|
|
55
|
+
const duration =
|
|
56
|
+
Number.isInteger(a.replay.maxDuration) && a.replay.maxDuration > 0 ? a.replay.maxDuration : 300000;
|
|
57
|
+
return `<script defer src="${src}" data-website-id="${a.websiteId.toLowerCase()}" data-sample-rate="${rate}" data-mask-level="${mask}" data-max-duration="${duration}"></script>`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Meta's standard base code: fbq loader + PageView, with the no-JS pixel
|
|
61
|
+
// image fallback. The ID is digits-only by validation, so safe to inline.
|
|
62
|
+
function metaPixelTag(a: Analytics['metaPixel']): string {
|
|
63
|
+
if (!a.enabled || !META_PIXEL_ID_RE.test(a.pixelId)) return '';
|
|
64
|
+
return `<script>
|
|
65
|
+
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
|
66
|
+
n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
|
|
67
|
+
n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
|
|
68
|
+
t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,
|
|
69
|
+
document,'script','https://connect.facebook.net/en_US/fbevents.js');
|
|
70
|
+
fbq('init', '${a.pixelId}');
|
|
71
|
+
fbq('track', 'PageView');
|
|
72
|
+
</script>
|
|
73
|
+
<noscript><img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=${a.pixelId}&ev=PageView&noscript=1"/></noscript>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// TikTok's published base code (experimental — untested live): ttq loader +
|
|
77
|
+
// page(). ID is restricted to [A-Z0-9_-] by validation.
|
|
78
|
+
function tiktokPixelTag(a: Analytics['tiktokPixel']): string {
|
|
79
|
+
if (!a.enabled || !TIKTOK_PIXEL_ID_RE.test(a.pixelId)) return '';
|
|
80
|
+
return `<script>
|
|
81
|
+
!function (w, d, t) {
|
|
82
|
+
w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie"],ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<e.length;n++)ttq.setAndDefer(e[n],ttq.methods[n]);return e},ttq.load=function(e,n){var i="https://analytics.tiktok.com/i18n/pixel/events.js";ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=i,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=n||{};var o=document.createElement("script");o.type="text/javascript",o.async=!0,o.src=i+"?sdkid="+e+"&lib="+t;var a=document.getElementsByTagName("script")[0];a.parentNode.insertBefore(o,a)};
|
|
83
|
+
ttq.load('${a.pixelId}');
|
|
84
|
+
ttq.page();
|
|
85
|
+
}(window, document, 'ttq');
|
|
86
|
+
</script>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function analyticsHead(settings: Settings): string {
|
|
90
|
+
const a = settings.analytics;
|
|
91
|
+
return [umamiTag(a.umami), umamiReplayTag(a.umami), metaPixelTag(a.metaPixel), tiktokPixelTag(a.tiktokPixel)]
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.join('\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Event names are author-typed; collapse whitespace so "Sign up " and
|
|
97
|
+
// "Sign up" count as one event in the dashboards.
|
|
98
|
+
export function cleanEventName(name: string): string {
|
|
99
|
+
return name.replace(/\s+/g, ' ').trim();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Click-event bridge: elements carrying data-umami-event (editor cards, the
|
|
103
|
+
// links page) are counted by Umami natively — this dispatcher only exists to
|
|
104
|
+
// forward the same clicks to the ad pixels, so it is emitted solely when one
|
|
105
|
+
// of them is actually enabled. Umami-only sites ship zero extra JS.
|
|
106
|
+
export function analyticsBody(settings: Settings): string {
|
|
107
|
+
const a = settings.analytics;
|
|
108
|
+
const meta = a.metaPixel.enabled && META_PIXEL_ID_RE.test(a.metaPixel.pixelId);
|
|
109
|
+
const tiktok = a.tiktokPixel.enabled && TIKTOK_PIXEL_ID_RE.test(a.tiktokPixel.pixelId);
|
|
110
|
+
if (!meta && !tiktok) return '';
|
|
111
|
+
return `<script>
|
|
112
|
+
document.addEventListener('click', function (e) {
|
|
113
|
+
var el = e.target && e.target.closest && e.target.closest('[data-umami-event]');
|
|
114
|
+
if (!el) return;
|
|
115
|
+
var name = el.getAttribute('data-umami-event');
|
|
116
|
+
if (!name) return;
|
|
117
|
+
if (window.fbq) window.fbq('trackCustom', name);
|
|
118
|
+
if (window.ttq) window.ttq.track(name);
|
|
119
|
+
});
|
|
120
|
+
</script>`;
|
|
121
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AstroCookies } from 'astro';
|
|
2
|
+
import type { Settings } from './schema';
|
|
3
|
+
import { mergeDesignDraft } from './drafts';
|
|
4
|
+
import { verifySessionToken, SESSION_COOKIE } from './auth';
|
|
5
|
+
|
|
6
|
+
// The Design screen's preview iframe appends ?designPreview=1 so admins see
|
|
7
|
+
// the STAGED design (settings.draftDesign) while every normal request keeps
|
|
8
|
+
// rendering the live one. Session-gated: the flag is inert for visitors.
|
|
9
|
+
export function effectiveSettings(settings: Settings, url: URL, cookies: AstroCookies): Settings {
|
|
10
|
+
if (!settings.draftDesign) return settings;
|
|
11
|
+
if (!url.searchParams.has('designPreview')) return settings;
|
|
12
|
+
if (!verifySessionToken(cookies.get(SESSION_COOKIE)?.value)) return settings;
|
|
13
|
+
return mergeDesignDraft(settings);
|
|
14
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Page, Post, Settings } from './schema';
|
|
2
|
+
|
|
3
|
+
// Pending-edit ("draft") snapshots for live documents. A published or
|
|
4
|
+
// scheduled post/page never receives content edits directly: they accumulate
|
|
5
|
+
// in the row's `draft` JSON column and only reach the live columns when the
|
|
6
|
+
// user hits Update (repo.applyPostDraft / applyPageDraft).
|
|
7
|
+
//
|
|
8
|
+
// The snapshot stores the same camelCase field names the save actions accept,
|
|
9
|
+
// plus `savedAt`. Status transitions, schedule times and the publish sweep
|
|
10
|
+
// never live in a snapshot — those stay deliberate, immediate actions.
|
|
11
|
+
|
|
12
|
+
export const POST_DRAFT_KEYS = [
|
|
13
|
+
'title', 'slug', 'collection', 'body', 'bodyJson', 'bodyHtml', 'excerpt',
|
|
14
|
+
'tags', 'cover', 'coverAlt', 'seoTitle', 'seoDesc', 'featured',
|
|
15
|
+
'authorIds', 'template', 'publishedAt',
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export const PAGE_DRAFT_KEYS = [
|
|
19
|
+
'title', 'slug', 'locale', 'isHome', 'seoTitle', 'seoDesc', 'template',
|
|
20
|
+
'bodyJson', 'bodyHtml', 'blocks',
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
type PostDraftKey = (typeof POST_DRAFT_KEYS)[number];
|
|
24
|
+
type PageDraftKey = (typeof PAGE_DRAFT_KEYS)[number];
|
|
25
|
+
|
|
26
|
+
function pick(draft: Record<string, unknown>, keys: readonly string[]) {
|
|
27
|
+
const out: Record<string, unknown> = {};
|
|
28
|
+
for (const k of keys) if (draft[k] !== undefined) out[k] = draft[k];
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Only known editable fields survive the pick — a hand-edited or legacy
|
|
33
|
+
// snapshot can never smuggle status/id/timestamps into an UPDATE.
|
|
34
|
+
export function pickPostDraft(draft: Record<string, unknown>): Partial<Pick<Post, PostDraftKey>> {
|
|
35
|
+
return pick(draft, POST_DRAFT_KEYS) as Partial<Pick<Post, PostDraftKey>>;
|
|
36
|
+
}
|
|
37
|
+
export function pickPageDraft(draft: Record<string, unknown>): Partial<Pick<Page, PageDraftKey>> {
|
|
38
|
+
return pick(draft, PAGE_DRAFT_KEYS) as Partial<Pick<Page, PageDraftKey>>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The document as it will look after Update — what the editor hydrates from
|
|
42
|
+
// and what /preview renders. Live rows pass through untouched.
|
|
43
|
+
export function mergePostDraft(post: Post): Post {
|
|
44
|
+
if (!post.draft) return post;
|
|
45
|
+
const staged = pickPostDraft(post.draft);
|
|
46
|
+
const merged = { ...post, ...staged };
|
|
47
|
+
if (staged.authorIds) merged.authorId = staged.authorIds[0] ?? null;
|
|
48
|
+
return merged;
|
|
49
|
+
}
|
|
50
|
+
export function mergePageDraft(page: Page): Page {
|
|
51
|
+
if (!page.draft) return page;
|
|
52
|
+
return { ...page, ...pickPageDraft(page.draft) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function draftSavedAt(doc: Pick<Post | Page, 'draft'>): number | null {
|
|
56
|
+
const ts = doc.draft?.savedAt;
|
|
57
|
+
return typeof ts === 'number' ? ts : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Design-screen staging: the same shape, stored in settings.draftDesign and
|
|
61
|
+
// applied by the "Publish design" button.
|
|
62
|
+
export const DESIGN_DRAFT_KEYS = [
|
|
63
|
+
'activeTheme', 'designOverrides', 'siteLogo', 'siteFavicon', 'siteCover',
|
|
64
|
+
] as const;
|
|
65
|
+
|
|
66
|
+
type DesignDraftKey = (typeof DESIGN_DRAFT_KEYS)[number];
|
|
67
|
+
|
|
68
|
+
export function pickDesignDraft(draft: Record<string, unknown>): Partial<Pick<Settings, DesignDraftKey>> {
|
|
69
|
+
return pick(draft, DESIGN_DRAFT_KEYS) as Partial<Pick<Settings, DesignDraftKey>>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Settings as they will look after Publish design — what ?designPreview=1
|
|
73
|
+
// renders for admins while visitors keep the live design.
|
|
74
|
+
export function mergeDesignDraft(settings: Settings): Settings {
|
|
75
|
+
if (!settings.draftDesign) return settings;
|
|
76
|
+
return { ...settings, ...pickDesignDraft(settings.draftDesign) };
|
|
77
|
+
}
|
package/src/lib/dynamicBlocks.ts
CHANGED
|
@@ -14,8 +14,9 @@ import { renderCalendarBlock } from './calendar/render';
|
|
|
14
14
|
// data-layout="grid" data-limit="3" data-show-image="1" data-show-excerpt="1"></div>
|
|
15
15
|
// <div data-dyn="calendar" data-sources="[…ics urls]" data-view="month"
|
|
16
16
|
// data-title="…" data-subtitle="…" data-limit="6" data-open-link="1"></div>
|
|
17
|
+
// <div data-dyn="toc" data-title="In this article"></div>
|
|
17
18
|
|
|
18
|
-
const PLACEHOLDER = /<div[^>]*data-dyn="(?:post-list|calendar)"[^>]*>\s*<\/div>/g;
|
|
19
|
+
const PLACEHOLDER = /<div[^>]*data-dyn="(?:post-list|calendar|toc)"[^>]*>\s*<\/div>/g;
|
|
19
20
|
|
|
20
21
|
const esc = (s: string) =>
|
|
21
22
|
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
@@ -51,6 +52,41 @@ function renderList(posts: Post[], layout: string, showImage: boolean, showExcer
|
|
|
51
52
|
return `<section class="dyn-postlist" data-layout="${esc(layout)}">${items}</section>`;
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
// Heading anchors: body h1–h3 get stable slugified ids so sections are
|
|
56
|
+
// deep-linkable and the TOC block has targets. Headings that already carry an
|
|
57
|
+
// id (e.g. hand-written in an HTML card) are left alone.
|
|
58
|
+
export function stampHeadingAnchors(html: string): string {
|
|
59
|
+
const seen = new Map<string, number>();
|
|
60
|
+
return html.replace(/<h([1-3])(\s[^>]*)?>([\s\S]*?)<\/h\1>/g, (m, lvl: string, attrs: string | undefined, inner: string) => {
|
|
61
|
+
if (attrs && /\bid=/.test(attrs)) return m;
|
|
62
|
+
const text = inner.replace(/<[^>]+>/g, '').replace(/&/g, '&').replace(/�?39;|'/g, "'");
|
|
63
|
+
const base = slugify(text);
|
|
64
|
+
const n = (seen.get(base) ?? 0) + 1;
|
|
65
|
+
seen.set(base, n);
|
|
66
|
+
const id = n > 1 ? `${base}-${n}` : base;
|
|
67
|
+
return `<h${lvl}${attrs ?? ''} id="${id}">${inner}</h${lvl}>`;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Table of contents: rebuilt from the body's (already anchored) headings on
|
|
72
|
+
// every request, so it can never go stale. Depth is relative to the shallowest
|
|
73
|
+
// heading present.
|
|
74
|
+
function renderToc(html: string, title: string): string {
|
|
75
|
+
const items: { level: number; id: string; text: string }[] = [];
|
|
76
|
+
const re = /<h([1-3])[^>]*\bid="([^"]+)"[^>]*>([\s\S]*?)<\/h\1>/g;
|
|
77
|
+
let m: RegExpExecArray | null;
|
|
78
|
+
while ((m = re.exec(html))) {
|
|
79
|
+
const text = m[3].replace(/<[^>]+>/g, '').trim();
|
|
80
|
+
if (text) items.push({ level: Number(m[1]), id: m[2], text });
|
|
81
|
+
}
|
|
82
|
+
if (!items.length) return '';
|
|
83
|
+
const min = Math.min(...items.map((i) => i.level));
|
|
84
|
+
const links = items
|
|
85
|
+
.map((i) => `<a class="pc-toc-link" data-depth="${i.level - min}" href="#${esc(i.id)}">${i.text}</a>`)
|
|
86
|
+
.join('');
|
|
87
|
+
return `<nav class="pc-toc">${title ? `<span class="pc-toc-title">${esc(title)}</span>` : ''}${links}</nav>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
54
90
|
function calendarAttrs(tag: string) {
|
|
55
91
|
let sources: string[] = [];
|
|
56
92
|
try {
|
|
@@ -71,9 +107,11 @@ function calendarAttrs(tag: string) {
|
|
|
71
107
|
}
|
|
72
108
|
|
|
73
109
|
export async function expandDynamicBlocks(html: string, repo: ContentRepository): Promise<string> {
|
|
110
|
+
html = stampHeadingAnchors(html);
|
|
74
111
|
const hasPostList = html.includes('data-dyn="post-list"');
|
|
75
112
|
const hasCalendar = html.includes('data-dyn="calendar"');
|
|
76
|
-
|
|
113
|
+
const hasToc = html.includes('data-dyn="toc"');
|
|
114
|
+
if (!hasPostList && !hasCalendar && !hasToc) return html;
|
|
77
115
|
const [posts, settings] = await Promise.all([
|
|
78
116
|
hasPostList ? repo.listPublishedPosts() : Promise.resolve([] as Post[]), // newest first; shared by all blocks on the page
|
|
79
117
|
repo.getSettings(),
|
|
@@ -83,15 +121,17 @@ export async function expandDynamicBlocks(html: string, repo: ContentRepository)
|
|
|
83
121
|
// rendered up front, then spliced in during the synchronous pass.
|
|
84
122
|
const calendarHtml = new Map<string, string>();
|
|
85
123
|
if (hasCalendar) {
|
|
86
|
-
const tags = [...new Set((html.match(PLACEHOLDER) ?? []).filter((t) => t.includes('data-dyn="calendar"')))];
|
|
124
|
+
const tags = [...new Set((html.match(PLACEHOLDER) ?? ([] as string[])).filter((t) => t.includes('data-dyn="calendar"')))];
|
|
87
125
|
const now = Date.now();
|
|
88
126
|
await Promise.all(
|
|
89
127
|
tags.map(async (tag) => calendarHtml.set(tag, await renderCalendarBlock(calendarAttrs(tag), settings, now)))
|
|
90
128
|
);
|
|
91
129
|
}
|
|
92
130
|
|
|
131
|
+
const anchored = html;
|
|
93
132
|
return html.replace(PLACEHOLDER, (tag) => {
|
|
94
133
|
if (tag.includes('data-dyn="calendar"')) return calendarHtml.get(tag) ?? '';
|
|
134
|
+
if (tag.includes('data-dyn="toc"')) return renderToc(anchored, attr(tag, 'title'));
|
|
95
135
|
const source = attr(tag, 'source') || 'latest';
|
|
96
136
|
const tagName = attr(tag, 'tag');
|
|
97
137
|
// which collection the block draws from; 'posts' unless the card says otherwise
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Searchable plain text from rendered body_html: the FTS index stores this
|
|
2
|
+
// instead of a posts column, derived once per write. Dynamic-block
|
|
3
|
+
// placeholders (<div data-dyn>) carry no text, so tag-stripping drops them;
|
|
4
|
+
// script/style bodies are removed explicitly. Entity decoding covers what
|
|
5
|
+
// the editor emits — the named basics plus numeric escapes.
|
|
6
|
+
const codePoint = (n: number) => (n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : ' ');
|
|
7
|
+
|
|
8
|
+
export function htmlToPlaintext(html: string): string {
|
|
9
|
+
if (!html) return '';
|
|
10
|
+
return html
|
|
11
|
+
.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, ' ')
|
|
12
|
+
.replace(/<[^>]*>/g, ' ')
|
|
13
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, h: string) => codePoint(parseInt(h, 16)))
|
|
14
|
+
.replace(/&#(\d+);/g, (_, n: string) => codePoint(Number(n)))
|
|
15
|
+
.replace(/ /gi, ' ')
|
|
16
|
+
.replace(/&/gi, '&')
|
|
17
|
+
.replace(/</gi, '<')
|
|
18
|
+
.replace(/>/gi, '>')
|
|
19
|
+
.replace(/"/gi, '"')
|
|
20
|
+
.replace(/\s+/g, ' ')
|
|
21
|
+
.trim();
|
|
22
|
+
}
|
|
@@ -226,6 +226,58 @@ export const MIGRATIONS: Migration[] = [
|
|
|
226
226
|
version: 20,
|
|
227
227
|
statements: [`ALTER TABLE pages ADD COLUMN locale TEXT NOT NULL DEFAULT ''`],
|
|
228
228
|
},
|
|
229
|
+
{
|
|
230
|
+
// Public search: one FTS5 row per published post/page (title, excerpt,
|
|
231
|
+
// stripped body text, public tag names). Rows are synced by the repo on
|
|
232
|
+
// every write — no SQL triggers — and backfilled in code right after
|
|
233
|
+
// this version applies, since plaintext can't be derived in SQL.
|
|
234
|
+
// remove_diacritics makes "cafe" match "café" (matters for es content).
|
|
235
|
+
version: 21,
|
|
236
|
+
statements: [
|
|
237
|
+
`CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
|
|
238
|
+
kind UNINDEXED, ref_id UNINDEXED, locale UNINDEXED,
|
|
239
|
+
title, excerpt, plaintext, tags,
|
|
240
|
+
tokenize = 'unicode61 remove_diacritics 2'
|
|
241
|
+
)`,
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
// Manual publish: live (published/scheduled) documents stage their edits
|
|
246
|
+
// in a `draft` JSON snapshot; the live columns only change when the user
|
|
247
|
+
// hits Update. NULL = no pending edits.
|
|
248
|
+
version: 22,
|
|
249
|
+
statements: [
|
|
250
|
+
`ALTER TABLE posts ADD COLUMN draft TEXT`,
|
|
251
|
+
`ALTER TABLE pages ADD COLUMN draft TEXT`,
|
|
252
|
+
],
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
// Manual publish for the Design screen: theme/token/logo changes stage in
|
|
256
|
+
// a `draft_design` JSON snapshot (previewed with ?designPreview=1) until
|
|
257
|
+
// the user hits Publish design. NULL = no staged design.
|
|
258
|
+
version: 23,
|
|
259
|
+
statements: [`ALTER TABLE settings ADD COLUMN draft_design TEXT`],
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
// Snippets: named, reusable fragments of editor content ("Save as
|
|
263
|
+
// snippet" on a selection; re-inserted from the "/" menu).
|
|
264
|
+
version: 24,
|
|
265
|
+
statements: [
|
|
266
|
+
`CREATE TABLE IF NOT EXISTS snippets (
|
|
267
|
+
id TEXT PRIMARY KEY,
|
|
268
|
+
name TEXT NOT NULL,
|
|
269
|
+
content_json TEXT NOT NULL,
|
|
270
|
+
created_at INTEGER NOT NULL
|
|
271
|
+
)`,
|
|
272
|
+
],
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
// Analytics providers (Umami, Meta Pixel, TikTok Pixel): structured
|
|
276
|
+
// per-provider settings rendered into the public <head>. JSON object,
|
|
277
|
+
// '{}' = every provider disabled (schema defaults).
|
|
278
|
+
version: 25,
|
|
279
|
+
statements: [`ALTER TABLE settings ADD COLUMN analytics TEXT DEFAULT '{}'`],
|
|
280
|
+
},
|
|
229
281
|
];
|
|
230
282
|
|
|
231
283
|
export const LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|