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
74 changes: 52 additions & 22 deletions scripts/generate-search-index.mts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
#!/usr/bin/env node
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { format as formatWithPrettier } from 'prettier';
import type { SearchIndexEntry } from './lib/types.mts';
import { parseFrontmatter } from './lib/frontmatter.mts';

const POSTS_DIR = 'src/posts';
const OUTPUT = 'static/search-index.json';
const PUBLICATION_HOLDS_OUTPUT = 'static/blog-publication-holds.json';
const PUBLISHED_POST_LOADERS_OUTPUT = 'src/lib/data/blog-post-loaders.generated.ts';
const WORDS_PER_MINUTE = 230;

function computeReadingTime(text: string): number {
Expand All @@ -16,14 +19,19 @@ function computeReadingTime(text: string): number {
async function main(): Promise<void> {
const files = (await readdir(POSTS_DIR)).filter((f) => f.endsWith('.md'));
const index: SearchIndexEntry[] = [];
const publicationHolds: string[] = [];
const publishedSourceFiles: string[] = [];

for (const file of files) {
const content = await readFile(join(POSTS_DIR, file), 'utf-8');
const meta = parseFrontmatter(content);
if (!meta || !meta.published) continue;
if (!meta) continue;

const slug =
(meta.slug as string) ?? file.replace('.md', '').replace(/^\d{4}-\d{2}-\d{2}-/, '');
const slug = (meta.slug as string) ?? file.replace('.md', '').replace(/^\d{4}-\d{2}-\d{2}-/, '');
if (meta.published !== true) {
if (meta.published === false) publicationHolds.push(slug);
continue;
}

// Extract body excerpt (~150 chars of plain text)
const body = content.replace(/^---[\s\S]*?---/, '');
Expand All @@ -43,9 +51,10 @@ async function main(): Promise<void> {
}

const tagList = Array.isArray(meta.tags) ? (meta.tags as string[]) : [];
const editorialTier =
typeof meta.editorial_tier === 'string' ? meta.editorial_tier : undefined;
const editorialTier = typeof meta.editorial_tier === 'string' ? meta.editorial_tier : undefined;

const sourceFile = `/src/posts/${file}`;
publishedSourceFiles.push(sourceFile);
index.push({
id: slug,
title: String(meta.title ?? slug),
Expand All @@ -61,32 +70,53 @@ async function main(): Promise<void> {
: {}),
slug,
date: String(meta.date ?? ''),
source_file: `/src/posts/${file}`,
source_file: sourceFile,
body_excerpt,
published: Boolean(meta.published),
reading_time:
typeof meta.reading_time === 'number'
? meta.reading_time
: computeReadingTime(plain),
feature_image:
typeof meta.feature_image === 'string' ? meta.feature_image : undefined,
thumbnail_image:
typeof meta.thumbnail_image === 'string' ? meta.thumbnail_image : undefined,
featured:
typeof meta.featured === 'boolean' ? meta.featured : undefined,
author_slug:
typeof meta.author_slug === 'string' ? meta.author_slug : undefined,
original_url:
typeof meta.original_url === 'string' ? meta.original_url : undefined,
excerpt: typeof meta.excerpt === 'string' ? meta.excerpt : undefined
reading_time: typeof meta.reading_time === 'number' ? meta.reading_time : computeReadingTime(plain),
feature_image: typeof meta.feature_image === 'string' ? meta.feature_image : undefined,
thumbnail_image: typeof meta.thumbnail_image === 'string' ? meta.thumbnail_image : undefined,
featured: typeof meta.featured === 'boolean' ? meta.featured : undefined,
author_slug: typeof meta.author_slug === 'string' ? meta.author_slug : undefined,
original_url: typeof meta.original_url === 'string' ? meta.original_url : undefined,
excerpt: typeof meta.excerpt === 'string' ? meta.excerpt : undefined,
});
}

// Sort by date descending
index.sort((a, b) => b.date.localeCompare(a.date));
publicationHolds.sort((a, b) => a.localeCompare(b));
publishedSourceFiles.sort((a, b) => a.localeCompare(b));
const unsafeLoaderPath = publishedSourceFiles.find(
(sourceFile) => !/^\/src\/posts\/[A-Za-z0-9._-]+\.md$/.test(sourceFile),
);
if (unsafeLoaderPath) {
throw new Error(`Published post path cannot be emitted as an exact loader: ${unsafeLoaderPath}`);
}

const publishedPostLoadersSource = [
'// Generated by scripts/generate-search-index.mts. Do not edit by hand.',
'export const publishedPostLoaders = import.meta.glob([',
...publishedSourceFiles.map((sourceFile) => `\t${JSON.stringify(sourceFile)},`),
']);',
'',
].join('\n');
const publishedPostLoaders = await formatWithPrettier(publishedPostLoadersSource, {
parser: 'typescript',
printWidth: 120,
singleQuote: true,
trailingComma: 'all',
useTabs: true,
});

await writeFile(OUTPUT, JSON.stringify(index), 'utf-8');
await Promise.all([
writeFile(OUTPUT, JSON.stringify(index), 'utf-8'),
writeFile(PUBLICATION_HOLDS_OUTPUT, JSON.stringify(publicationHolds), 'utf-8'),
writeFile(PUBLISHED_POST_LOADERS_OUTPUT, publishedPostLoaders, 'utf-8'),
]);
console.log(`Search index: ${index.length} posts -> ${OUTPUT}`);
console.log(`Publication holds: ${publicationHolds.length} slugs -> ${PUBLICATION_HOLDS_OUTPUT}`);
console.log(`Published post loaders: ${publishedSourceFiles.length} modules -> ${PUBLISHED_POST_LOADERS_OUTPUT}`);
}

main().catch((err) => {
Expand Down
81 changes: 81 additions & 0 deletions scripts/run-sveltekit-vite-build-bazel.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ if (!indexHtml.includes('<!doctype html>')) {
throw new Error('SvelteKit build output index.html is missing doctype');
}

assertUnpublishedPostContentExcluded();

console.log(
`SvelteKit/Vite build smoke passed for ${packageJson.name}; output=${indexPath}; mermaid=${process.env.MERMAID_PRERENDER}`,
);
Expand All @@ -86,6 +88,85 @@ function copyInputsToBuildRoot() {
}
}

function assertUnpublishedPostContentExcluded() {
const postsDir = join(buildRoot, 'src', 'posts');
const appDir = join(buildRoot, 'build', '_app');
const posts = readdirSync(postsDir)
.filter((file) => file.endsWith('.md'))
.map((file) => ({ file, source: readFileSync(join(postsDir, file), 'utf8') }));
const publishedCorpus = posts
.filter(({ source }) => /^published:\s*true\s*$/m.test(frontmatter(source)))
.map(({ source }) => source)
.join('\n');
const unpublishedPosts = posts
.filter(({ source }) => !/^published:\s*true\s*$/m.test(frontmatter(source)))
.map(({ file, source }) => ({
file,
sentinel: publicationSentinel(file, source, publishedCorpus),
}));
const appAssets = collectFiles(appDir).map((file) => ({
file,
source: readFileSync(file, 'utf8'),
}));

for (const unpublishedPost of unpublishedPosts) {
const leak = appAssets.find(({ source }) => assetContains(source, unpublishedPost.sentinel));
if (leak) {
throw new Error(`Unpublished post ${unpublishedPost.file} leaked into client asset ${leak.file}`);
}
}

console.log(`Unpublished-post asset scan passed for ${unpublishedPosts.length} posts`);
}

function frontmatter(source) {
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
return match?.[1] ?? '';
}

function publicationSentinel(file, source, publishedCorpus) {
const block = frontmatter(source);
const bodyLines = source
.slice(source.indexOf(block) + block.length)
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length >= 32 && !/^(?:[#>*_`-]|\d+\.)/.test(line));
const candidates = [frontmatterScalar(block, 'description'), frontmatterScalar(block, 'title'), ...bodyLines].filter(
(candidate) => candidate.length >= 24,
);
const sentinel = candidates.find((candidate) => !publishedCorpus.includes(candidate));
if (!sentinel) {
throw new Error(`Unpublished post ${file} has no unique public-asset sentinel`);
}
return sentinel;
}

function frontmatterScalar(block, key) {
const match = block.match(new RegExp(`^${key}:\\s*(.+?)\\s*$`, 'm'));
if (!match) return '';
const raw = match[1].trim();
if (raw.startsWith('"') && raw.endsWith('"')) {
return JSON.parse(raw);
}
if (raw.startsWith("'") && raw.endsWith("'")) {
return raw.slice(1, -1).replaceAll("''", "'");
}
return raw;
}

function assetContains(source, sentinel) {
const jsonEscaped = JSON.stringify(sentinel).slice(1, -1);
const singleQuoteEscaped = sentinel.replaceAll('\\', '\\\\').replaceAll("'", "\\'");
return source.includes(sentinel) || source.includes(jsonEscaped) || source.includes(singleQuoteEscaped);
}

function collectFiles(root) {
return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
const path = join(root, entry.name);
return entry.isDirectory() ? collectFiles(path) : [path];
});
}

function copyPath(source, destination) {
if (!existsSync(source)) {
throw new Error(`Missing declared build input: ${source}`);
Expand Down
143 changes: 143 additions & 0 deletions src/lib/data/blog-post-loaders.generated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Generated by scripts/generate-search-index.mts. Do not edit by hand.
export const publishedPostLoaders = import.meta.glob([
'/src/posts/2016-12-13-example-photo-work.md',
'/src/posts/2016-12-13-required-custom-tools.md',
'/src/posts/2016-12-13-the-audeasy-build.md',
'/src/posts/2016-12-13-waxwing-audio.md',
'/src/posts/2016-12-14-opportunistic-birding-14122016.md',
'/src/posts/2016-12-15-beta-how-to-build-the-phone-charger.md',
'/src/posts/2016-12-15-building-electrostatic-earspeakers.md',
'/src/posts/2016-12-15-diy-electrostatic-amp-options.md',
'/src/posts/2016-12-15-music-sketches-1-playing-with-rhythm-division-and-jazzy-chords.md',
'/src/posts/2016-12-16-music-sketches-2-rethinking-funtwos-guitar-tone-discipline.md',
'/src/posts/2016-12-21-develop-limitless-habitual-soithen_soicould_-ethos.md',
'/src/posts/2017-02-20-wolf-pine-fox-park-1-bonus-winter-birds.md',
'/src/posts/2017-03-02-wolf-pine-fox-park-2-22717.md',
'/src/posts/2017-03-04-wolf-pine-fox-park-3-3317-noon-ish-time.md',
'/src/posts/2017-03-05-wolf-pine-fox-park-4-3517-noon-ish-time.md',
'/src/posts/2017-03-11-wolf-pine-fox-park-6-31017-theoretical-owls-and-real-tree-bark.md',
'/src/posts/2017-03-12-wolf-pine-fox-park-7-31217-surprise-its-cold.md',
'/src/posts/2017-03-13-sit-spot-n-1-the-gho-talk.md',
'/src/posts/2017-03-17-wolf-pine-fox-park-8-31617-someone-bumped-the-snow-machine.md',
'/src/posts/2017-03-20-boutique-everything-when-the-hobby-grows-up.md',
'/src/posts/2017-03-28-wolf-pine-fox-park-9-32617-everyone-is-on-vacation.md',
'/src/posts/2017-03-30-beaver-dam-quincy-bog-1-33017-the-world-has-gone-mad.md',
'/src/posts/2017-03-31-parking-lotsit-spot-fox-park-10-33117-whoos-clues.md',
'/src/posts/2017-04-02-wolf-pine-fox-park-10-4117-it-is-a-snow-show-debunking-the-melanistic-dogamount.md',
'/src/posts/2017-04-03-parking-lotsit-spot-fox-park-12-4317-owling-2-hours-before-sunrise.md',
'/src/posts/2017-04-04-603.md',
'/src/posts/2017-04-08-pt-1-reflecting-on-stunt-culture-theories-and-frisbee.md',
'/src/posts/2017-04-08-pt-2-reflecting-on-stunt-culture-scooters.md',
'/src/posts/2017-04-11-bird-observations-today-langdon-woods.md',
'/src/posts/2017-04-12-fox-park-14-41017-1-if-by-land-16-if-by-ear.md',
'/src/posts/2017-04-12-fox-park-15-41217-1-if-by-land-17-if-by-ear.md',
'/src/posts/2017-04-17-fox-park-16-and-17-41617big-toad-small-vireo-yay.md',
'/src/posts/2017-04-19-fox-park-18-41917-in-class-roamings.md',
'/src/posts/2017-04-20-addendum-to-secret-beach-area-natural-history-class-walk.md',
'/src/posts/2017-05-02-langdon-woods-with-kurt-plants.md',
'/src/posts/2017-05-03-the-big-len-list.md',
'/src/posts/2017-05-04-early-morning-guitar-session-ala-laundry.md',
'/src/posts/2017-05-04-fox-park-to-langdon-morning-birds.md',
'/src/posts/2017-05-05-new-photopromotional-media-business-pages-in-the-works.md',
'/src/posts/2017-05-07-hunting-for-trees-birds.md',
'/src/posts/2017-05-07-mega-langdon.md',
'/src/posts/2017-05-08-new-business-site-up-and-running.md',
'/src/posts/2017-05-08-pre-dawn-fox-park-lot-walk-birding-by-ear.md',
'/src/posts/2017-05-12-wolf-pine-fox-park-silence.md',
'/src/posts/2017-05-17-rugby-morning-2.md',
'/src/posts/2017-05-18-rugby-morning-3-6.md',
'/src/posts/2017-07-03-birding-beyond-binos-bird-apps-and-the-guide.md',
'/src/posts/2017-07-25-mpcnc-it-moves.md',
'/src/posts/2017-07-27-gallery-of-my-proper-photos-nh-warbler-research.md',
'/src/posts/2017-07-27-gallery-of-warblers-in-the-hand.md',
'/src/posts/2018-03-08-how-to-make-a-aws-r-server.md',
'/src/posts/2018-03-13-pages-fresh-from-the-book.md',
'/src/posts/2018-03-19-ebpp-my-epic-birding-prediction-project-in-r.md',
'/src/posts/2018-04-03-using-esri-arcgis-arcmap-on-mac-osx-2-methods.md',
'/src/posts/2018-04-05-using-esri-arcgis-arcmap-in-the-aws-cloud.md',
'/src/posts/2018-04-10-intro-to-the-aws-cloud-9-ide.md',
'/src/posts/2018-04-18-signs-of-thoughts-of-possible-spring.md',
'/src/posts/2018-05-23-birding-at-plumb-island.md',
'/src/posts/2018-05-24-840-watts-of-solar-power.md',
'/src/posts/2018-05-28-gathering-point-data-using-compass-55-on-apple-ios.md',
'/src/posts/2018-05-31-research-year-two-three-photos.md',
'/src/posts/2018-05-31-solar-upgrades.md',
'/src/posts/2018-06-10-how-to-query-kml-point-data-as-csv-using-qgis-and-r.md',
'/src/posts/2018-06-24-how-to-generate-convex-hull-polygons-and-centroids-from-kml.md',
'/src/posts/2018-06-25-off-grid-file-sharing-with-samba-gl-inet.md',
'/src/posts/2018-07-16-deploy-a-shiny-web-app-in-r-using-aws-ec2-red-hat.md',
'/src/posts/2018-07-23-gdal-for-r-server-on-ubuntu-kml-spatial-libraries-and-more.md',
'/src/posts/2018-07-27-new-app-kml-search-and-convert.md',
'/src/posts/2018-07-28-shiny-app-specific-repo-live.md',
'/src/posts/2018-09-01-early-hardware-and-maker-projects.md',
'/src/posts/2018-09-12-github-update-9-12-18-shiny-apps.md',
'/src/posts/2018-09-12-quick-fix-254-character-limit-in-esri-story-map.md',
'/src/posts/2018-11-05-recycled-personal-cloud-computing-under-nat.md',
'/src/posts/2018-11-25-deploy-shiny-r-apps-along-node-js.md',
'/src/posts/2019-02-20-installing-chapel-language-on-mac-and-linux.md',
'/src/posts/2019-03-10-querying-kml-data-with-qgis-and-r.md',
'/src/posts/2019-03-21-notes-on-a-free-and-open-source-notes-app-joplin.md',
'/src/posts/2019-04-15-open-source-collaboration-and-higher-education.md',
'/src/posts/2019-04-18-warbler-trillers-of-the-charles.md',
'/src/posts/2019-05-01-succession-warblers-and-gis.md',
'/src/posts/2019-07-05-summer-2019-update.md',
'/src/posts/2019-08-04-persistent-live-ubuntu-for-college.md',
'/src/posts/2019-09-30-decentralized-pi-video-monitoring-w-motioneye-batman.md',
'/src/posts/2019-10-07-gdal-for-gis-on-unix-using-a-mac-or-better-linux.md',
'/src/posts/2019-10-30-qemu-for-raspian-arm.md',
'/src/posts/2020-03-17-when-it-must-be-windows.md',
'/src/posts/2020-03-23-1607.md',
'/src/posts/2020-04-02-ppe-me.md',
'/src/posts/2020-04-05-dm-shields-fusion-360.md',
'/src/posts/2020-04-09-convert-heic-png.md',
'/src/posts/2020-04-25-install-adobe-applications-on-aws-workspaces.md',
'/src/posts/2020-04-27-oes.md',
'/src/posts/2020-05-18-while-at-a-safe-distance.md',
'/src/posts/2020-06-04-prius-printers.md',
'/src/posts/2020-06-14-the-ebird-api-regioncode.md',
'/src/posts/2020-06-24-1768.md',
'/src/posts/2020-06-24-1820.md',
'/src/posts/2020-06-29-osselc-monday.md',
'/src/posts/2020-07-11-1784.md',
'/src/posts/2020-07-15-this-that-7-15.md',
'/src/posts/2020-07-23-ever-tried-to-chrome-remote-ubuntu-xd.md',
'/src/posts/2020-08-23-purple-prius-parts.md',
'/src/posts/2020-09-05-kvm-does-fruit-xcode-from-qemu.md',
'/src/posts/2020-09-06-1984.md',
'/src/posts/2020-10-28-asynchronous-http-methods-from-typescript.md',
'/src/posts/2020-10-28-evening-metal-9-14-20.md',
'/src/posts/2020-12-23-a-boilerplate-for-flask-react-typescript-mongodb.md',
'/src/posts/2021-01-02-naive-shenanigans-measuring-distance-to-roi-w-opencv.md',
'/src/posts/2021-01-23-chindogu-ascii-art-i-suppose.md',
'/src/posts/2021-02-17-tmpui-the-merlin-sound-id-project.md',
'/src/posts/2021-03-18-bits-bobs-mushstools-toadrooms.md',
'/src/posts/2021-04-23-morning-metal-4-22-21.md',
'/src/posts/2021-11-30-2393.md',
'/src/posts/2022-01-01-datasets-plots-graphs-charts.md',
'/src/posts/2022-02-06-makerspace-financial-reporting-w-ipython.md',
'/src/posts/2022-05-15-running-cornells-dla-makerspace.md',
'/src/posts/2022-05-23-this-and-that.md',
'/src/posts/2023-03-01-fusion-360-for-3d-printing-w-jess.md',
'/src/posts/2023-11-27-turkey-probe.md',
'/src/posts/2023-12-18-lets-write-a-simple-efficient-and-fast-flask-based-image-server-in-an-afternoon.md',
'/src/posts/2024-01-02-accuwix.md',
'/src/posts/2024-02-22-i-wrote-a-mutual-aid-mental-health-service.md',
'/src/posts/2024-05-22-what-have-i-been-up-to-these-last-few-months.md',
'/src/posts/2024-12-31-ligature-test-fixture.md',
'/src/posts/2026-02-26-aperture-tagged-devices-and-the-tsnet-escape-hatch.md',
'/src/posts/2026-03-04-from-bricked-to-recovered-the-story-of-hacking-an-nvme-ssd-back-to-life.md',
'/src/posts/2026-03-04-week-notes-cyborgs-servers-and-sending.md',
'/src/posts/2026-03-04-xram-injection-bypassing-usb-bridge-whitelists-to-recover-nvme-drives.md',
'/src/posts/2026-03-12-updated-resume-and-cv.md',
'/src/posts/2026-03-13-the-winrm-forkbomb-how-ansible-molecule-locks-you-out-of-active-directory.md',
'/src/posts/2026-03-21-week-notes-indeterminism-passkeys-and-spring.md',
'/src/posts/2026-04-25-reproducible-host-probes-nix-chapel-dhall.md',
'/src/posts/2026-04-28-darwin-nic-docs-are-live.md',
'/src/posts/2026-04-30-small-zig-libraries-for-apple-shaped-escape-hatches.md',
'/src/posts/2026-05-03-was110-fidium-gonetspeed-omci-personalities.md',
'/src/posts/2026-05-11-trashmonitor-tailnet-window-into-the-hardware-pile.md',
'/src/posts/2026-05-29-gingersnap-cookies.md',
'/src/posts/2026-06-08-glue-you-can-see-in-uv.md',
'/src/posts/2026-06-08-week-notes-scroll-wheels-and-chasing-the-sun.md',
'/src/posts/2026-06-09-clearing-canon-5b00-lock-native-key-free-megatank-reset.md',
]);
9 changes: 9 additions & 0 deletions src/lib/tinyland/blogBrokerStream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,4 +295,13 @@ describe('mergeBrokerPostsIntoStatic', () => {
);
expect(merged.map((p) => p.slug)).toEqual(['canon', 'federated', 'old']);
});

it('does not revive an explicitly held slug from the broker', () => {
const merged = mergeBrokerPostsIntoStatic(
[post('canon', '2026-06-09')],
[post('held', '2026-06-10'), post('federated', '2026-06-01')],
new Set(['held']),
);
expect(merged.map((p) => p.slug)).toEqual(['canon', 'federated']);
});
});
Loading
Loading