@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,10 @@
|
|
|
1
|
+
// Search feature toggle, configured per project via the integration:
|
|
2
|
+
// acms({ search: { enabled: false } })
|
|
3
|
+
// integration.mjs validates and fills defaults before serializing; when
|
|
4
|
+
// disabled it also skips injecting /search and /api/search, so this flag
|
|
5
|
+
// only decides whether the layout renders the trigger + modal.
|
|
6
|
+
import { userConfig } from 'virtual:acms-config';
|
|
7
|
+
|
|
8
|
+
export const SEARCH = {
|
|
9
|
+
enabled: userConfig.search?.enabled ?? true,
|
|
10
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// UI strings for the search modal and /search page. There is no site-wide
|
|
2
|
+
// translation catalog yet — search is the first public surface with
|
|
3
|
+
// per-locale chrome, so the dict lives here until one exists. Locales fall
|
|
4
|
+
// back to English.
|
|
5
|
+
export interface SearchStrings {
|
|
6
|
+
label: string;
|
|
7
|
+
placeholder: string;
|
|
8
|
+
posts: string;
|
|
9
|
+
pages: string;
|
|
10
|
+
tags: string;
|
|
11
|
+
authors: string;
|
|
12
|
+
empty: string;
|
|
13
|
+
result: string;
|
|
14
|
+
results: string;
|
|
15
|
+
button: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const STRINGS: Record<string, SearchStrings> = {
|
|
19
|
+
en: {
|
|
20
|
+
label: 'Search',
|
|
21
|
+
placeholder: 'Search posts, pages, tags and authors',
|
|
22
|
+
posts: 'Posts',
|
|
23
|
+
pages: 'Pages',
|
|
24
|
+
tags: 'Tags',
|
|
25
|
+
authors: 'Authors',
|
|
26
|
+
empty: 'Nothing found for',
|
|
27
|
+
result: 'result',
|
|
28
|
+
results: 'results',
|
|
29
|
+
button: 'Search',
|
|
30
|
+
},
|
|
31
|
+
es: {
|
|
32
|
+
label: 'Buscar',
|
|
33
|
+
placeholder: 'Busca artículos, páginas, etiquetas y autores',
|
|
34
|
+
posts: 'Artículos',
|
|
35
|
+
pages: 'Páginas',
|
|
36
|
+
tags: 'Etiquetas',
|
|
37
|
+
authors: 'Autores',
|
|
38
|
+
empty: 'No hay resultados para',
|
|
39
|
+
result: 'resultado',
|
|
40
|
+
results: 'resultados',
|
|
41
|
+
button: 'Buscar',
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const searchStrings = (locale: string): SearchStrings => STRINGS[locale] ?? STRINGS.en;
|
|
@@ -6,6 +6,7 @@ import { verifySessionToken, SESSION_COOKIE } from '../lib/auth';
|
|
|
6
6
|
import { expandDynamicBlocks } from '../lib/dynamicBlocks';
|
|
7
7
|
import { highlightCodeHtml } from '../lib/highlightCode';
|
|
8
8
|
import { resolveDesign } from '../lib/theme';
|
|
9
|
+
import { effectiveSettings } from '../lib/designPreview';
|
|
9
10
|
|
|
10
11
|
const { slug } = Astro.params;
|
|
11
12
|
const repo = await getRepo(Astro.locals);
|
|
@@ -17,7 +18,8 @@ if (!page || (page.status !== 'published' && !isAdmin)) {
|
|
|
17
18
|
return new Response(null, { status: 404, statusText: 'Not found' });
|
|
18
19
|
}
|
|
19
20
|
const bodyHtml = page.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo)) : '';
|
|
20
|
-
|
|
21
|
+
// ?designPreview=1 (admin-only) renders the staged Design-screen changes
|
|
22
|
+
const settings = effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies);
|
|
21
23
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
22
24
|
const PageTemplate = (page.template ? resolveDesign(settings).theme.pageTemplates[page.template] : null) as any;
|
|
23
25
|
---
|
|
@@ -5,7 +5,8 @@ import { createPreviewToken } from '../../../../lib/preview';
|
|
|
5
5
|
import { resolveDesign } from '../../../../lib/theme';
|
|
6
6
|
import { env } from '../../../../lib/env';
|
|
7
7
|
import { BRAND } from '../../../../lib/brand';
|
|
8
|
-
import { CONTENT } from '../../../../lib/content';
|
|
8
|
+
import { CONTENT, collectionUrl } from '../../../../lib/content';
|
|
9
|
+
import { mergePostDraft } from '../../../../lib/drafts';
|
|
9
10
|
import PostEditor from '../../../../components/editor/PostEditor';
|
|
10
11
|
import '../../../../styles/admin.css';
|
|
11
12
|
import '../../../../styles/editor.css';
|
|
@@ -22,9 +23,14 @@ const post = await repo.getPost(id);
|
|
|
22
23
|
if (!post) return new Response(null, { status: 404 });
|
|
23
24
|
if (post.collection !== key) return Astro.redirect(`/admin/c/${post.collection}/${post.id}`);
|
|
24
25
|
|
|
26
|
+
// The editor hydrates from the pending draft when one exists (live posts
|
|
27
|
+
// stage their edits until Update); the live URL stays on the live slug.
|
|
28
|
+
const editable = mergePostDraft(post);
|
|
29
|
+
const livePath = collectionUrl(post.collection, post.slug);
|
|
30
|
+
|
|
25
31
|
// Canonical content is the TipTap doc (as HTML). Legacy markdown posts are
|
|
26
32
|
// converted on first open and become rich content on next save.
|
|
27
|
-
const initialHTML =
|
|
33
|
+
const initialHTML = editable.bodyHtml || (editable.body ? renderMarkdown(editable.body) : '');
|
|
28
34
|
const previewPath = `/preview/post/${post.id}`;
|
|
29
35
|
const sharePath = `${previewPath}?token=${createPreviewToken('post', post.id)}`;
|
|
30
36
|
const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
|
|
@@ -45,11 +51,12 @@ const templates = Object.keys(resolveDesign(settings).theme.postTemplates);
|
|
|
45
51
|
<body>
|
|
46
52
|
<PostEditor
|
|
47
53
|
client:only="react"
|
|
48
|
-
post={
|
|
54
|
+
post={editable}
|
|
49
55
|
initialHTML={initialHTML}
|
|
50
56
|
unsplashEnabled={unsplashEnabled}
|
|
51
57
|
previewPath={previewPath}
|
|
52
58
|
sharePath={sharePath}
|
|
59
|
+
livePath={livePath}
|
|
53
60
|
templates={templates}
|
|
54
61
|
/>
|
|
55
62
|
</body>
|
|
@@ -116,6 +116,7 @@ const filterHref = (patch: Record<string, string>) => {
|
|
|
116
116
|
</td>
|
|
117
117
|
<td>
|
|
118
118
|
<span class={`pill ${p.status}`}>{p.status}</span>
|
|
119
|
+
{p.draft && <span class="pill edited" title="Has pending edits — press Update in the editor to push them live">edited</span>}
|
|
119
120
|
{p.status === 'scheduled' && p.scheduledAt && (
|
|
120
121
|
<div class="t-sub" style="margin-top:2px">
|
|
121
122
|
{new Date(p.scheduledAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
|
|
@@ -4,12 +4,16 @@ import { getRepo } from '../../lib/repo';
|
|
|
4
4
|
import { env } from '../../lib/env';
|
|
5
5
|
import DesignManager from '../../components/DesignManager';
|
|
6
6
|
import { listThemes, FONT_PAIRINGS } from '../../lib/theme';
|
|
7
|
+
import { mergeDesignDraft } from '../../lib/drafts';
|
|
7
8
|
import { postUrl } from '../../lib/content';
|
|
8
9
|
// ImagePicker (logo upload) styles live in editor.css
|
|
9
10
|
import '../../styles/editor.css';
|
|
10
11
|
|
|
11
12
|
const repo = await getRepo(Astro.locals);
|
|
12
|
-
const
|
|
13
|
+
const liveSettings = await repo.getSettings();
|
|
14
|
+
// the manager edits the staged design when one exists; visitors keep the live one
|
|
15
|
+
const settings = mergeDesignDraft(liveSettings);
|
|
16
|
+
const pendingDesign = Boolean(liveSettings.draftDesign);
|
|
13
17
|
// most recent published post powers the preview's "Post" tab
|
|
14
18
|
const [latestPost] = await repo.listPublishedPosts();
|
|
15
19
|
const postPreviewPath = latestPost ? postUrl(latestPost.slug) : null;
|
|
@@ -34,6 +38,7 @@ const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
|
|
|
34
38
|
<DesignManager
|
|
35
39
|
client:only="react"
|
|
36
40
|
settings={{ ...settings, draftsApiKey: '' }}
|
|
41
|
+
pendingDesign={pendingDesign}
|
|
37
42
|
themes={themes}
|
|
38
43
|
pairings={[...FONT_PAIRINGS]}
|
|
39
44
|
unsplashEnabled={unsplashEnabled}
|
|
@@ -57,18 +57,21 @@ function ago(ts: number) {
|
|
|
57
57
|
<div id="acms-update" hidden data-current={coreVersion} class="card card-pad" style="display:none;align-items:center;gap:10px;margin-bottom:14px;border-left:3px solid var(--accent)">
|
|
58
58
|
<div>
|
|
59
59
|
<b>ACMS <span id="acms-update-latest"></span> is available</b>
|
|
60
|
-
<span class="hint" style="margin-left:6px">you're on {coreVersion} — run <code>npx acms update</code
|
|
60
|
+
<span class="hint" style="margin-left:6px">you're on {coreVersion} — run <code>npx acms update</code>; it prints the deploy steps for your setup.</span>
|
|
61
61
|
</div>
|
|
62
|
-
<a class="btn sm ghost" style="margin-left:auto" href="https://
|
|
62
|
+
<a id="acms-update-notes" class="btn sm ghost" style="margin-left:auto" href="https://www.npmjs.com/package/@acms-subir/core" target="_blank" rel="noopener">What's new</a>
|
|
63
|
+
<button id="acms-update-dismiss" class="btn sm ghost" type="button" aria-label="Dismiss until the next release" title="Dismiss until the next release">×</button>
|
|
63
64
|
</div>
|
|
64
65
|
<script>
|
|
65
66
|
// Ghost-style update nudge: npm registry check, cached a day, silent on
|
|
66
|
-
// any failure (offline, unpublished package, CORS).
|
|
67
|
+
// any failure (offline, unpublished package, CORS). Dismissal is stored
|
|
68
|
+
// per-version, so the banner returns on the next release.
|
|
67
69
|
(async () => {
|
|
68
70
|
const el = document.getElementById('acms-update');
|
|
69
71
|
if (!el) return;
|
|
70
72
|
const current = el.dataset.current ?? '';
|
|
71
73
|
const KEY = 'acms-update-check';
|
|
74
|
+
const DISMISSED = 'acms-update-dismissed';
|
|
72
75
|
let latest = '';
|
|
73
76
|
try {
|
|
74
77
|
const cached = JSON.parse(localStorage.getItem(KEY) ?? 'null');
|
|
@@ -82,16 +85,30 @@ function ago(ts: number) {
|
|
|
82
85
|
}
|
|
83
86
|
} catch { return; }
|
|
84
87
|
const lt = (a: string, b: string) => {
|
|
85
|
-
|
|
88
|
+
// prerelease-blind on purpose except for the base-equal case: a site
|
|
89
|
+
// pinned to a canary (0.1.1-next.x) should be nudged once the real
|
|
90
|
+
// 0.1.1 lands on the latest tag, and never before
|
|
91
|
+
const pa = a.split('-')[0].split('.').map(Number), pb = b.split('-')[0].split('.').map(Number);
|
|
86
92
|
for (let i = 0; i < 3; i++) { if ((pa[i] ?? 0) < (pb[i] ?? 0)) return true; if ((pa[i] ?? 0) > (pb[i] ?? 0)) return false; }
|
|
87
|
-
return
|
|
93
|
+
return a.includes('-') && !b.includes('-');
|
|
88
94
|
};
|
|
89
|
-
if (latest
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
95
|
+
if (!latest || !lt(current, latest)) return;
|
|
96
|
+
try { if (localStorage.getItem(DISMISSED) === latest) return; } catch {}
|
|
97
|
+
const slot = document.getElementById('acms-update-latest');
|
|
98
|
+
if (slot) slot.textContent = latest;
|
|
99
|
+
// deep-link to the version's npm page — the repository is self-hosted and
|
|
100
|
+
// private, so npm is the only public place this release exists
|
|
101
|
+
const notes = document.getElementById('acms-update-notes');
|
|
102
|
+
if (notes instanceof HTMLAnchorElement) {
|
|
103
|
+
notes.href = `https://www.npmjs.com/package/@acms-subir/core/v/${encodeURIComponent(latest)}`;
|
|
94
104
|
}
|
|
105
|
+
document.getElementById('acms-update-dismiss')?.addEventListener('click', () => {
|
|
106
|
+
try { localStorage.setItem(DISMISSED, latest); } catch {}
|
|
107
|
+
el.hidden = true;
|
|
108
|
+
el.style.display = 'none';
|
|
109
|
+
});
|
|
110
|
+
el.hidden = false;
|
|
111
|
+
el.style.display = 'flex';
|
|
95
112
|
})();
|
|
96
113
|
</script>
|
|
97
114
|
|
|
@@ -5,6 +5,8 @@ import { createPreviewToken } from '../../../lib/preview';
|
|
|
5
5
|
import { resolveDesign } from '../../../lib/theme';
|
|
6
6
|
import { env } from '../../../lib/env';
|
|
7
7
|
import { BRAND } from '../../../lib/brand';
|
|
8
|
+
import { mergePageDraft } from '../../../lib/drafts';
|
|
9
|
+
import { localeHomeUrl } from '../../../lib/i18n';
|
|
8
10
|
import PostEditor from '../../../components/editor/PostEditor';
|
|
9
11
|
import '../../../styles/admin.css';
|
|
10
12
|
import '../../../styles/editor.css';
|
|
@@ -14,9 +16,14 @@ const id = Astro.params.id!;
|
|
|
14
16
|
const page = await repo.getPage(id);
|
|
15
17
|
if (!page) return new Response(null, { status: 404 });
|
|
16
18
|
|
|
19
|
+
// The editor hydrates from the pending draft when one exists (live pages
|
|
20
|
+
// stage their edits until Update); the live URL stays on the live slug.
|
|
21
|
+
const editable = mergePageDraft(page);
|
|
22
|
+
const livePath = page.isHome ? localeHomeUrl(page.locale) : `/${page.slug}`;
|
|
23
|
+
|
|
17
24
|
// Canonical content is the TipTap doc (as HTML). Legacy widget-block pages
|
|
18
25
|
// are converted on first open and become rich content on next save.
|
|
19
|
-
const initialHTML =
|
|
26
|
+
const initialHTML = editable.bodyHtml || blocksToHtml(editable.blocks);
|
|
20
27
|
const previewPath = `/preview/page/${page.id}`;
|
|
21
28
|
const sharePath = `${previewPath}?token=${createPreviewToken('page', page.id)}`;
|
|
22
29
|
const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
|
|
@@ -38,11 +45,12 @@ const templates = Object.keys(resolveDesign(settings).theme.pageTemplates);
|
|
|
38
45
|
<PostEditor
|
|
39
46
|
client:only="react"
|
|
40
47
|
kind="page"
|
|
41
|
-
page={
|
|
48
|
+
page={editable}
|
|
42
49
|
initialHTML={initialHTML}
|
|
43
50
|
unsplashEnabled={unsplashEnabled}
|
|
44
51
|
previewPath={previewPath}
|
|
45
52
|
sharePath={sharePath}
|
|
53
|
+
livePath={livePath}
|
|
46
54
|
templates={templates}
|
|
47
55
|
/>
|
|
48
56
|
</body>
|
|
@@ -70,6 +70,7 @@ const fmt = (ts: number) => new Date(ts).toLocaleDateString('en-US', { month: 's
|
|
|
70
70
|
</td>
|
|
71
71
|
<td>
|
|
72
72
|
<span class={`pill ${p.status}`}>{p.status}</span>
|
|
73
|
+
{p.draft && <span class="pill edited" title="Has pending edits — press Update in the editor to push them live">edited</span>}
|
|
73
74
|
{p.isHome && <span class="pill home">home</span>}
|
|
74
75
|
{I18N.enabled && <span class="pill lang">{(p.locale || I18N.defaultLocale).toUpperCase()}</span>}
|
|
75
76
|
</td>
|
|
@@ -4,6 +4,7 @@ import { getRepo } from '../../lib/repo';
|
|
|
4
4
|
import { verifySessionToken, OWNER_ENV, SESSION_COOKIE } from '../../lib/auth';
|
|
5
5
|
import { invalidateRedirectCache } from '../../middleware';
|
|
6
6
|
import { CONTENT } from '../../lib/content';
|
|
7
|
+
import { UMAMI_ID_RE, META_PIXEL_ID_RE, TIKTOK_PIXEL_ID_RE, normalizeScriptUrl } from '../../lib/analytics';
|
|
7
8
|
|
|
8
9
|
const repo = await getRepo(Astro.locals);
|
|
9
10
|
|
|
@@ -77,6 +78,57 @@ if (Astro.request.method === 'POST') {
|
|
|
77
78
|
});
|
|
78
79
|
flash = 'Code injection saved.';
|
|
79
80
|
}
|
|
81
|
+
} else if (action === 'analytics') {
|
|
82
|
+
if (!isOwner) {
|
|
83
|
+
flashError = 'Only owners can edit analytics.';
|
|
84
|
+
} else {
|
|
85
|
+
// replay sample rate is edited as a percent, stored as a fraction
|
|
86
|
+
const replayPct = Number(String(form.get('umamiReplaySampleRate') ?? '15').trim());
|
|
87
|
+
const replayDuration = Number(String(form.get('umamiReplayMaxDuration') ?? '300000').trim());
|
|
88
|
+
const umami = {
|
|
89
|
+
enabled: form.get('umamiEnabled') === 'on',
|
|
90
|
+
websiteId: String(form.get('umamiWebsiteId') ?? '').trim(),
|
|
91
|
+
scriptUrl: String(form.get('umamiScriptUrl') ?? '').trim() || 'https://cloud.umami.is/script.js',
|
|
92
|
+
replay: {
|
|
93
|
+
enabled: form.get('umamiReplayEnabled') === 'on',
|
|
94
|
+
sampleRate: Math.round(replayPct) / 100,
|
|
95
|
+
maskLevel: (form.get('umamiReplayMaskLevel') === 'strict' ? 'strict' : 'moderate') as 'strict' | 'moderate',
|
|
96
|
+
maxDuration: replayDuration,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
const metaPixel = {
|
|
100
|
+
enabled: form.get('metaEnabled') === 'on',
|
|
101
|
+
pixelId: String(form.get('metaPixelId') ?? '').trim(),
|
|
102
|
+
};
|
|
103
|
+
const tiktokPixel = {
|
|
104
|
+
enabled: form.get('tiktokEnabled') === 'on',
|
|
105
|
+
pixelId: String(form.get('tiktokPixelId') ?? '').trim(),
|
|
106
|
+
};
|
|
107
|
+
// reject bad values even while disabled — a stored ID must always be
|
|
108
|
+
// renderable, since these end up in set:html on every public page
|
|
109
|
+
if (umami.websiteId && !UMAMI_ID_RE.test(umami.websiteId)) {
|
|
110
|
+
flashError = 'Umami website ID should be a UUID — copy it from your Umami dashboard (Settings → Websites → Edit).';
|
|
111
|
+
} else if (!normalizeScriptUrl(umami.scriptUrl)) {
|
|
112
|
+
flashError = 'Umami script URL must be an https:// URL, e.g. https://cloud.umami.is/script.js';
|
|
113
|
+
} else if (!Number.isFinite(replayPct) || replayPct < 1 || replayPct > 100) {
|
|
114
|
+
flashError = 'Replay sample rate must be between 1 and 100 percent.';
|
|
115
|
+
} else if (!Number.isInteger(replayDuration) || replayDuration <= 0) {
|
|
116
|
+
flashError = 'Replay max duration is invalid.';
|
|
117
|
+
} else if (metaPixel.pixelId && !META_PIXEL_ID_RE.test(metaPixel.pixelId)) {
|
|
118
|
+
flashError = 'Meta Pixel ID should be numbers only — find it in Meta Events Manager.';
|
|
119
|
+
} else if (tiktokPixel.pixelId && !TIKTOK_PIXEL_ID_RE.test(tiktokPixel.pixelId)) {
|
|
120
|
+
flashError = 'TikTok Pixel ID should be letters and numbers only — find it in TikTok Events Manager.';
|
|
121
|
+
} else if (umami.enabled && !umami.websiteId) {
|
|
122
|
+
flashError = 'Enter the Umami website ID to enable it.';
|
|
123
|
+
} else if (metaPixel.enabled && !metaPixel.pixelId) {
|
|
124
|
+
flashError = 'Enter the Meta Pixel ID to enable it.';
|
|
125
|
+
} else if (tiktokPixel.enabled && !tiktokPixel.pixelId) {
|
|
126
|
+
flashError = 'Enter the TikTok Pixel ID to enable it.';
|
|
127
|
+
} else {
|
|
128
|
+
await repo.updateSettings({ analytics: { umami, metaPixel, tiktokPixel } });
|
|
129
|
+
flash = 'Analytics saved.';
|
|
130
|
+
}
|
|
131
|
+
}
|
|
80
132
|
} else if (action === 'announcement') {
|
|
81
133
|
// link must be a relative path or http(s) URL — never javascript: etc.
|
|
82
134
|
const rawLink = String(form.get('annLink') ?? '').trim();
|
|
@@ -314,6 +366,102 @@ const remainingPresets = ORIGIN_PRESETS.filter((p) => !settings.draftsAllowedOri
|
|
|
314
366
|
</div>
|
|
315
367
|
)}
|
|
316
368
|
|
|
369
|
+
{isOwner && (
|
|
370
|
+
<div class="card card-pad">
|
|
371
|
+
<p class="card-title">Analytics</p>
|
|
372
|
+
<p style="margin:0">
|
|
373
|
+
Traffic and ad tracking on every public page. Only the ID is stored — the tracking code itself
|
|
374
|
+
is rendered by the CMS. The admin is never tracked. Mind visitor-consent rules (GDPR) if you
|
|
375
|
+
enable the ad pixels.
|
|
376
|
+
</p>
|
|
377
|
+
<form method="post">
|
|
378
|
+
<input type="hidden" name="action" value="analytics" />
|
|
379
|
+
|
|
380
|
+
<div style="border-top:1px solid var(--line);margin-top:14px;padding-top:12px">
|
|
381
|
+
<label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
|
|
382
|
+
<input type="checkbox" name="umamiEnabled" checked={settings.analytics.umami.enabled} />
|
|
383
|
+
<span style="font-weight:600">Umami</span>
|
|
384
|
+
</label>
|
|
385
|
+
<div class="field-row">
|
|
386
|
+
<label class="field">
|
|
387
|
+
<span>Website ID</span>
|
|
388
|
+
<input type="text" name="umamiWebsiteId" value={settings.analytics.umami.websiteId} placeholder="94db1cb1-74f4-4a40-ad6c-962362670409" style="font-family:monospace;font-size:12.5px" />
|
|
389
|
+
</label>
|
|
390
|
+
<label class="field">
|
|
391
|
+
<span>Script URL</span>
|
|
392
|
+
<input type="text" name="umamiScriptUrl" value={settings.analytics.umami.scriptUrl} placeholder="https://cloud.umami.is/script.js" style="font-family:monospace;font-size:12.5px" />
|
|
393
|
+
</label>
|
|
394
|
+
</div>
|
|
395
|
+
<p class="hint" style="margin:0">
|
|
396
|
+
Privacy-friendly stats: pageviews, referrers, devices, countries, visit duration.
|
|
397
|
+
Umami Cloud by default — point Script URL at your own instance if self-hosting.
|
|
398
|
+
</p>
|
|
399
|
+
<div style="margin:10px 0 0 24px;padding:12px 14px;background:var(--panel-2);border:1px solid var(--line);border-radius:10px">
|
|
400
|
+
<label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
|
|
401
|
+
<input type="checkbox" name="umamiReplayEnabled" checked={settings.analytics.umami.replay.enabled} />
|
|
402
|
+
<span style="font-weight:600">Session replay</span>
|
|
403
|
+
</label>
|
|
404
|
+
<div class="field-row">
|
|
405
|
+
<label class="field">
|
|
406
|
+
<span>Sample rate (%)</span>
|
|
407
|
+
<input type="number" name="umamiReplaySampleRate" min="1" max="100" step="1" value={Math.round(settings.analytics.umami.replay.sampleRate * 100)} />
|
|
408
|
+
</label>
|
|
409
|
+
<label class="field">
|
|
410
|
+
<span>Mask level</span>
|
|
411
|
+
<select name="umamiReplayMaskLevel">
|
|
412
|
+
<option value="moderate" selected={settings.analytics.umami.replay.maskLevel === 'moderate'}>moderate</option>
|
|
413
|
+
<option value="strict" selected={settings.analytics.umami.replay.maskLevel === 'strict'}>strict</option>
|
|
414
|
+
</select>
|
|
415
|
+
</label>
|
|
416
|
+
<label class="field">
|
|
417
|
+
<span>Max duration</span>
|
|
418
|
+
<select name="umamiReplayMaxDuration">
|
|
419
|
+
<option value="60000" selected={settings.analytics.umami.replay.maxDuration === 60000}>1 minute</option>
|
|
420
|
+
<option value="300000" selected={settings.analytics.umami.replay.maxDuration === 300000}>5 minutes</option>
|
|
421
|
+
<option value="600000" selected={settings.analytics.umami.replay.maxDuration === 600000}>10 minutes</option>
|
|
422
|
+
<option value="1800000" selected={settings.analytics.umami.replay.maxDuration === 1800000}>30 minutes</option>
|
|
423
|
+
</select>
|
|
424
|
+
</label>
|
|
425
|
+
</div>
|
|
426
|
+
<p class="hint" style="margin:0">
|
|
427
|
+
Records a sample of visitor sessions for playback in Umami (v3.1+, Behavior → Replays).
|
|
428
|
+
Umami keeps replays for 30 days. Inputs are masked — mind visitor-consent rules (GDPR) before enabling.
|
|
429
|
+
</p>
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
|
|
433
|
+
<div style="border-top:1px solid var(--line);margin-top:14px;padding-top:12px">
|
|
434
|
+
<label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
|
|
435
|
+
<input type="checkbox" name="metaEnabled" checked={settings.analytics.metaPixel.enabled} />
|
|
436
|
+
<span style="font-weight:600">Meta Pixel</span>
|
|
437
|
+
</label>
|
|
438
|
+
<label class="field">
|
|
439
|
+
<span>Pixel ID</span>
|
|
440
|
+
<input type="text" name="metaPixelId" value={settings.analytics.metaPixel.pixelId} placeholder="724159876543210" style="font-family:monospace;font-size:12.5px;max-width:320px" />
|
|
441
|
+
</label>
|
|
442
|
+
<p class="hint" style="margin:0">Tracks page views for Facebook/Instagram ad attribution and retargeting audiences.</p>
|
|
443
|
+
</div>
|
|
444
|
+
|
|
445
|
+
<div style="border-top:1px solid var(--line);margin-top:14px;padding-top:12px">
|
|
446
|
+
<label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
|
|
447
|
+
<input type="checkbox" name="tiktokEnabled" checked={settings.analytics.tiktokPixel.enabled} />
|
|
448
|
+
<span style="font-weight:600">TikTok Pixel <span style="display:inline-block;font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:0.05em;padding:2px 8px;border-radius:999px;background:var(--amber-soft);color:var(--amber);vertical-align:1px">Experimental</span></span>
|
|
449
|
+
</label>
|
|
450
|
+
<label class="field">
|
|
451
|
+
<span>Pixel ID</span>
|
|
452
|
+
<input type="text" name="tiktokPixelId" value={settings.analytics.tiktokPixel.pixelId} placeholder="CABC123DEF456GHI789" style="font-family:monospace;font-size:12.5px;max-width:320px" />
|
|
453
|
+
</label>
|
|
454
|
+
<p class="hint" style="margin:0">
|
|
455
|
+
Untested integration — the snippet follows TikTok’s published base code, but verify events
|
|
456
|
+
in TikTok Events Manager before relying on it.
|
|
457
|
+
</p>
|
|
458
|
+
</div>
|
|
459
|
+
|
|
460
|
+
<div class="form-actions"><button class="btn primary" type="submit">Save</button></div>
|
|
461
|
+
</form>
|
|
462
|
+
</div>
|
|
463
|
+
)}
|
|
464
|
+
|
|
317
465
|
<div class="card card-pad">
|
|
318
466
|
<p class="card-title">Announcement bar</p>
|
|
319
467
|
<form method="post">
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { APIRoute } from 'astro';
|
|
2
|
+
import { getRepo } from '../../lib/repo';
|
|
3
|
+
import { authorSlug, publicTags } from '../../lib/schema';
|
|
4
|
+
import { collectionUrl, tagUrl } from '../../lib/content';
|
|
5
|
+
import { localeHomeUrl, normalizePageLocale } from '../../lib/i18n';
|
|
6
|
+
import { formatDate } from '../../lib/dates';
|
|
7
|
+
|
|
8
|
+
// Public search API behind the site's search modal. Published-only by
|
|
9
|
+
// construction — the same contract as rss/sitemap — so it needs no auth and
|
|
10
|
+
// the middleware already leaves /api alone. `locale` boosts the visitor's
|
|
11
|
+
// language to the top of page results; it never filters. Snippets are safe
|
|
12
|
+
// HTML (entity-escaped, <mark> around matches) ready for innerHTML.
|
|
13
|
+
|
|
14
|
+
const LIMITS = { posts: 10, pages: 5, tags: 5, authors: 5 };
|
|
15
|
+
|
|
16
|
+
export const GET: APIRoute = async ({ locals, url }) => {
|
|
17
|
+
const headers = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'public, max-age=60' };
|
|
18
|
+
const q = (url.searchParams.get('q') ?? '').trim().slice(0, 100);
|
|
19
|
+
if (!q) return new Response(JSON.stringify({ posts: [], pages: [], tags: [], authors: [] }), { headers });
|
|
20
|
+
|
|
21
|
+
const repo = await getRepo(locals);
|
|
22
|
+
const locale = normalizePageLocale(url.searchParams.get('locale') ?? '');
|
|
23
|
+
const [results, settings] = await Promise.all([repo.searchPublished(q, { locale }), repo.getSettings()]);
|
|
24
|
+
|
|
25
|
+
const body = {
|
|
26
|
+
posts: results.posts.slice(0, LIMITS.posts).map(({ doc: p, snippet }) => ({
|
|
27
|
+
title: p.title,
|
|
28
|
+
url: collectionUrl(p.collection, p.slug),
|
|
29
|
+
excerpt: p.excerpt,
|
|
30
|
+
snippet,
|
|
31
|
+
date: p.publishedAt ? formatDate(p.publishedAt, settings) : '',
|
|
32
|
+
tags: publicTags(p.tags),
|
|
33
|
+
})),
|
|
34
|
+
pages: results.pages.slice(0, LIMITS.pages).map(({ doc: p, snippet }) => ({
|
|
35
|
+
title: p.title,
|
|
36
|
+
url: p.isHome ? localeHomeUrl(p.locale) : `/${p.slug}`,
|
|
37
|
+
snippet,
|
|
38
|
+
locale: p.locale,
|
|
39
|
+
})),
|
|
40
|
+
tags: results.tags.slice(0, LIMITS.tags).map(({ tag, count }) => ({ name: tag.name, url: tagUrl(tag.slug), count })),
|
|
41
|
+
authors: results.authors.slice(0, LIMITS.authors).map((u) => ({ name: u.name, url: `/author/${authorSlug(u)}`, avatar: u.avatar })),
|
|
42
|
+
};
|
|
43
|
+
return new Response(JSON.stringify(body), { headers });
|
|
44
|
+
};
|
|
@@ -7,6 +7,7 @@ import { highlightCodeHtml } from '../../lib/highlightCode';
|
|
|
7
7
|
import { verifySessionToken, SESSION_COOKIE } from '../../lib/auth';
|
|
8
8
|
import { slugify, authorSlug, publicTags } from '../../lib/schema';
|
|
9
9
|
import { resolveDesign } from '../../lib/theme';
|
|
10
|
+
import { effectiveSettings } from '../../lib/designPreview';
|
|
10
11
|
import { siteOrigin } from '../../lib/seo';
|
|
11
12
|
import { formatDate } from '../../lib/dates';
|
|
12
13
|
import { collectionFromPath, collectionUrl, tagUrl } from '../../lib/content';
|
|
@@ -24,13 +25,15 @@ if (!post || (post.status !== 'published' && !isAdmin)) {
|
|
|
24
25
|
return new Response(null, { status: 404, statusText: 'Not found' });
|
|
25
26
|
}
|
|
26
27
|
const authorIds = post.authorIds.length ? post.authorIds : post.authorId ? [post.authorId] : [];
|
|
27
|
-
const [tags, allUsers, allPosts,
|
|
28
|
+
const [tags, allUsers, allPosts, rawSettings, allMedia] = await Promise.all([
|
|
28
29
|
repo.listTags(),
|
|
29
30
|
authorIds.length ? repo.listUsers() : Promise.resolve([]),
|
|
30
31
|
repo.listPublishedPosts(col.key), // related reading stays within the collection
|
|
31
32
|
repo.getSettings(),
|
|
32
33
|
post.cover.startsWith('/media/') ? repo.listMedia() : Promise.resolve([]),
|
|
33
34
|
]);
|
|
35
|
+
// ?designPreview=1 (admin-only) renders the staged Design-screen changes
|
|
36
|
+
const settings = effectiveSettings(rawSettings, Astro.url, Astro.cookies);
|
|
34
37
|
// ordered author objects; the first is the primary (gets the author card)
|
|
35
38
|
const authors = authorIds
|
|
36
39
|
.map((id) => allUsers.find((u) => u.id === id))
|
package/src/pages/index.astro
CHANGED
|
@@ -3,6 +3,7 @@ import Site from '../layouts/Site.astro';
|
|
|
3
3
|
import WidgetRenderer from '../components/WidgetRenderer.astro';
|
|
4
4
|
import { getRepo } from '../lib/repo';
|
|
5
5
|
import { resolveDesign } from '../lib/theme';
|
|
6
|
+
import { effectiveSettings } from '../lib/designPreview';
|
|
6
7
|
import { expandDynamicBlocks } from '../lib/dynamicBlocks';
|
|
7
8
|
import { I18N, localeFromPath } from '../lib/i18n';
|
|
8
9
|
|
|
@@ -11,10 +12,12 @@ const repo = await getRepo(Astro.locals);
|
|
|
11
12
|
// resolved from the request path (injectRoute can't pass props)
|
|
12
13
|
const locale = I18N.enabled ? localeFromPath(Astro.url.pathname) : I18N.defaultLocale;
|
|
13
14
|
const wantsLocaleHome = I18N.enabled && locale !== I18N.defaultLocale;
|
|
14
|
-
let [home,
|
|
15
|
+
let [home, rawSettings] = await Promise.all([
|
|
15
16
|
repo.getHomePage(wantsLocaleHome ? locale : undefined),
|
|
16
17
|
repo.getSettings(),
|
|
17
18
|
]);
|
|
19
|
+
// ?designPreview=1 (admin-only) renders the staged Design-screen changes
|
|
20
|
+
const settings = effectiveSettings(rawSettings, Astro.url, Astro.cookies);
|
|
18
21
|
// a locale home that isn't authored yet falls back to the default home —
|
|
19
22
|
// /es must never 404 mid-setup
|
|
20
23
|
if (wantsLocaleHome && !home) home = await repo.getHomePage();
|
package/src/pages/links.astro
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Site from '../layouts/Site.astro';
|
|
3
3
|
import { getRepo } from '../lib/repo';
|
|
4
|
+
import { cleanEventName } from '../lib/analytics';
|
|
4
5
|
|
|
5
6
|
const repo = await getRepo(Astro.locals);
|
|
6
7
|
const [links, settings] = await Promise.all([repo.listLinks(), repo.getSettings()]);
|
|
@@ -11,6 +12,11 @@ const profileBio = settings.linksBio || settings.tagline;
|
|
|
11
12
|
|
|
12
13
|
const SPACER_PX: Record<string, number> = { s: 16, m: 36, l: 72 };
|
|
13
14
|
|
|
15
|
+
// every link button is auto-stamped with an analytics event so per-link click
|
|
16
|
+
// counts (sponsors!) show up in Umami alongside the first-party /go counter
|
|
17
|
+
const linkEvent = (label: string): string | undefined =>
|
|
18
|
+
cleanEventName(`Link: ${label}`) === 'Link:' ? undefined : cleanEventName(`Link: ${label}`);
|
|
19
|
+
|
|
14
20
|
// icon column convention: '<svg…' inline markup, http// image URL,
|
|
15
21
|
// 'favicon' derive from the link's host, anything else = emoji/text
|
|
16
22
|
function iconInfo(l: (typeof links)[number]): { type: 'svg' | 'img' | 'emoji' | 'none'; value: string } {
|
|
@@ -96,13 +102,13 @@ const visible = links.filter((l) => {
|
|
|
96
102
|
case 'html':
|
|
97
103
|
return <div class="link-html" set:html={l.html} />;
|
|
98
104
|
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>;
|
|
105
|
+
return <a class="link-btn" style={buttonStyle(l)} href={`mailto:${l.url}`} data-umami-event={linkEvent(l.label || l.url)}>{icon}<span class="link-label">{l.label || l.url}</span></a>;
|
|
100
106
|
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>;
|
|
107
|
+
return <a class="link-btn" style={buttonStyle(l)} href={`tel:${l.url.replace(/[^+\d]/g, '')}`} data-umami-event={linkEvent(l.label || l.url)}>{icon}<span class="link-label">{l.label || l.url}</span></a>;
|
|
102
108
|
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>;
|
|
109
|
+
return <a class="link-btn" style={buttonStyle(l)} href={vcardHref(l)} download={`${(l.meta.firstName as string) || 'contact'}.vcf`} data-umami-event={linkEvent(l.label || 'Save contact')}>{icon}<span class="link-label">{l.label || 'Save contact'}</span></a>;
|
|
104
110
|
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>;
|
|
111
|
+
return <a class="link-btn" style={buttonStyle(l)} href={`/go/${l.id}`} rel="noopener" data-umami-event={linkEvent(l.label)}>{icon}<span class="link-label">{l.label}</span></a>;
|
|
106
112
|
}
|
|
107
113
|
})}
|
|
108
114
|
{visible.length === 0 && <p>No links yet.</p>}
|
|
@@ -7,9 +7,12 @@ import { verifyPreviewToken } from '../../../lib/preview';
|
|
|
7
7
|
import { expandDynamicBlocks } from '../../../lib/dynamicBlocks';
|
|
8
8
|
import { highlightCodeHtml } from '../../../lib/highlightCode';
|
|
9
9
|
import { verifySessionToken, SESSION_COOKIE } from '../../../lib/auth';
|
|
10
|
+
import { mergePageDraft, mergePostDraft } from '../../../lib/drafts';
|
|
10
11
|
|
|
11
12
|
// Draft preview, any status. Reachable by a logged-in admin (the editor's
|
|
12
|
-
// preview iframe) or by anyone holding a signed share token.
|
|
13
|
+
// preview iframe) or by anyone holding a signed share token. Live documents
|
|
14
|
+
// with pending edits render the PENDING version — exactly what Update will
|
|
15
|
+
// publish — while the public URL keeps serving the live one.
|
|
13
16
|
const { type, id } = Astro.params;
|
|
14
17
|
if (type !== 'post' && type !== 'page') return new Response(null, { status: 404 });
|
|
15
18
|
|
|
@@ -20,9 +23,11 @@ if (!isAdmin && !verifyPreviewToken(type, id!, token)) {
|
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
const repo = await getRepo(Astro.locals);
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
if (!
|
|
26
|
+
const livePost = type === 'post' ? await repo.getPost(id!) : null;
|
|
27
|
+
const livePage = type === 'page' ? await repo.getPage(id!) : null;
|
|
28
|
+
if (!livePost && !livePage) return new Response(null, { status: 404 });
|
|
29
|
+
const post = livePost ? mergePostDraft(livePost) : null;
|
|
30
|
+
const page = livePage ? mergePageDraft(livePage) : null;
|
|
26
31
|
|
|
27
32
|
const html = post ? highlightCodeHtml(await expandDynamicBlocks(post.bodyHtml || renderMarkdown(post.body), repo)) : '';
|
|
28
33
|
const pageHtml = page?.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo)) : '';
|