Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions drizzle/0011_fts5_external_content.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
-- Fix #48: switch articles_fts from a contentless FTS5 table to an
-- external-content table (content='article_localizations',
-- content_rowid='rowid').
--
-- The contentless pattern from migration 0002 requires the
-- ('delete', old.rowid, …all column values…) tuple to **exactly
-- match** what's stored in the index. Any drift — different
-- normalization, trailing newline, prior failed REPLACE — makes the
-- delete-trigger fail with SQLITE_ERROR. UPDATE inherits that fault.
-- After-effect on the CMS: editors couldn't update or delete an
-- article_localizations row once any drift had occurred.
--
-- The external-content pattern lets the trigger reference rowid
-- alone:
-- INSERT INTO articles_fts(articles_fts, rowid) VALUES('delete', old.rowid);
-- — no column values to match against, so drift becomes a non-issue.
-- And `rebuild` can recover from any inconsistency without dropping
-- the table.
--
-- Migration steps:
-- 1. Drop the old triggers (must happen before dropping the table).
-- 2. Drop the old virtual table.
-- 3. Recreate as external-content.
-- 4. Rebuild the index from article_localizations in one shot.
-- 5. Recreate triggers using the simpler delete pattern.

DROP TRIGGER IF EXISTS articles_fts_ai;
--> statement-breakpoint
DROP TRIGGER IF EXISTS articles_fts_ad;
--> statement-breakpoint
DROP TRIGGER IF EXISTS articles_fts_au;
--> statement-breakpoint
DROP TABLE IF EXISTS articles_fts;
--> statement-breakpoint

CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(
title,
excerpt,
body,
locale UNINDEXED,
article_id UNINDEXED,
content = 'article_localizations',
content_rowid = 'rowid',
tokenize = 'unicode61 remove_diacritics 2'
);
--> statement-breakpoint

-- Build the index from current state. With external-content, this
-- reads from article_localizations directly (the `content =` link
-- above tells FTS5 where to look).
INSERT INTO articles_fts(articles_fts) VALUES('rebuild');
--> statement-breakpoint

-- Triggers — simpler than before because external-content tables
-- only need rowid for delete (the source-of-truth values come from
-- the linked table itself).

CREATE TRIGGER IF NOT EXISTS articles_fts_ai AFTER INSERT ON article_localizations BEGIN
INSERT INTO articles_fts(rowid, title, excerpt, body, locale, article_id)
VALUES (new.rowid, new.title, COALESCE(new.excerpt, ''), new.body, new.locale, new.article_id);
END;
--> statement-breakpoint

CREATE TRIGGER IF NOT EXISTS articles_fts_ad AFTER DELETE ON article_localizations BEGIN
INSERT INTO articles_fts(articles_fts, rowid) VALUES('delete', old.rowid);
END;
--> statement-breakpoint

CREATE TRIGGER IF NOT EXISTS articles_fts_au AFTER UPDATE ON article_localizations BEGIN
INSERT INTO articles_fts(articles_fts, rowid) VALUES('delete', old.rowid);
INSERT INTO articles_fts(rowid, title, excerpt, body, locale, article_id)
VALUES (new.rowid, new.title, COALESCE(new.excerpt, ''), new.body, new.locale, new.article_id);
END;
7 changes: 7 additions & 0 deletions drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@
"when": 1777709468165,
"tag": "0010_windy_carmella_unuscione",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1778060000000,
"tag": "0011_fts5_external_content",
"breakpoints": true
}
]
}
1 change: 1 addition & 0 deletions src/lib/components/consent/CookieBanner.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
<p class="font-medium text-foreground mb-1">{m.cookie_banner_title()}</p>
<p>
{m.cookie_banner_body()}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -- privacy href is operator-supplied (always /[locale]/privacy-policy from layout); not a build-time route -->
<a href={privacyHref} class="underline hover:text-foreground">{m.cookie_banner_learn_more()}</a>
</p>
{#if detailsOpen}
Expand Down
11 changes: 9 additions & 2 deletions src/lib/components/seo/Seo.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,16 @@
<meta name="twitter:site" content={defaults.twitter} />
{/if}

<!-- JSON-LD: one <script> per entry for maximum tooling compatibility -->
<!-- JSON-LD: one <script> per entry for maximum tooling compatibility.
Using {@html} is required to emit a literal <script> tag (Svelte
strips them in normal markup). The payload is server-built JSON
from our own typed builders ($lib/seo) — never user input — and
we escape any "</script>" sequence before injection so a JSON
value can't break out of the tag. -->
{#each jsonLd as ld, i (i)}
{@html `<script type="application/ld+json">${JSON.stringify(ld)}<\/script>`}
{@const safeJson = JSON.stringify(ld).replace(/<\/script>/gi, '<\\/script>')}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted: server-built JSON-LD with </script> escaped -->
{@html '<script type="application/ld+json">' + safeJson + '<' + '/script>'}
{/each}

<!-- RSS auto-discovery -->
Expand Down
4 changes: 3 additions & 1 deletion src/lib/server/webhooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ async function deliverOne(
for (let attempt = 1; attempt <= MAX_INLINE_ATTEMPTS; attempt++) {
const t0 = Date.now();
let responseStatus: number | null = null;
let responseExcerpt: string | null = null;
// Assigned in every code path below; no initializer to satisfy
// no-useless-assignment.
let responseExcerpt: string | null;
let ok = false;
try {
const ctrl = new AbortController();
Expand Down
2 changes: 1 addition & 1 deletion src/routes/(cms)/cms/dashboard/+page.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { redirect, error } from "@sveltejs/kit";
import { drizzle } from "drizzle-orm/d1";
import { desc, eq, and, gte } from "drizzle-orm";
import { desc, eq, gte } from "drizzle-orm";
import * as schema from "$lib/server/content/schema";
import { canManageUsers } from "$lib/server/auth/permissions";
import { AnalyticsService } from "$lib/server/analytics";
Expand Down
5 changes: 4 additions & 1 deletion src/routes/(cms)/cms/media/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,11 @@
Boolean(data.user && ['super_admin', 'admin', 'editor'].includes(data.user.role)),
);

// Build a parent → children map for the folder tree.
// Build a parent → children map for the folder tree. Local to the
// derived; never read reactively (rebuilt every invocation), so a
// plain Map is correct — no need for SvelteMap's reactivity overhead.
const childrenByParent = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local lookup, not reactive state
const map = new Map<string | null, MediaFolderRecord[]>();
for (const f of data.folders) {
const parent = f.parentId ?? null;
Expand Down
1 change: 1 addition & 0 deletions src/routes/(www)/[locale]/[...slug]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
<h1 class="text-4xl font-bold mb-2">{data.title}</h1>
</header>
<div class="prose prose-neutral dark:prose-invert max-w-none">
<!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted: server-rendered markdown from CMS -->
{@html data.htmlContent}
</div>
</article>
Loading