diff --git a/scripts/patch-folds.mjs b/scripts/patch-folds.mjs index 59ad91b7e..37c20240d 100644 --- a/scripts/patch-folds.mjs +++ b/scripts/patch-folds.mjs @@ -4,28 +4,56 @@ import { join, dirname } from 'path'; const __dirname = dirname(fileURLToPath(import.meta.url)); const foldsPath = join(__dirname, '../node_modules/folds/dist/index.js'); +const foldsPkgPath = join(__dirname, '../node_modules/folds/package.json'); + +// Context lines around the target, not just the single `children: src(filled)` +// expression, so a coincidental match elsewhere in the bundle (e.g. some other +// `src(filled)` call) can't be mistaken for the Icon component we're patching. +// This is still string matching, not an AST edit, but the extra context makes +// an accidental match far less likely (Gitea #55). +const original = [' ...props,', ' ref,', ' children: src(filled)', ' }'].join( + '\n', +); +const patched = [ + ' ...props,', + ' ref,', + ' children: typeof src === "function" ? src(filled) : null', + ' }', +].join('\n'); + +function foldsVersion() { + try { + return JSON.parse(readFileSync(foldsPkgPath, 'utf8')).version ?? 'unknown'; + } catch { + return 'unknown'; + } +} try { - let content = readFileSync(foldsPath, 'utf8'); - - // Defensive guard: if src is not a function, render null instead of crashing - const original = 'children: src(filled)'; - const patched = 'children: typeof src === "function" ? src(filled) : null'; + const content = readFileSync(foldsPath, 'utf8'); if (content.includes(patched)) { + // Already patched (e.g. re-running postinstall, or a fresh checkout that + // already has a patched node_modules cache) — no-op, exit 0. console.log('folds patch already applied.'); } else if (content.includes(original)) { - content = content.replace(original, patched); - writeFileSync(foldsPath, content, 'utf8'); + writeFileSync(foldsPath, content.replace(original, patched), 'utf8'); console.log('Applied defensive Icon src guard to folds.'); } else { - // Genuine "patch could not be applied" case: the target string is gone - // (folds renamed/restructured it) AND it isn't already patched. Fail hard - // so the postinstall hook / CI breaks loudly instead of silently shipping - // an unpatched folds (which crashes at render with "src is not a function"). + // Genuine "patch could not be applied" case: neither the original nor the + // patched form was found, meaning folds changed the Icon implementation. + // Fail loudly so the postinstall hook / CI breaks instead of silently + // shipping an unpatched folds (which crashes at render with "src is not a + // function"). See LOTUS_TODO.md "Dependencies / Build / Hygiene" for + // context on why this is a direct node_modules patch rather than + // patch-package. + console.error('ERROR: folds Icon patch target not found.'); + console.error(` folds version installed: ${foldsVersion()}`); + console.error(` Expected to find (surrounding context):\n${original}`); console.error( - 'ERROR: folds Icon patch target not found - folds may have updated. ' + - 'Update the patch target string in scripts/patch-folds.mjs before building.', + ' folds likely changed its Icon implementation. Update the patch target ' + + 'in scripts/patch-folds.mjs (see LOTUS_TODO -> "Dependencies / Build / Hygiene" ' + + '-> patch-folds.mjs entry) before building.', ); process.exit(1); } diff --git a/scripts/syncDecorations.mjs b/scripts/syncDecorations.mjs index ba1e0afdf..7cde014ee 100644 --- a/scripts/syncDecorations.mjs +++ b/scripts/syncDecorations.mjs @@ -103,9 +103,38 @@ missing.forEach((r) => console.log(` Removing (HTTP ${r.status}): ${r.slug}`)); const missingSet = new Set(missing.map((r) => r.slug)); // Remove individual entries for missing slugs -let updated = catalog.replace(/^[ \t]*\{ slug: '([^']+)', name: .+\},?\r?\n/gm, (match, slug) => - missingSet.has(slug) ? '' : match, -); +const removedSlugs = new Set(); +let updated = catalog.replace(/^[ \t]*\{ slug: '([^']+)', name: .+\},?\r?\n/gm, (match, slug) => { + if (!missingSet.has(slug)) return match; + removedSlugs.add(slug); + return ''; +}); + +// Regex-based removal is brittle: if the catalog is reformatted (different +// indentation, line-wrapped entries, etc.) the pattern above can silently +// match zero entries while HTTP probing still reports slugs missing. Verify +// every slug we intended to remove actually got matched — otherwise abort +// without writing, so a formatting change fails loudly instead of leaving +// stale/dead entries in the catalog (see Gitea #88). +const unmatched = [...missingSet].filter((slug) => !removedSlugs.has(slug)); +if (unmatched.length > 0) { + console.error( + `Aborting: expected to remove ${missingSet.size} entr${missingSet.size === 1 ? 'y' : 'ies'} ` + + `but only matched ${removedSlugs.size}. The catalog's formatting may have changed and the ` + + `parser in scripts/syncDecorations.mjs needs updating. Refusing to write a partial result.`, + ); + console.error(` Unmatched slugs: ${unmatched.join(', ')}`); + process.exit(1); +} +if (removedSlugs.size === 0) { + // We already exited above when `missing.length === 0`, so reaching here + // with zero removals despite `missing.length > 0` means the diff between + // "expected" and "actual" itself is broken — fail rather than proceed. + console.error( + 'Aborting: no entries were matched for removal despite missing slugs. Refusing to write.', + ); + process.exit(1); +} // Drop category blocks that now have an empty decorations array updated = updated.replace( @@ -118,6 +147,6 @@ updated = updated.replace(/\n{3,}/g, '\n\n'); writeFileSync(catalogPath, updated, 'utf8'); console.log( - `\nDone. Removed ${missing.length} entr${missing.length === 1 ? 'y' : 'ies'} from the catalog.`, + `\nDone. Removed ${removedSlugs.size} entr${removedSlugs.size === 1 ? 'y' : 'ies'} from the catalog.`, ); console.log('Review with: git diff src/app/features/lotus/avatarDecorations.ts');