diff --git a/package.json b/package.json index 4e6d0622..e4beac44 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.50.0", + "version": "1.51.0", "private": true, "type": "module", "scripts": { diff --git a/scripts/codemod-href-resolve-bucket2.ts b/scripts/codemod-href-resolve-bucket2.ts new file mode 100644 index 00000000..07bf8a5a --- /dev/null +++ b/scripts/codemod-href-resolve-bucket2.ts @@ -0,0 +1,268 @@ +/** + * Bucket 2 codemod: replace template-literal hrefs that start with `/` and + * contain `{expr}` interpolations with `resolve(routeId, { ... })`. + * + * Skips: + * - tags: , (svg), , + * - hrefs not starting with `/` + * - hrefs containing `?` or `#` (query/fragment) — handle manually + * - mixed segments like `view-{id}` + * - paths matching 0 or >1 routes + * + * Run: pnpm exec vite-node scripts/codemod-href-resolve-bucket2.ts [--dry] [--verbose] + */ + +import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'; +import { join, extname } from 'node:path'; + +const SRC = 'src'; +const ROUTES = 'src/routes'; +const DRY = process.argv.includes('--dry'); + +const SKIP_TAGS = new Set(['link', 'image', 'use', 'textpath']); + +// --- Route tree --------------------------------------------------------- + +type Dir = { name: string; subdirs: Dir[] }; + +function loadTree(dir: string, name = ''): Dir { + const subdirs: Dir[] = []; + for (const e of readdirSync(dir, { withFileTypes: true })) { + if (!e.isDirectory()) continue; + if (e.name === 'api' || e.name.startsWith('.')) continue; + subdirs.push(loadTree(join(dir, e.name), e.name)); + } + return { name, subdirs }; +} + +const ROUTE_TREE = loadTree(ROUTES); + +// --- Path parsing ------------------------------------------------------- + +type HrefSeg = { kind: 'literal'; text: string } | { kind: 'param'; expr: string }; + +function hasUnbracedChar(path: string, chars: string): boolean { + let depth = 0; + for (const c of path) { + if (c === '{') depth++; + else if (c === '}') depth--; + else if (depth === 0 && chars.includes(c)) return true; + } + return false; +} + +function parsePath(path: string): HrefSeg[] | null { + if (!path.startsWith('/')) return null; + if (hasUnbracedChar(path, '?#')) return null; + if (path.includes('//')) return null; + // Split on `/`, but only outside of {...} + const parts: string[] = []; + let buf = ''; + let depth = 0; + for (const c of path.slice(1)) { + if (c === '{') { depth++; buf += c; } + else if (c === '}') { depth--; buf += c; } + else if (c === '/' && depth === 0) { parts.push(buf); buf = ''; } + else buf += c; + } + parts.push(buf); + if (parts.length === 1 && parts[0] === '') return []; + const segs: HrefSeg[] = []; + for (const p of parts) { + if (p === '') return null; + const m = p.match(/^\{([^}]+)\}$/); + if (m) { + segs.push({ kind: 'param', expr: m[1] }); + } else if (!p.includes('{') && !p.includes('}')) { + segs.push({ kind: 'literal', text: p }); + } else { + return null; // mixed segment + } + } + return segs; +} + +function paramInfo( + name: string +): { paramName: string; isRest: boolean } | null { + let body = name; + if (body.startsWith('[[') && body.endsWith(']]')) { + body = body.slice(2, -2); + } else if (body.startsWith('[') && body.endsWith(']')) { + body = body.slice(1, -1); + } else return null; + const isRest = body.startsWith('...'); + if (isRest) body = body.slice(3); + const eq = body.indexOf('='); + const paramName = eq >= 0 ? body.slice(0, eq) : body; + return { paramName, isRest }; +} + +// --- Tree matching ------------------------------------------------------ + +type Match = { routeId: string; params: Array<[string, string]> }; + +function matchTree( + dir: Dir, + segs: HrefSeg[], + routePath: string[], + params: Array<[string, string]> +): Match[] { + if (segs.length === 0) { + const id = routePath.length === 0 ? '/' : '/' + routePath.join('/'); + return [{ routeId: id, params }]; + } + const [seg, ...rest] = segs; + const out: Match[] = []; + for (const sub of dir.subdirs) { + // Route groups are transparent — they don't consume a URL segment + // but DO appear in the route ID. + if (sub.name.startsWith('(') && sub.name.endsWith(')')) { + out.push(...matchTree(sub, segs, [...routePath, sub.name], params)); + continue; + } + if (seg.kind === 'literal') { + if (sub.name === seg.text) { + out.push( + ...matchTree(sub, rest, [...routePath, sub.name], params) + ); + } + } else { + const info = paramInfo(sub.name); + if (info && !info.isRest) { + out.push( + ...matchTree(sub, rest, [...routePath, sub.name], [ + ...params, + [info.paramName, seg.expr] + ]) + ); + } + } + } + return out; +} + +// --- Output formatting -------------------------------------------------- + +function isIdentifier(s: string): boolean { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(s); +} + +function formatParams(params: Array<[string, string]>): string { + if (params.length === 0) return ''; + const items = params.map(([name, expr]) => { + const trimmed = expr.trim(); + if (isIdentifier(trimmed) && trimmed === name) return name; + return `${name}: ${trimmed}`; + }); + return `, { ${items.join(', ')} }`; +} + +// --- Rewrite ------------------------------------------------------------ + +const HREF_RE = + /(<([A-Za-z][\w.-]*)\b[^>]*?\s)href="(\/[^"]*\{[^"]*\}[^"]*)"/gs; + +type Skip = { path: string; reason: string }; + +function rewriteHrefs(src: string): { + code: string; + changed: number; + skipped: Skip[]; +} { + let changed = 0; + const skipped: Skip[] = []; + const code = src.replace(HREF_RE, (full, prefix, tag, path) => { + if (SKIP_TAGS.has(tag.toLowerCase())) return full; + const segs = parsePath(path); + if (!segs) { + skipped.push({ path, reason: 'unparsable (mixed/query/fragment)' }); + return full; + } + const matches = matchTree(ROUTE_TREE, segs, [], []); + if (matches.length === 0) { + skipped.push({ path, reason: 'no route match' }); + return full; + } + if (matches.length > 1) { + skipped.push({ + path, + reason: `${matches.length} ambiguous matches: ${matches.map((m) => m.routeId).join(' | ')}` + }); + return full; + } + const { routeId, params } = matches[0]; + changed++; + return `${prefix}href={resolve('${routeId}'${formatParams(params)})}`; + }); + return { code, changed, skipped }; +} + +// --- Import injection --------------------------------------------------- + +const SCRIPT_RE = /]*)>([\s\S]*?)<\/script>/; +const PATHS_IMPORT_RE = + /import\s*\{([^}]*)\}\s*from\s*['"]\$app\/paths['"]\s*;?/; + +function ensureResolveImport(src: string): string { + const m = SCRIPT_RE.exec(src); + if (!m) { + return `\n\n${src}`; + } + const [scriptFull, attrs, body] = m; + const pm = PATHS_IMPORT_RE.exec(body); + if (pm) { + const inner = pm[1]; + if (/\bresolve\b/.test(inner)) return src; + const merged = inner.trim().replace(/,?\s*$/, '') + ', resolve'; + const newImport = `import { ${merged} } from '$app/paths';`; + const newBody = body.replace(PATHS_IMPORT_RE, newImport); + return src.replace(scriptFull, `${newBody}`); + } + const im = body.match(/^([ \t]*)import\b/m); + const indent = im ? im[1] : '\t'; + const opening = ``; + return src.replace( + scriptFull, + `${opening}\n${indent}import { resolve } from '$app/paths';${body}` + ); +} + +// --- Driver ------------------------------------------------------------- + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + const s = statSync(p); + if (s.isDirectory()) walk(p, out); + else if (extname(p) === '.svelte') out.push(p); + } + return out; +} + +const files = walk(SRC); +let totalFiles = 0; +let totalReplacements = 0; +const allSkipped: Array<{ file: string } & Skip> = []; + +for (const f of files) { + const orig = readFileSync(f, 'utf8'); + const { code, changed, skipped } = rewriteHrefs(orig); + for (const s of skipped) allSkipped.push({ file: f, ...s }); + if (changed === 0) continue; + const final = ensureResolveImport(code); + if (!DRY) writeFileSync(f, final); + totalFiles++; + totalReplacements += changed; + console.log(`${changed.toString().padStart(3)} ${f}`); +} + +console.log( + `\n${DRY ? '[dry] ' : ''}${totalReplacements} replacements across ${totalFiles} files` +); +if (allSkipped.length > 0) { + console.log(`\n--- ${allSkipped.length} skipped hrefs ---`); + for (const s of allSkipped) { + console.log(` ${s.file}\n ${s.path} [${s.reason}]`); + } +} diff --git a/scripts/codemod-href-resolve.ts b/scripts/codemod-href-resolve.ts new file mode 100644 index 00000000..50d28b48 --- /dev/null +++ b/scripts/codemod-href-resolve.ts @@ -0,0 +1,105 @@ +/** + * Bucket 1 codemod: replace literal href="/path" with href={resolve('/path')} + * in .svelte files, and inject `import { resolve } from '$app/paths'`. + * + * Skips: + * - non-anchor tags: , (svg), + * - external/protocol URLs: http(s)://, //host, mailto:, tel: + * - fragments (#...) and empty values + * - existing dynamic hrefs ({...}) + * + * Run: pnpm exec vite-node scripts/codemod-href-resolve.ts [--dry] + */ + +import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'; +import { join, extname } from 'node:path'; + +const ROOT = 'src'; +const DRY = process.argv.includes('--dry'); + +const SKIP_TAGS = new Set(['link', 'image', 'use']); + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + const s = statSync(p); + if (s.isDirectory()) walk(p, out); + else if (extname(p) === '.svelte') out.push(p); + } + return out; +} + +/** + * Match: opening of element, then its attributes, then href="/...". + * Group 1 = full prefix incl. tag-name, Group 2 = tag name, Group 3 = path. + */ +// Excludes `{` and `}` so Svelte template interpolations inside the +// attribute value (e.g. href="/{lang}/foo") are NOT treated as literals. +const HREF_RE = + /(<([A-Za-z][\w.-]*)\b[^>]*?\s)href="(\/[^"{}]*)"/gs; + +function rewriteHrefs(src: string): { code: string; changed: number } { + let changed = 0; + const code = src.replace(HREF_RE, (full, prefix, tag, path) => { + if (SKIP_TAGS.has(tag.toLowerCase())) return full; + // Skip protocol-relative just in case + if (path.startsWith('//')) return full; + changed++; + return `${prefix}href={resolve('${path}')}`; + }); + return { code, changed }; +} + +const SCRIPT_RE = /]*)>([\s\S]*?)<\/script>/; +const PATHS_IMPORT_RE = + /import\s*\{([^}]*)\}\s*from\s*['"]\$app\/paths['"]\s*;?/; + +function ensureResolveImport(src: string): string { + const scriptMatch = SCRIPT_RE.exec(src); + if (!scriptMatch) { + // No script tag — prepend a TS one. + return `\n\n${src}`; + } + const [scriptFull, attrs, body] = scriptMatch; + const pathsMatch = PATHS_IMPORT_RE.exec(body); + if (pathsMatch) { + const inner = pathsMatch[1]; + if (/\bresolve\b/.test(inner)) return src; // already imported + const merged = inner.trim().replace(/,?\s*$/, '') + ', resolve'; + const newImport = `import { ${merged} } from '$app/paths';`; + const newBody = body.replace(PATHS_IMPORT_RE, newImport); + return src.replace(scriptFull, `${newBody}`); + } + // Inject new import line. Detect indent from first import line if present. + const importMatch = body.match(/^([ \t]*)import\b/m); + const indent = importMatch ? importMatch[1] : '\t'; + // Insert right after the opening script tag's newline. + const opening = ``; + const insertion = `\n${indent}import { resolve } from '$app/paths';`; + const newScript = opening + insertion + body + ''; + return src.replace(scriptFull, newScript); +} + +function processFile(path: string): { changed: number } { + const orig = readFileSync(path, 'utf8'); + const { code: rewritten, changed } = rewriteHrefs(orig); + if (changed === 0) return { changed: 0 }; + const final = ensureResolveImport(rewritten); + if (!DRY) writeFileSync(path, final); + return { changed }; +} + +const files = walk(ROOT); +let totalFiles = 0; +let totalReplacements = 0; +for (const f of files) { + const { changed } = processFile(f); + if (changed > 0) { + totalFiles++; + totalReplacements += changed; + console.log(`${changed.toString().padStart(3)} ${f}`); + } +} +console.log( + `\n${DRY ? '[dry] ' : ''}${totalReplacements} replacements across ${totalFiles} files` +); diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte index e43c5449..8f5724d7 100644 --- a/src/lib/components/Header.svelte +++ b/src/lib/components/Header.svelte @@ -1,4 +1,5 @@ - +

{session.name}

{formatDate(session.startTime)} · {formatTime(session.startTime)} diff --git a/src/lib/components/recipes/Icon.svelte b/src/lib/components/recipes/Icon.svelte index 21331a3b..ca168800 100644 --- a/src/lib/components/recipes/Icon.svelte +++ b/src/lib/components/recipes/Icon.svelte @@ -1,4 +1,5 @@ @@ -26,4 +27,4 @@ } -
{icon} +{icon} diff --git a/src/lib/components/recipes/TranslationApproval.svelte b/src/lib/components/recipes/TranslationApproval.svelte index 6ce64c60..c357a9f8 100644 --- a/src/lib/components/recipes/TranslationApproval.svelte +++ b/src/lib/components/recipes/TranslationApproval.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/[cospendRoot=cospendRoot]/+layout.svelte b/src/routes/[cospendRoot=cospendRoot]/+layout.svelte index 2739579e..2059344a 100644 --- a/src/routes/[cospendRoot=cospendRoot]/+layout.svelte +++ b/src/routes/[cospendRoot=cospendRoot]/+layout.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/[faithLang=faithLang]/+layout.svelte b/src/routes/[faithLang=faithLang]/+layout.svelte index ce126714..1a894707 100644 --- a/src/routes/[faithLang=faithLang]/+layout.svelte +++ b/src/routes/[faithLang=faithLang]/+layout.svelte @@ -1,4 +1,5 @@ - +
{#snippet links()} @@ -50,7 +58,7 @@ const prayersActive = $derived(isActive(prayersHref) && !isActive(angelusHref)); {:else}
  • {angelusLabel}
  • {/if} -
  • {labels.catechesis}
  • +
  • {labels.catechesis}
  • {labels.apologetics}
  • {labels.calendar}
  • diff --git a/src/routes/[faithLang=faithLang]/+page.svelte b/src/routes/[faithLang=faithLang]/+page.svelte index 30f32476..d4aa04af 100644 --- a/src/routes/[faithLang=faithLang]/+page.svelte +++ b/src/routes/[faithLang=faithLang]/+page.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/detail/[yyyy=calendarYear]/[mm=calendarMonth]/[dd=calendarDay]/+page.svelte b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/detail/[yyyy=calendarYear]/[mm=calendarMonth]/[dd=calendarDay]/+page.svelte index 026e9aa1..0864aa3a 100644 --- a/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/detail/[yyyy=calendarYear]/[mm=calendarMonth]/[dd=calendarDay]/+page.svelte +++ b/src/routes/[faithLang=faithLang]/[calendar=calendarLang]/[rite=calendarRite]/detail/[yyyy=calendarYear]/[mm=calendarMonth]/[dd=calendarDay]/+page.svelte @@ -1,4 +1,5 @@
    {#each data.icons as icon} - {icon} + {icon} {/each}
    diff --git a/src/routes/[recipeLang=recipeLang]/icon/[icon]/+page.svelte b/src/routes/[recipeLang=recipeLang]/icon/[icon]/+page.svelte index 87ad9a3d..4bee606c 100644 --- a/src/routes/[recipeLang=recipeLang]/icon/[icon]/+page.svelte +++ b/src/routes/[recipeLang=recipeLang]/icon/[icon]/+page.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/fitness/+layout.svelte b/src/routes/fitness/+layout.svelte index d9a710d7..5996dbf3 100644 --- a/src/routes/fitness/+layout.svelte +++ b/src/routes/fitness/+layout.svelte @@ -1,4 +1,5 @@ {t('history_title', lang)} - Bocken diff --git a/src/routes/fitness/[nutrition=fitnessNutrition]/[[date=fitnessDate]]/+page.svelte b/src/routes/fitness/[nutrition=fitnessNutrition]/[[date=fitnessDate]]/+page.svelte index 7562e75f..038d9e91 100644 --- a/src/routes/fitness/[nutrition=fitnessNutrition]/[[date=fitnessDate]]/+page.svelte +++ b/src/routes/fitness/[nutrition=fitnessNutrition]/[[date=fitnessDate]]/+page.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/tasks/+layout.svelte b/src/routes/tasks/+layout.svelte index 77efb065..907e8868 100644 --- a/src/routes/tasks/+layout.svelte +++ b/src/routes/tasks/+layout.svelte @@ -1,4 +1,5 @@