I wanted my X bookmarks to feel like a personal archive. The saved posts lived inside one account, the useful parts were hard to search, and the workflow offered no public reading surface.
This project turns that archive into a static site. A Windows machine reads the active Firefox session twice each day. Field Theory downloads the bookmark data into a local JSONL cache. A PowerShell runner creates export files and pushes them into a GitHub repository. Cloudflare Pages builds the frontend from that repository and serves it at signals.asynchronope.my.id.
The site stays static. Its browser code fetches JSON files. The request path contains static files served by Pages.
The project has two repositories:
- Field Theory CLI, which owns the local sync and export pipeline.
-
my-twitter-bookmarks, a private repository that owns the static frontend and the generatedfieldtheory/snapshot.
The local checkout used while writing this article contains both projects. The frontend checkout sits under .tmp-my-twitter-bookmarks.
The whole flow
Firefox session on Windows
|
| 07:00 and 19:00 local time
v
Windows Task Scheduler
|
v
sync-bookmarks-to-github.ps1
|
+--> node bin/ft.mjs sync
| |
| v
| ~/.ft-bookmarks/bookmarks.jsonl
|
+--> node bin/ft.mjs site-export
| |
| v
| fieldtheory/site/*.json
|
+--> git commit and git push
|
v
my-twitter-bookmarks repository
|
v
Cloudflare Pages build: npm run build
|
v
dist/index.html + dist/data/*.json
|
v
signals.asynchronope.my.id
The local machine owns the browser session and the source cache. GitHub carries the generated snapshot. Cloudflare Pages serves the public files.
Repository layout
The Field Theory repository has the parts below.
fieldtheory/
bin/
ft.mjs
src/
cli.ts
graphql-bookmarks.ts
bookmarks-db.ts
bookmarks-site-export.ts
paths.ts
scripts/
automation/
setup-bookmark-sync.ps1
sync-bookmarks-to-github.ps1
export_bookmarks_xlsx.py
output/
tests/
The separate frontend repository has this shape:
my-twitter-bookmarks/
fieldtheory/
site/
manifest.json
search-index.json
topics.json
stats.json
bookmarks.index.json
bookmarks.full.jsonl
web/
index.html
app.js
styles.css
build.mjs
_headers
package.json
fieldtheory/site is the handoff point. The exporter writes the data there. The static build copies that directory into dist/data.
How Field Theory gets the bookmarks
The CLI entry point is bin/ft.mjs.
#!/usr/bin/env node
import { buildCli } from '../dist/cli.js';
await buildCli().parseAsync(process.argv);
The production repository builds TypeScript into dist before the CLI runs. The scheduled runner calls the local entry point with Node:
node D:\Repo\fieldtheory\bin\ft.mjs sync --yes --browser firefox
Field Theory reads cookies from the configured Firefox profile, uses the active X session for the bookmark request, and writes records into the data directory.
The default data location comes from src/paths.ts:
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
export function dataDir(): string {
const override = process.env.FT_DATA_DIR;
if (override) return override;
return path.join(os.homedir(), '.ft-bookmarks');
}
export function twitterBookmarksCachePath(): string {
return path.join(dataDir(), 'bookmarks.jsonl');
}
export function twitterBookmarksMetaPath(): string {
return path.join(dataDir(), 'bookmarks-meta.json');
}
export function twitterBookmarksIndexPath(): string {
return path.join(dataDir(), 'bookmarks.db');
}
The resulting local directory looks like this:
C:\Users\your-user\.ft-bookmarks\
bookmarks.jsonl
bookmarks-meta.json
bookmarks.db
site\
The JSONL file stores one bookmark record per line. SQLite gives the CLI its local full-text search index. The website uses the generated JSON files, so the database stays on the source machine.
The 7 AM and 7 PM schedule
The schedule lives in setup-bookmark-sync.ps1.
These parameters define the local times:
param(
[string]$PrimaryMorningTime = '07:00',
[string]$PrimaryEveningTime = '19:00'
)
The installer turns those values into daily triggers:
$morningAt = [datetime]::Today.Add([timespan]::Parse($PrimaryMorningTime))
$eveningAt = [datetime]::Today.Add([timespan]::Parse($PrimaryEveningTime))
$primaryTriggerMorning = New-ScheduledTaskTrigger -Daily -At $morningAt
$primaryTriggerEvening = New-ScheduledTaskTrigger -Daily -At $eveningAt
$primaryTriggers = @($primaryTriggerMorning, $primaryTriggerEvening)
$recoveryTrigger = New-ScheduledTaskTrigger -AtLogOn -User $env:USERNAME
The primary task runs twice per day. The recovery task runs when the user logs in. Its action receives -SkipIfRecentSuccessHours 6, which prevents a login from starting a second sync shortly after a successful scheduled run.
The task settings live in the same installer:
$settings = New-ScheduledTaskSettingsSet `
-StartWhenAvailable `
-WakeToRun `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-RunOnlyIfNetworkAvailable `
-ExecutionTimeLimit (New-TimeSpan -Hours 2) `
-MultipleInstances IgnoreNew `
-RestartCount 3 `
-RestartInterval (New-TimeSpan -Minutes 30)
The task action points to the runner script:
$syncScriptPath = Join-Path $SourceRepoPath 'scripts\automation\sync-bookmarks-to-github.ps1'
$primaryAction = New-ScheduledTaskAction `
-Execute $powershellExe `
-Argument (New-TaskActionArgument `
-ScriptPath $syncScriptPath `
-ConfigPath $configPath `
-TriggerReason 'scheduled')
$recoveryAction = New-ScheduledTaskAction `
-Execute $powershellExe `
-Argument (New-TaskActionArgument `
-ScriptPath $syncScriptPath `
-ConfigPath $configPath `
-TriggerReason 'logon-recovery' `
-SkipIfRecentSuccessHours 6)
The registration names are:
Register-BookmarkTask `
-TaskPath '\FieldTheory\' `
-TaskName 'Bookmark Sync' `
-Triggers $primaryTriggers `
-Action $primaryAction `
-Settings $settings `
-Principal $principal `
-Description 'Sync Field Theory bookmarks to GitHub at 07:00 and 19:00 local time.'
Register-BookmarkTask `
-TaskPath '\FieldTheory\' `
-TaskName 'Bookmark Sync Recovery' `
-Triggers @($recoveryTrigger) `
-Action $recoveryAction `
-Settings $settings `
-Principal $principal `
-Description 'Catch up Field Theory bookmark sync after logon if the last successful run is stale.'
Run the installer from an elevated PowerShell window:
powershell -ExecutionPolicy Bypass `
-File D:\Repo\fieldtheory\scripts\automation\setup-bookmark-sync.ps1 `
-SourceRepoPath D:\Repo\fieldtheory `
-FirefoxProfileDir 'C:\Users\your-user\AppData\Roaming\Mozilla\Firefox\Profiles\your-profile' `
-RepoUrl 'https://github.com/your-account/my-twitter-bookmarks.git' `
-RunNow
The installer writes its configuration here:
C:\Users\your-user\.ft-bookmarks\automation\github-sync\bookmark-sync.config.psd1
A safe configuration file contains machine paths and repository settings. Browser cookies remain inside the Firefox profile.
@{
SourceRepoPath = 'D:\Repo\fieldtheory'
DataDir = 'C:\Users\your-user\.ft-bookmarks'
RepoUrl = 'https://github.com/your-account/my-twitter-bookmarks.git'
RepoBranch = 'main'
StateDir = 'C:\Users\your-user\.ft-bookmarks\automation\github-sync'
Browser = 'firefox'
FirefoxProfileDir = 'C:\Users\your-user\AppData\Roaming\Mozilla\Firefox\Profiles\your-profile'
ManagedRoot = 'fieldtheory'
CommitHeartbeat = $true
}
Check the tasks with:
Get-ScheduledTask -TaskPath '\FieldTheory\' |
Select-Object TaskPath, TaskName, State
Inspect a task’s next run time with:
Get-ScheduledTaskInfo -TaskPath '\FieldTheory\' -TaskName 'Bookmark Sync' |
Select-Object LastRunTime, NextRunTime, LastTaskResult
StartWhenAvailable lets Windows catch up after a missed start. A powered-off machine pauses the scheduled process. The logon task gives the next session a recovery path.
The runner that creates the archive snapshot
The production runner lives in sync-bookmarks-to-github.ps1. It has a lock file, retry handling, status output, optional Telegram messages, CSV export, XLSX export, and website export.
The central command array is short:
$ftArguments = @(
(Join-Path $config.SourceRepoPath 'bin/ft.mjs'),
'sync',
'--yes',
'--browser', $config.Browser
)
if (-not [string]::IsNullOrWhiteSpace([string]$config.FirefoxProfileDir)) {
$ftArguments += @('--firefox-profile-dir', [string]$config.FirefoxProfileDir)
}
$syncResult = Invoke-WithRetry -Label 'ft sync' -MaxAttempts 2 -DelaySeconds @(60) -RetryPatterns @(
'429',
'5\d\d',
'ETIMEDOUT',
'ECONN',
'temporar',
'rate limited',
'Could not read .*Cookies database'
) -Operation {
$result = Invoke-LoggedProcess `
-FilePath 'node' `
-Arguments $ftArguments `
-WorkingDirectory $config.SourceRepoPath
if ($result.ExitCode -ne 0) {
throw "ft sync failed.`n$($result.StdErr)$($result.StdOut)"
}
return $result
}
The runner then creates the portable exports:
$csvOutputPath = Join-Path $stagingRoot 'exports\bookmarks-full-latest.csv'
Invoke-WithRetry -Label 'ft export csv' -MaxAttempts 2 -DelaySeconds @(30) -Operation {
$result = Invoke-LoggedProcess -FilePath 'node' -Arguments @(
(Join-Path $config.SourceRepoPath 'bin/ft.mjs'),
'export',
'--format', 'csv',
'--output', $csvOutputPath
) -WorkingDirectory $config.SourceRepoPath
if ($result.ExitCode -ne 0) {
throw "ft export failed.`n$($result.StdErr)$($result.StdOut)"
}
return $result
}
$siteOutputPath = Join-Path $stagingRoot 'site'
Invoke-WithRetry -Label 'ft site-export' -MaxAttempts 2 -DelaySeconds @(30) -Operation {
$result = Invoke-LoggedProcess -FilePath 'node' -Arguments @(
(Join-Path $config.SourceRepoPath 'bin/ft.mjs'),
'site-export',
'--output', $siteOutputPath
) -WorkingDirectory $config.SourceRepoPath
if ($result.ExitCode -ne 0) {
throw "ft site-export failed.`n$($result.StdErr)$($result.StdOut)"
}
return $result
}
The generated managed tree looks like this:
fieldtheory/
raw/
bookmarks.jsonl
bookmarks-meta.json
exports/
bookmarks-full-latest.csv
bookmarks-full-latest.xlsx
site/
manifest.json
bookmarks.index.json
bookmarks.full.jsonl
search-index.json
topics.json
stats.json
status/
last-sync.json
README.md
The remote update uses a temporary clone. The runner replaces the managed root, creates a commit, and pushes the configured branch:
$managedRootInClone = Join-Path $clonePath $config.ManagedRoot
Remove-PathIfExists -Path $managedRootInClone
Copy-Item -LiteralPath $stagingRoot -Destination $managedRootInClone -Recurse -Force
$addResult = Invoke-LoggedProcess `
-FilePath 'git' `
-Arguments @('add', '--all', $config.ManagedRoot) `
-WorkingDirectory $clonePath
$commitMessage = if ($addedCount -gt 0) {
"Sync bookmarks ($addedCount new) $($timestamp.ToString('yyyy-MM-dd HH:mm zzz'))"
} else {
"Heartbeat bookmark sync $($timestamp.ToString('yyyy-MM-dd HH:mm zzz'))"
}
$commitResult = Invoke-LoggedProcess `
-FilePath 'git' `
-Arguments @('commit', '-m', $commitMessage) `
-WorkingDirectory $clonePath
$pushResult = Invoke-LoggedProcess `
-FilePath 'git' `
-Arguments @('push', '-u', 'origin', $config.RepoBranch) `
-WorkingDirectory $clonePath
The GitHub repository receives the generated site data through this path. Git authentication belongs to the Windows machine. A GitHub token can live in the credential manager or in an SSH configuration, with the repository URL chosen to match that setup.
Keeping the frontend repository private
The repository can stay private. Cloudflare Pages supports both private and public GitHub repositories through its Git integration, then publishes the contents of the configured output directory. Visitors receive the generated dist files through the Pages domain. They receive the deployed site, while the Git history, source tree, and local sync scripts remain inside the private repository. See the Cloudflare Pages Git integration guide.
The public boundary sits at dist. Every field copied into dist/data becomes readable by a visitor who knows the file URL. Review the generated JSON before the first deployment and decide which bookmark fields belong in a public archive.
For a personal archive, place Cloudflare Access in front of the Pages project or publish the site behind an authenticated route. A public custom domain works well for a deliberately public reading archive. The article can describe either version with the same source layout.
The website export format
The CLI command is registered in src/cli.ts:
program
.command('site-export')
.description('Generate website-ready cleaned JSON artifacts from the raw bookmark cache')
.option('--output <dir>', 'Write site artifacts to a directory')
.action(safe(async (options) => {
if (!requireData()) return;
const result = await exportSiteDataset({
output: options.output ? String(options.output) : path.join(dataDir(), 'site'),
});
console.log(`Exported ${result.exported} site bookmarks -> ${result.outputDir}`);
}));
The implementation lives in src/bookmarks-site-export.ts. It reads bookmarks.jsonl, removes duplicate tweet IDs, normalizes author fields, extracts links and media, creates topic buckets, and writes the artifact set.
One detail matters for dates. X's bookmark timeline response includes each post's created_at value and an opaque sortIndex used for timeline order. It does not include the moment when the account saved the post. The exporter keeps sortIndex as bookmarkOrder, uses postedAt for the post date, and uses syncedAt as capturedAt when the archive observed the record. The frontend labels that field Captured, so the public page does not turn an ordering token into a false calendar date.
The main transformation has this shape:
export async function exportSiteDataset(options: SiteExportOptions = {}): Promise<SiteExportResult> {
if (!options.output) {
throw new Error('Site export requires an output directory.');
}
const outputDir = path.resolve(options.output);
const records = await readJsonLines<BookmarkRecord>(twitterBookmarksCachePath());
const dataset = buildSiteDataset(records, { generatedAt: options.generatedAt });
await ensureDir(outputDir);
const files = {
manifest: path.join(outputDir, 'manifest.json'),
index: path.join(outputDir, 'bookmarks.index.json'),
full: path.join(outputDir, 'bookmarks.full.jsonl'),
search: path.join(outputDir, 'search-index.json'),
topics: path.join(outputDir, 'topics.json'),
stats: path.join(outputDir, 'stats.json'),
};
await writeJson(files.manifest, dataset.manifest);
await writeJson(files.index, dataset.index);
await writeJsonLines(files.full, dataset.bookmarks);
await writeJson(files.search, dataset.search);
await writeJson(files.topics, dataset.topics);
await writeJson(files.stats, dataset.stats);
return {
exported: dataset.bookmarks.length,
outputDir,
files,
};
}
The manifest gives the frontend a small discovery layer:
{
"schemaVersion": 1,
"generatedAt": "2026-08-14T12:00:00.000Z",
"source": "fieldtheory-cli",
"bookmarkCount": 1842,
"artifacts": {
"index": "bookmarks.index.json",
"full": "bookmarks.full.jsonl",
"search": "search-index.json",
"topics": "topics.json",
"stats": "stats.json"
}
}
The browser loads search-index.json, topics.json, and stats.json. The full JSONL archive remains available for deeper tooling and future builds.
The static frontend
The frontend repository has a tiny package file:
{
"name": "my-twitter-bookmarks-web",
"version": "1.0.0",
"private": true,
"description": "Searchable public view over a private Field Theory bookmark archive.",
"scripts": {
"build": "node web/build.mjs"
},
"engines": {
"node": ">=20"
}
}
The build script creates dist, copies the browser files, and places the generated archive under dist/data:
import { cp, mkdir, rm } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const webDir = path.dirname(fileURLToPath(import.meta.url));
const repoDir = path.resolve(webDir, '..');
const distDir = path.join(repoDir, 'dist');
await rm(distDir, { recursive: true, force: true });
await mkdir(distDir, { recursive: true });
for (const filename of ['index.html', 'styles.css', 'app.js', '_headers']) {
await cp(path.join(webDir, filename), path.join(distDir, filename));
}
await cp(path.join(repoDir, 'fieldtheory', 'site'), path.join(distDir, 'data'), { recursive: true });
console.log(`Built bookmark frontend: ${distDir}`);
The _headers file controls cache behavior for the changing manifest and the data files:
/data/manifest.json
Cache-Control: no-cache
/data/*
Cache-Control: public, max-age=300
The page shell is plain HTML. It gives the browser code stable elements for the archive status, filters, result grid, and detail drawer.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f6f4ef" />
<meta name="description" content="A living index of saved ideas, tools, research, and opportunities." />
<title>Saved Signals</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<div class="site-shell">
<header class="topbar">
<a class="brand" href="./" aria-label="Saved Signals home">
<span class="brand-mark">SS</span>
<span>
<strong>Saved Signals</strong>
<small>an indexed bookmark archive</small>
</span>
</a>
<div class="sync-pill" id="sync-status" data-state="loading">
<span class="status-dot" aria-hidden="true"></span>
<span>Loading archive</span>
</div>
</header>
<main>
<section class="intro" aria-labelledby="page-title">
<div class="intro-copy">
<p class="eyebrow">Field notes from the scroll</p>
<h1 id="page-title">Everything I saved because it made me stop.</h1>
<p class="intro-text">A searchable window into the ideas, tools, people, and opportunities I keep coming back to.</p>
</div>
<div class="intro-aside">
<p class="sync-label">Latest archive</p>
<strong id="sync-date">Loading</strong>
<a href="https://signals.asynchronope.my.id/" target="_blank" rel="noreferrer">Open deployed archive</a>
</div>
</section>
<section class="stat-strip" aria-label="Archive statistics">
<div class="stat-block">
<span class="stat-value" id="total-count">-</span>
<span class="stat-label">bookmarks</span>
</div>
<div class="stat-block">
<span class="stat-value" id="author-count">-</span>
<span class="stat-label">voices</span>
</div>
<div class="stat-block">
<span class="stat-value" id="topic-count">-</span>
<span class="stat-label">signals</span>
</div>
<div class="stat-block stat-note">
<span class="stat-label">Archive span</span>
<strong id="date-range">Loading</strong>
</div>
</section>
<section class="workspace" aria-label="Bookmark explorer">
<aside class="sidebar">
<div class="sidebar-section search-section">
<label class="search-label" for="search-input">Search the archive</label>
<div class="search-box">
<span class="search-icon" aria-hidden="true">/</span>
<input id="search-input" type="search" placeholder="Try: agent memory, fellowship, design" autocomplete="off" />
<button id="clear-search" class="icon-button" type="button" aria-label="Clear search" title="Clear search">x</button>
</div>
<p class="search-hint">Press <kbd>/</kbd> to focus</p>
</div>
<div class="sidebar-section">
<label class="field-label" for="topic-select">Signal</label>
<select id="topic-select"><option value="all">All signals</option></select>
</div>
<div class="sidebar-section">
<label class="field-label" for="year-select">Posted year</label>
<select id="year-select"><option value="all">All years</option></select>
</div>
<div class="sidebar-section">
<label class="field-label" for="sort-select">Sort by</label>
<select id="sort-select">
<option value="saved">Latest in X order</option>
<option value="posted">Recently posted</option>
<option value="author">Author name</option>
</select>
</div>
<div class="sidebar-section topic-section">
<div class="section-heading">
<span class="field-label">Most present</span>
<span class="section-count" id="topic-list-count">-</span>
</div>
<div class="topic-list" id="topic-list"></div>
</div>
</aside>
<section class="results-panel" aria-live="polite">
<div class="results-toolbar">
<div>
<p class="eyebrow">Your index</p>
<h2 id="results-title">All bookmarks</h2>
</div>
<span class="results-count" id="results-count">Loading</span>
</div>
<div class="active-filters" id="active-filters" hidden></div>
<div class="results-grid" id="results-grid"></div>
<div class="empty-state" id="empty-state" hidden>
<span class="empty-mark">?</span>
<h3>Nothing matched that search.</h3>
<p>Try a broader phrase or clear one of the filters.</p>
</div>
<div class="error-state" id="error-state" hidden>
<span class="empty-mark">!</span>
<h3>The archive could not be loaded.</h3>
<p id="error-message">Check the latest deployment and try again.</p>
</div>
<button class="load-more" id="load-more" type="button" hidden>Load more bookmarks</button>
</section>
</section>
</main>
<footer class="footer">
<span>Synced from Field Theory twice daily.</span>
<span>Built for slow browsing.</span>
</footer>
</div>
<div class="drawer-backdrop" id="drawer-backdrop" hidden></div>
<aside class="detail-drawer" id="detail-drawer" aria-label="Bookmark detail" aria-hidden="true" hidden>
<div class="drawer-header">
<span class="eyebrow">Bookmark detail</span>
<button class="icon-button drawer-close" id="drawer-close" type="button" aria-label="Close detail">x</button>
</div>
<div id="drawer-content"></div>
</aside>
<script type="module" src="./app.js"></script>
</body>
</html>
The browser code keeps its state in memory. It fetches the manifest, finds the current artifact names, loads the search data, and renders cards from that array.
const DATA_BASE = './data/';
const PAGE_SIZE = 36;
const state = {
bookmarks: [],
topics: [],
stats: {},
query: '',
topic: 'all',
year: 'all',
sort: 'saved',
visible: PAGE_SIZE,
activeId: null
};
const $ = (selector) => document.querySelector(selector);
const nf = new Intl.NumberFormat('en-US');
const els = {
syncStatus: $('#sync-status'), syncDate: $('#sync-date'), totalCount: $('#total-count'), authorCount: $('#author-count'),
topicCount: $('#topic-count'), dateRange: $('#date-range'), search: $('#search-input'), clearSearch: $('#clear-search'),
topic: $('#topic-select'), year: $('#year-select'), sort: $('#sort-select'), topicList: $('#topic-list'),
topicListCount: $('#topic-list-count'), resultsTitle: $('#results-title'), resultsCount: $('#results-count'),
activeFilters: $('#active-filters'), grid: $('#results-grid'), empty: $('#empty-state'), error: $('#error-state'),
errorMessage: $('#error-message'), loadMore: $('#load-more'), backdrop: $('#drawer-backdrop'), drawer: $('#detail-drawer'),
drawerContent: $('#drawer-content'), drawerClose: $('#drawer-close')
};
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character]));
}
function safeUrl(value) {
try {
const url = new URL(String(value || ''));
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : '';
} catch {
return '';
}
}
function formatDate(value, fallback = 'Undated') {
if (!value) return fallback;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? fallback : new Intl.DateTimeFormat('en', { day: '2-digit', month: 'short', year: 'numeric' }).format(date);
}
function compactDate(value) {
if (!value) return '';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? '' : new Intl.DateTimeFormat('en', { month: 'short', year: 'numeric' }).format(date);
}
function capturedDate(bookmark) {
return bookmark.capturedAt || bookmark.postedAt || '';
}
function compareSaved(a, b) {
const aOrder = String(a.bookmarkOrder || '');
const bOrder = String(b.bookmarkOrder || '');
if (/^\d+$/.test(aOrder) && /^\d+$/.test(bOrder) && aOrder !== bOrder) {
return BigInt(aOrder) > BigInt(bOrder) ? -1 : 1;
}
return String(capturedDate(b)).localeCompare(String(capturedDate(a)));
}
function firstLink(bookmark) {
return (Array.isArray(bookmark.links) ? bookmark.links : [])
.map((link) => typeof link === 'string' ? link : link?.url)
.map(safeUrl)
.find(Boolean) || '';
}
function authorLabel(bookmark) {
const author = bookmark.author || {};
return author.name && author.handle ? author.name + ' @' + author.handle : author.handle ? '@' + author.handle : author.name || 'Unknown voice';
}
function topicLabel(topic) {
return topic.label || topic.name || topic.id || 'General';
}
function topicId(topic) {
return topic.id || topic.name || topic.label || 'general';
}
function searchBlob(bookmark) {
return [
bookmark.title, bookmark.excerpt, bookmark.text, bookmark.author?.handle, bookmark.author?.name, bookmark.primaryTopic,
...(bookmark.topics || []), ...(bookmark.tags || []),
...(bookmark.links || []).map((link) => typeof link === 'string' ? link : link?.url || ''),
bookmark.quotedTweet?.text, bookmark.quotedTweet?.author?.handle
].join(' ').toLowerCase();
}
function score(bookmark, query) {
if (!query) return 0;
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
const title = String(bookmark.title || '').toLowerCase();
const author = String(bookmark.author?.handle || '').toLowerCase();
const blob = searchBlob(bookmark);
return terms.reduce((total, term) => {
if (!blob.includes(term)) return -100000;
return total + (title.includes(term) ? 12 : 0) + (author.includes(term) ? 8 : 0) + (blob.split(term).length - 1);
}, 0);
}
function getResults() {
const query = state.query.trim();
const results = state.bookmarks.filter((bookmark) => {
const matchesTopic = state.topic === 'all' || bookmark.primaryTopic === state.topic || (bookmark.topics || []).includes(state.topic);
const matchesYear = state.year === 'all' || String(bookmark.year || '') === state.year;
return matchesTopic && matchesYear && (!query || score(bookmark, query) > -100000);
});
results.sort((a, b) => {
if (query) {
const difference = score(b, query) - score(a, query);
if (difference) return difference;
}
if (state.sort === 'author') return authorLabel(a).localeCompare(authorLabel(b));
if (state.sort === 'saved') return compareSaved(a, b);
return String(b.postedAt || '').localeCompare(String(a.postedAt || ''));
});
return results;
}
function renderCard(bookmark) {
const link = firstLink(bookmark);
const topic = bookmark.primaryTopic || (bookmark.topics || [])[0] || 'saved';
const host = link ? new URL(link).hostname.replace(/^www\./, '') : 'Open detail';
return '<button class="bookmark-card" type="button" data-id="' + escapeHtml(bookmark.id) + '">' +
'<span class="card-top"><span class="card-topic">' + escapeHtml(topic) + '</span><span class="card-year">' + escapeHtml(bookmark.year || '') + '</span></span>' +
'<span class="card-author">' + escapeHtml(authorLabel(bookmark)) + '</span>' +
'<span class="card-title">' + escapeHtml(bookmark.title || bookmark.excerpt || 'Saved signal') + '</span>' +
'<span class="card-excerpt">' + escapeHtml(bookmark.excerpt || bookmark.text || '') + '</span>' +
'<span class="card-bottom"><span>' + escapeHtml(compactDate(bookmark.capturedAt || bookmark.postedAt)) + '</span><span class="card-link">' + escapeHtml(host) + '</span></span>' +
'</button>';
}
function renderStats() {
const stats = state.stats || {};
els.totalCount.textContent = nf.format(stats.bookmarkCount || stats.totalBookmarks || state.bookmarks.length);
els.authorCount.textContent = nf.format(stats.authorCount || stats.uniqueAuthors || new Set(state.bookmarks.map((item) => item.author?.handle).filter(Boolean)).size);
els.topicCount.textContent = nf.format(state.topics.length);
const dates = state.bookmarks.map((item) => item.postedAt || item.capturedAt).filter(Boolean).sort();
els.dateRange.textContent = dates.length ? new Date(dates[0]).getFullYear() + ' - ' + new Date(dates[dates.length - 1]).getFullYear() : 'No dates';
}
function renderOptions() {
const years = [...new Set(state.bookmarks.map((item) => item.year).filter(Boolean))].sort((a, b) => Number(b) - Number(a));
els.year.innerHTML = '<option value="all">All years</option>' + years.map((year) => '<option value="' + escapeHtml(year) + '">' + escapeHtml(year) + '</option>').join('');
els.year.value = state.year;
els.topic.innerHTML = '<option value="all">All signals</option>' + state.topics.map((topic) => '<option value="' + escapeHtml(topicId(topic)) + '">' + escapeHtml(topicLabel(topic)) + ' (' + nf.format(topic.count || 0) + ')</option>').join('');
els.topic.value = state.topic;
els.topicListCount.textContent = nf.format(state.topics.length);
els.topicList.innerHTML = state.topics.slice(0, 12).map((topic) => '<button class="topic-button' + (state.topic === topicId(topic) ? ' is-active' : '') + '" type="button" data-topic="' + escapeHtml(topicId(topic)) + '"><span>' + escapeHtml(topicLabel(topic)) + '</span><span>' + nf.format(topic.count || 0) + '</span></button>').join('');
}
function renderResults() {
const results = getResults();
const shown = results.slice(0, state.visible);
const selectedTopic = state.topics.find((topic) => topicId(topic) === state.topic);
els.resultsTitle.textContent = state.query ? 'Search results' : selectedTopic ? topicLabel(selectedTopic) : 'All bookmarks';
els.resultsCount.textContent = nf.format(results.length) + (results.length === 1 ? ' result' : ' results');
els.grid.innerHTML = shown.map(renderCard).join('');
els.empty.hidden = results.length !== 0;
els.loadMore.hidden = shown.length >= results.length || results.length === 0;
}
async function fetchJson(file) {
const response = await fetch(DATA_BASE + file, { cache: 'no-store' });
if (!response.ok) throw new Error(file + ' returned HTTP ' + response.status);
return response.json();
}
async function init() {
try {
const manifest = await fetchJson('manifest.json');
const [bookmarks, topics, stats] = await Promise.all([
fetchJson(manifest.artifacts?.search || 'search-index.json'),
fetchJson(manifest.artifacts?.topics || 'topics.json'),
fetchJson(manifest.artifacts?.stats || 'stats.json')
]);
state.bookmarks = Array.isArray(bookmarks) ? bookmarks : [];
state.topics = Array.isArray(topics) ? topics : [];
state.stats = stats || {};
els.syncStatus.dataset.state = 'ready';
els.syncStatus.lastElementChild.textContent = 'Archive ready';
els.syncDate.textContent = formatDate(manifest.generatedAt, 'Unknown');
renderStats();
renderOptions();
renderResults();
} catch (error) {
els.syncStatus.dataset.state = 'error';
els.syncStatus.lastElementChild.textContent = 'Archive unavailable';
els.errorMessage.textContent = error.message;
els.error.hidden = false;
}
}
els.search.addEventListener('input', (event) => {
state.query = event.target.value;
state.visible = PAGE_SIZE;
renderResults();
});
els.clearSearch.addEventListener('click', () => {
els.search.value = '';
state.query = '';
renderResults();
els.search.focus();
});
els.topic.addEventListener('change', (event) => {
state.topic = event.target.value;
state.visible = PAGE_SIZE;
renderOptions();
renderResults();
});
els.year.addEventListener('change', (event) => {
state.year = event.target.value;
state.visible = PAGE_SIZE;
renderResults();
});
els.sort.addEventListener('change', (event) => {
state.sort = event.target.value;
state.visible = PAGE_SIZE;
renderResults();
});
els.topicList.addEventListener('click', (event) => {
const button = event.target.closest('[data-topic]');
if (!button) return;
state.topic = button.dataset.topic;
state.visible = PAGE_SIZE;
renderOptions();
renderResults();
});
els.grid.addEventListener('click', (event) => {
const card = event.target.closest('[data-id]');
if (!card) return;
const bookmark = state.bookmarks.find((item) => String(item.id) === String(card.dataset.id));
if (!bookmark) return;
const external = safeUrl(bookmark.url) || firstLink(bookmark);
els.drawerContent.innerHTML = '<h2 class="drawer-title">' + escapeHtml(bookmark.title || 'Saved signal') + '</h2>' +
'<p class="drawer-author">' + escapeHtml(authorLabel(bookmark)) + '</p>' +
'<div class="drawer-text">' + escapeHtml(bookmark.text || bookmark.excerpt || '') + '</div>' +
'<div class="drawer-meta"><span>Captured ' + escapeHtml(formatDate(bookmark.capturedAt || bookmark.postedAt)) + '</span><span>Posted ' + escapeHtml(formatDate(bookmark.postedAt)) + '</span></div>' +
(external ? '<div class="drawer-actions"><a href="' + escapeHtml(external) + '" target="_blank" rel="noreferrer">Open source</a></div>' : '');
els.backdrop.hidden = false;
els.drawer.hidden = false;
els.drawer.setAttribute('aria-hidden', 'false');
});
els.loadMore.addEventListener('click', () => {
state.visible += PAGE_SIZE;
renderResults();
});
function closeDrawer() {
els.backdrop.hidden = true;
els.drawer.hidden = true;
els.drawer.setAttribute('aria-hidden', 'true');
}
els.backdrop.addEventListener('click', closeDrawer);
els.drawerClose.addEventListener('click', closeDrawer);
document.addEventListener('keydown', (event) => {
if (event.key === '/' && document.activeElement !== els.search && !['INPUT', 'SELECT', 'TEXTAREA'].includes(document.activeElement?.tagName)) {
event.preventDefault();
els.search.focus();
}
if (event.key === 'Escape' && !els.drawer.hidden) closeDrawer();
});
init();
The search index is small enough for the browser to hold in memory. Each query scores title matches and author matches, then checks the wider text blob. The page renders 36 cards at a time, which keeps the initial DOM light for a large archive.
The stylesheet gives the archive its paper-like surface and its responsive grid. The full production stylesheet lives in web/styles.css inside the private frontend repository. This compact version shows the layout primitives:
:root {
--paper: #f5f1e9;
--ink: #1f2622;
--muted: #69716b;
--line: #d5cec1;
--card: #fcfaf5;
--accent: #c54d31;
--serif: Georgia, serif;
--sans: "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html { background: var(--paper); color: var(--ink); }
body { margin: 0; min-width: 320px; font-family: var(--sans); background: var(--paper); }
button, input, select { font: inherit; }
a { color: inherit; }
.site-shell { width: min(1480px, 100%); margin: 0 auto; padding: 0 38px 34px; }
.topbar { min-height: 84px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); }
.brand { display: inline-flex; gap: 12px; align-items: center; text-decoration: none; }
.brand-mark { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid var(--ink); border-radius: 50%; }
.intro { display: grid; grid-template-columns: minmax(0, 1fr) 270px; gap: 40px; padding: 78px 0 64px; }
h1 { max-width: 760px; margin: 0 0 20px; font: 500 clamp(46px, 7vw, 94px)/0.94 var(--serif); letter-spacing: -0.055em; }
.stat-strip { display: grid; grid-template-columns: repeat(3, 1fr) 2fr; border-top: 1px solid var(--ink); border-bottom: 1px solid var(--line); }
.stat-block { min-height: 92px; padding: 18px 24px 16px 0; border-right: 1px solid var(--line); }
.workspace { display: grid; grid-template-columns: 230px minmax(0, 1fr); gap: 50px; padding-top: 52px; }
.results-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.bookmark-card { min-height: 270px; display: flex; flex-direction: column; padding: 20px; border: 1px solid var(--line); background: var(--card); cursor: pointer; text-align: left; }
.card-title { margin: 0 0 10px; font: 500 24px/1.04 var(--serif); }
.card-bottom { display: flex; justify-content: space-between; gap: 10px; margin-top: auto; padding-top: 20px; color: var(--muted); font-size: 10px; }
.detail-drawer { position: fixed; z-index: 6; top: 0; right: 0; width: min(560px, 100%); height: 100vh; overflow: auto; padding: 30px 34px 50px; background: var(--card); }
@media (max-width: 760px) {
.site-shell { padding: 0 18px 24px; }
.intro { display: block; padding: 54px 0 42px; }
.workspace { display: block; padding-top: 34px; }
.results-grid { grid-template-columns: 1fr; }
}
The live repository keeps the complete version with topic buttons, drawers, mobile rules, typography, and hover states.
Cloudflare Pages setup
The frontend repository README records these build values:
Production branch: main
Build command: npm run build
Build output directory: dist
Root directory: repository root
In Cloudflare, create a Pages project from the GitHub repository. Choose the main branch. Add the build command and the output directory above. The project’s build invokes web/build.mjs, which copies the data snapshot into the deployable directory.
Cloudflare’s current static HTML guide describes the same model: the build command runs during deployment and the output directory becomes the published site. See the Cloudflare Pages static HTML guide.
The custom domain setup lives under Workers & Pages, the Pages project, and Custom domains. Add signals.asynchronope.my.id there. Cloudflare then connects the subdomain to the Pages project. The custom domains documentation describes the current dashboard path and the CNAME behavior.
For a subdomain, the DNS record points at the Pages hostname:
Type: CNAME
Name: signals
Target: your-project.pages.dev
The domain association happens inside the Pages project before the DNS record is tested. This gives Cloudflare the context needed to activate the custom domain and issue the certificate.
Run the frontend locally
Clone the frontend repository and build it:
git clone https://github.com/your-account/my-twitter-bookmarks.git
Set-Location my-twitter-bookmarks
npm install
npm run build
Serve dist with any static server. Node gives a convenient option:
npx serve dist
Open the local URL printed by the server. The browser should request these files:
/data/manifest.json
/data/search-index.json
/data/topics.json
/data/stats.json
A direct check helps isolate a build problem from a browser problem:
Get-Content .\dist\data\manifest.json
The generatedAt value shows the time recorded by the exporter. The bookmark count comes from the same dataset that the cards display.
Run one sync by hand
The schedule can wait while the pipeline is tested manually:
powershell -ExecutionPolicy Bypass `
-File D:\Repo\fieldtheory\scripts\automation\sync-bookmarks-to-github.ps1 `
-ConfigPath 'C:\Users\your-user\.ft-bookmarks\automation\github-sync\bookmark-sync.config.psd1' `
-TriggerReason 'manual-test'
Watch the log directory:
Get-ChildItem 'C:\Users\your-user\.ft-bookmarks\automation\github-sync\logs' |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1 |
Get-Content
The run should leave a new commit in the frontend repository. Cloudflare Pages will receive that commit through its Git integration and build dist.
A useful failure map
The task stays idle
Inspect the task metadata:
Get-ScheduledTask -TaskPath '\FieldTheory\' -TaskName 'Bookmark Sync'
Get-ScheduledTaskInfo -TaskPath '\FieldTheory\' -TaskName 'Bookmark Sync'
The task account needs permission to read the Firefox profile and execute Node. The machine needs a network connection for the bookmark request and the Git push.
Cookie access fails
Check the Firefox profile path in the config. Firefox should have an active X session in that profile. A Firefox profile can change after a migration or a fresh installation, so inspect the profile directory when this error appears.
Get-ChildItem 'C:\Users\your-user\AppData\Roaming\Mozilla\Firefox\Profiles'
The JSON files update while the site stays stale
Open the deployed data/manifest.json and compare its generatedAt value with the local file. The site sets no-cache on the manifest and a five-minute cache on the other data files. A fresh deployment should show a new manifest immediately after Pages finishes.
The Pages build fails
Run the same build locally:
Set-Location D:\Repo\my-twitter-bookmarks
npm run build
Get-ChildItem .\dist\data
fieldtheory/site must exist before web/build.mjs runs. The source repository needs the generated snapshot commit before Pages can copy it into dist/data.
The public archive exposes more than intended
Review the generated files before the first public deployment. The exporter selects fields for the website. The public repository should contain the chosen bookmark fields and generated metadata. Browser cookies, OAuth tokens, and the local SQLite file belong on the source machine.
Why the split works
Field Theory understands X session sync, local storage, deduplication, classification, and export formats. The frontend understands reading, filtering, and browsing static JSON. Each side has a small boundary made of files.
That boundary makes the archive easy to inspect. A sync run can be tested from PowerShell. A site build can be tested from the frontend repository. A browser request can be opened directly against data/manifest.json.
The site can keep its visual language for years while the dataset changes twice a day. The exporter can add a new field while the browser keeps reading the existing ones. A new consumer can read bookmarks.full.jsonl without joining the frontend bundle.
Complete source files
The production files are longer than the excerpts in this article because they include retries, lock handling, Telegram notifications, XLSX generation, topic rules, detailed normalization, and the full responsive stylesheet. The frontend repository can remain private while the article carries the implementation pattern and the complete public-facing page code.
Use these paths as the canonical source inside the private frontend repository:
- Scheduled task installer
- GitHub sync runner
- Website dataset exporter
- CLI site-export command
web/index.htmlweb/app.jsweb/styles.cssweb/build.mjs
The important operational detail sits in the handoff:
Firefox -> ft sync -> bookmarks.jsonl -> ft site-export -> fieldtheory/site -> git push -> Pages build -> signals.asynchronope.my.id

Top comments (0)