ranzlappen.com
ranzlappen.com
Personal blog + PolyVote community voting platform + Blog Admin dashboard + Inventory Manager, hosted on GitHub Pages.
Architecture
Hybrid project with four independent builds plus a build-time tooling module:
- Jekyll blog (root) β Static site built by GitHub Pages. Posts in
_posts/, layouts in_layouts/, includes in_includes/, pages inpages/. Config:_config.yml. - PolyVote (
polyvote/) β React 19 SPA built with Vite. Backend: Firebase (Firestore, Auth, Cloud Functions). Deployed as a subfolder within the Jekyll_site/. - Blog Admin (
blog-admin/) β React 19 SPA built with Vite for managing blog drafts and publishing. Uses Firebase (Firestore, Auth), CodeMirror 6 for Markdown editing, and Zustand for state. Deployed as a subfolder within the Jekyll_site/. - Inventory Manager (
inventory-manager/) β React 19 SPA built with Vite for managing inventory with folders, custom per-folder field schemas, photos in Firebase Storage, CSV/JSON import-export, and multi-platform export (per-folder βplatform tagsβ drive required columns + a per-platform CSV/TSV/XML export for eBay, Amazon, Kleinanzeigen, Whatnot, Facebook, idealo, billiger.de, Geizhals). Admin-only via Firebase Auth custom claim (same login as Blog Admin). Hidden from crawlers (robots.txtDisallow: /inventory/+noindexmeta tags + not listed in nav). Deployed as a subfolder within the Jekyll_site/at/inventory/. Seeinventory-manager/README.mdfor the architecture handbook (data model, persistence, the platform registry + export formats, how to add field types or functions). - Search Crawler (
search-crawler/) β Node 22, dependency-free build tooling (not deployed). Crawls off-site content (*.ranzlappen.comsubdomains,*.ranzlappen.github.io,github.com/Ranzlappenrepos + gists) and writes the committed static indexsearch-external.json, which the blogβs grouped cross-domain search merges with the Jekyll-generatedsearch.json. Run on demand via thesearch-crawl.ymlworkflow ornpm run crawl. Seesearch-crawler/README.md.
Build & Development
Blog (Jekyll)
bundle exec jekyll serve # Local dev server at :4000
PolyVote (React/Vite)
cd polyvote
npm install # Install dependencies
npm run dev # Local dev server at :5173
npm run build # Production build (tsc + vite build)
npm run lint # ESLint (flat config)
npm test # Vitest (unit tests)
npm run format # Prettier formatting
Blog Admin (React/Vite)
cd blog-admin
npm install # Install dependencies
npm run dev # Local dev server
npm run build # Production build (tsc + vite build)
npm run lint # ESLint (flat config)
npm run format # Prettier formatting
Inventory Manager (React/Vite)
cd inventory-manager
npm install # Install dependencies
npm run dev # Local dev server
npm run build # Production build (tsc + vite build)
npm run lint # ESLint (flat config)
npm run format # Prettier formatting
Search Crawler (Node)
cd search-crawler
npm run crawl # Build ../search-external.json (web works; repos/gists need GITHUB_TOKEN)
GITHUB_TOKEN=<pat> npm run crawl # Include the repos/gists groups (raises API limit 60β5000/hr)
npm run lint # node --check syntax pass (what CI runs)
Cloud Functions
cd polyvote/functions
npm install
npm run build # Compile TypeScript
npm run lint # tsc --noEmit
npm test # Vitest (unit tests)
Production deploys of castBlogVote, the Blog Admin callables (blogSaveDraft, blogListDrafts, blogGetDraft, blogDeleteDraft, blogListExistingPosts, blogFetchExistingPost, blogImportPostForEdit, blogPublishToGitHub, blogUploadImage, blogListSeriesUsage), the admin user-management callables shared with the Blog Admin Users panel (setUserRole, adminListUsers, adminBanUser, adminUnbanUser), and the Inventory Manager callables (inventoryListFolders, inventoryCreateFolder, inventoryUpdateFolder, inventoryDeleteFolder, inventoryDuplicateFolder, inventoryListItems, inventoryGetItem, inventoryCreateItem, inventoryUpdateItem, inventoryDeleteItem, inventoryToggleEbaySync, inventoryUploadPhoto, inventoryDeletePhoto, inventoryReorderPhotos, inventoryImport, inventoryExport, inventoryExportPlatforms) are automated (see CI/CD below). Manual deploys of anything else use firebase deploy --only functions:<name> from polyvote/.
Key Conventions
- Module boundaries:
polyvote/,blog-admin/,inventory-manager/, and the Jekyll root (blog) are four independent modules, withpolyvote/functions/as a fifth nested module andsearch-crawler/as a sixth (dependency-free build tooling, not deployed). Each has its ownpackage.json/Gemfile, TypeScript/ESLint/Tailwind config, and deploy path. Run install/lint/test/build/format from within the moduleβs own directory (see Build & Development). Do not cross-import source between modules β there is no monorepo tooling and no shared package. If logic truly needs to be shared, duplicate it intentionally (e.g.polyvote/functions/src/inventory/shared.tsis mirrored ininventory-manager/src/types.ts, and the platform registrypolyvote/functions/src/inventory/platforms.tsis mirrored data-only ininventory-manager/src/platforms.tsβ backend keeps the value-transform functions the frontend doesnβt need). Scope PRs to a single module when possible so CIβs per-app path filters stay meaningful. - Post status: Posts use a
statusfield in front matter (published,draft,placeholder,unpublished). Onlypublishedandplaceholderappear in the sitemap and feed. - Post categories: Posts set a singular
category:field in front matter. The string"Projects"(capitalized, exact match) is canonical and routes the post to/projects/; everything else lands on/blog/. Homepage and/categories/show all categories. Liquidβs==is case-sensitive β keep the exact casing. - Navigation: Centralized in
_data/pages.ymlβ single source of truth for nav and footer links. Reference pages are reached via the top-nav References entry, which routes to/references/β a hand-rolled index page (pages/references/index.html) that lists Spectrum, Electronics Fundamentals, and the CLI Cheat Sheet as cards. Donβt add individual reference URLs to_data/pages.yml; add a new card to the index page instead. - Abbreviations / glossary: Reference pages share one utility for term cards + click-to-explain modal + opt-in in-content decoration. Per-page YAML datasets live under
_data/abbreviations/<page>.yml; markup is_includes/abbreviations-section.html; styles/assets/css/abbreviations.css; behaviour/assets/js/abbreviations.js. To add it to a page: include the partial withdata=site.data.abbreviations.<page>, pull in the CSS+JS, and adddata-abbr-decorateto any element whose text should auto-link matched terms. Per-page datasets are isolated, so the same key can have different definitions on different pages with no collision. Entry schema:term+full_form+explanation(required) plus optionalplain(beginner-first prose),example(shell/code snippet rendered as<pre><code>in the modal β string or array; literal text, not HTML),interpretation(structured table withtitle/columns/rows/footnote), andaliases(array of case/plural variants emitted into the JSON island as{"aliasOf": term}so they decorate and open the canonical card without rendering their own card β this is how lowercaseposix/ pluralglobsbecome clickable). All optional fields are additive β existing entries without them render identically.abbreviations.jsexposeswindow.Glossary = { openTerm, openInline, decorate }for pages that drive the modal directly (the CLI cheat sheet wires worked-example clicks toopenInline, which uses the modalβs visibleleadfield). Decoration normally skips<code>, but a<code class="abbr-decorate-code">opts back in so command tokens inside worked examples become clickable glossary triggers. - Reference-table scaffolding: The sticky tab strip + live-search + scrollable big-table pattern used by
/references/spectrum/and/references/cmd-cheat-sheet/is shared. Styles live in/assets/css/reference-table.css(controls, tabs, search, sticky thead,.is-sticky-colhook for the pinned column, generic legend swatches,.reference-badgebase, the.cell-collapsefamily). Behaviour lives in/assets/js/reference-table.jsand exposes one function βwindow.initReferenceTable({tableId, tabSelector, searchId, countId, emptyId, blurbId, batchDataId, sortable, osFilterId, osAttr})β that each pageβs thin wrapper calls with its own IDs. The last three are opt-in:sortable: trueinjects a click-to-sort control into every<th>(cycles ascending β descending β original; a cellβs sort key is itsdata-sortattribute, else its text, numeric when both compared values parse as numbers; one column at a time), andosFilterId/osAttrwire a secondary<select>filter (the.reference-osfiltercontrol) that ANDs a token match against a per-row attribute (defaultdata-os) on top of the tab + search filters. The CLI cheat sheet passes all three; Spectrum passes none (unchanged). Per-page CSS files (spectrum.css,cmd-cheat-sheet.css) layer column widths and palette modifiers on top, scoped under.reference-page--<slug>so overrides cannot leak between pages. To add a third reference page: opt in via<section class="reference-page reference-page--<slug>">, load the shared CSS/JS before the per-page ones, and write a thin wrapper that callsinitReferenceTable. The shared CSS also carries a tablet breakpoint (641β1024pxdrops the search box onto its own full-width row). The table wrapper is a bounded sticky scroll box (sticky thead + sticky.is-sticky-col) on all viewports including phones; on mobile themax-heightis capped lower (calc(100vh - header - 9rem)) so the page footer sits cleanly below the box and stays reachable by scrolling past it (no static/un-pin override β an earlier touch-un-pin hack was removed because it killed the sticky header and let the footer bleed into the table). The bounded box is deliberate: a horizontal-scroll container is required for the wide table, and that same container is what lets the sticky thead + sticky first column pin (a pure page-flow table canβt scroll wide content horizontally without breaking the viewport-pinned header). The controls stripβs stickytopand the wrapperβstop/max-heightkey off the global--header-offsetcustom property (defaults to--header-height), so unpinning the site header (which sets--header-offset: 0β see the header sticky-toggle under Theme) collapses the gap and grows the box to reclaim that height. The wrapperβstop/max-heightalso add--reference-controls-hβ the real measured height of the.reference-controlsstrip, whichreference-table.jspublishes on<html>(once on init, then live via aResizeObserveron the strip + a debouncedresizelistener) β so the box pins exactly below the controls instead of a hardcoded gap. This fixes the mobile bug where the controls strip stacks to 2β3 rows (taller than the old5.25remgap) and itsz-index: 10covered the sticky thead, leaving the reader with no column header; the3.25rem/5.25remliterals now survive only as no-JS fallbacks./assets/js/resize-handle.jsis a shared, self-attaching util that replaces the tiny native textarea resize corner with a large pointer+keyboard drag grabber on anytextarea[data-resize]or.cmd-widget__textarea; load it after whatever builds the textareas. The CLI cheat sheetβs table adds five columns beyond Spectrumβs pattern β OS / Appliance (sortable; its<select>drives the secondary OS filter), Danger (sortable via a numericdata-sortrank: safe0< caution1< destructive2), Recipes/Combos (recipes= array of{code, explain}), Modern alt (modernstring), and Docs (renders the existingreferencesfield) β and rendersexamples(now{code, explain}objects) as clickable buttons that open an explanation modal viawindow.Glossary.openInline. The per-row OS badge pills and thedangerchip (safe/caution/destructive) also render stacked beneath the command name inside the sticky first column: this is intentional redundancy β the stacked copy stays visible during horizontal scroll, while the dedicated OS / Appliance and Danger columns are the sortable + filterable/searchable ones (each<tr>carriesdata-os+data-dangerfor the filter/sort, and the OS column renders text labels so search matches βlinuxβ, βmacosβ, β¦ too). Raw-HTML cells:description, flagdescription,modern, andgotchasare emitted as raw HTML so inline<code>works β literal placeholder angle brackets (<script>,<path>, β¦) must be escaped (<β¦>) in the data or they break the table DOM (a literal<script>swallows the rest of the table so the footer renders inside it)._data/cmd-cheat-sheet/lint.shfails the build if any rawtext/script tag lands inside the command table. - cmd-widgets (Interactive Tools section on the CLI cheat sheet): Four browser-only widgets β chmod calculator, find builder, regex tester, curl composer β live on
/references/cmd-cheat-sheet/. JS lives in/assets/js/cmd-widget-core.js(registry +CMDW.mountAll()+CMDW.copyToClipboard()+CMDW.shellEscape()+CMDW.makeOutput()/CMDW.el()helpers) and fourcmd-widget-<name>.jsfiles that each callCMDW.register('<name>', factory). The entry pointcmd-widget-bundle.jscallsmountAll()on DOMContentLoaded; the loader scans for<section data-cmd-widget="<name>">and invokes the matching factory. Each factory builds its UI into a collapsible<details>shell viaCMDW.makeShell(root, title)(returns the body element to append into; the title becomes the<summary>heading). Styles in/assets/css/cmd-widgets.css. Adding a fifth widget: createcmd-widget-<name>.js(callCMDW.makeShellfirst, append controls to the returned body), drop a<section data-cmd-widget="<name>">on the page, add the script tag, optionally hook asee_also: ["widget-<name>"]entry from a cmd row (the see-also renderer incmd-cheat-sheet.htmlrouteswidget-*slugs to#widget-<name>instead of#cmd-<slug>). Per CLAUDE.mdβs βduplicate intentionallyβ rule, the widget core deliberately mirrors EFβs pattern rather than importing it β theyβre independent. - External apps: The userβs external apps (standalone subdomains like
ticked.ranzlappen.com) are not in the navbar. Theyβre listed in_data/projects.ymland rendered as a favicon strip in the footer via_includes/footer.html. Favicons are committed locally underassets/images/favicons/β do not hotlink upstream favicons (privacy-first: hotlinking leaks visitor IP/UA to the subdomain on every page load, before consent). To refresh a favicon,curlthe upstream<link rel="icon">target intoassets/images/favicons/<name>.pngand commit. - Search (blog): The
Ctrl/Cmd+Kmodal (_includes/search-modal.html,assets/js/search.js) runs client-side Lunr over two merged indexes, rendering results grouped by source (order:blog,pages,references,apps,gh-pages,repos,gists). Local content is generated fresh by Jekyll into/search.json(posts +site.html_pages+ reference pages, each tagged with agroup); off-site content is a committed crawl snapshot in/search-external.json(produced bysearch-crawler/). The merge stays behind the existing functional-cookie consent gate and loads nothing third-party at query time beyond the already-gated Lunr CDN script β keep it that way.urlis the Lunrrefand must stay unique across both files (local entries are root-relative, external are absolute). To widen scope, add a{ url, group }seed insearch-crawler/sources.config.js(and a matching label in theGROUPSarray insearch.js) β donβt add a query-time third-party search service. - Firebase keys: Public client-side keys in
_config.yml,polyvote/src/firebase.ts,blog-admin/src/firebase.ts, andinventory-manager/src/firebase.ts. Security is enforced via Firestore rules, Storage rules, and Cloud Functions. - Server-validated writes: All client writes go through Cloud Functions (
httpsCallable), never direct Firestore SDK writes. This applies to PolyVote user actions (votes, comments, requests), Blog Admin operations (drafts, publishing), and Inventory Manager operations (folders, items, photos, import/export, eBay CSV). Keepblog-admin/src/firebase.tsandinventory-manager/src/firebase.tsfree ofaddDoc/setDoc/updateDoc/deleteDoc. - Inventory Manager hiding: The tool lives at
/inventory/and must stay invisible to crawlers. Three layers guard this:robots.txtcarriesDisallow: /inventory/; the SPAβsindex.htmlships<meta name="robots" content="noindex,nofollow,noarchive,nosnippet">; nothing in_data/pages.ymlor_data/projects.ymllinks to it. Do not add it to nav, footer, or any public-facing page. - Inventory Storage: Photos go to Firebase Storage bucket
proven-concept-436717-q3.firebasestorage.app(the new-style bucket β not the legacy.appspot.com) atinventory/{itemId}/{uuid}.{ext}and are made public-read so eBayβsPicURLfield can fetch them. The bucket name is pinned inpolyvote/functions/src/inventory/photos.ts(INVENTORY_BUCKETconstant) and ininventory-manager/src/firebase.ts(storageBucket). Storage rules inpolyvote/storage.rulesblock all client writes; uploads only happen via theinventoryUploadPhotoCloud Function (admin SDK bypasses rules). Firebase Storage must be enabled in the Firebase Console for this to work β one-time manual setup. - Blog import flow: Importing an existing
_posts/file from Blog Admin offers two explicit modes β Edit (links the draft to the GitHub file viablogDrafts.sourceFilename, so re-imports reopen the same draft and publish updates in place) and Copy (unlinked draft seeded with a-copyslug for creating a new post).blogPublishToGitHubrequiresconfirmOverwrite: truewhen a draft would silently overwrite an unlinked GitHub file. - Privacy-first: No Google Analytics. Cookie consent is GDPR-compliant with functional category.
- Theme: Dark mode is default across all four modules. The blog and reference pages (Spectrum, Electronics Fundamentals) share one dark/light toggle driven by CSS custom properties on
<html data-theme>. PolyVote uses Tailwind + CSS variables persisted via Zustand/localStorage. Blog Admin and Inventory Manager are dark-only by design β no theme toggle, no.lightCSS variant. - Header sticky-toggle (global, blog): A pin/unpin button in the headerβs
.nav-actions(#header-sticky-toggle, mirrors the theme toggleβs localStorage + pre-paint bootstrap + two-icon swap) lets the visitor unpin the normally-fixedsite header so it scrolls away with the page. State lives inlocalStorage.headerSticky(ondefault /off), bootstrapped synchronously in_includes/head.html(sets<html data-header-sticky="off">before paint) and toggled inassets/js/main.js. CSS instyle.css:html[data-header-sticky="off"]sets--header-offset: 0rem(the default is--header-offset: var(--header-height)) and switches.site-headertoposition: absolute(so it scrolls away βmain#main-contentβspadding-topstill reserves its height). Throughout the wholeoffstate the.header-sticky-toggleisposition: fixed, positioned (top: calc((var(--header-height) - 2rem) / 2);right: calc((100vw - min(100vw, var(--max-width))) / 2 + var(--space-lg)), plus+ 2rem + var(--space-sm)atβ€48remwhere the hamburger sits to its right) to overlay its in-header slot exactly β so the absolute header scrolls away beneath it and the pin never moves (no teleport). Because it left the flex flow,html[data-header-sticky="off"] .theme-togglegetsmargin-right: calc(2rem + var(--space-sm))to reserve the vacated slot so search/theme/hamburger donβt shift. The pin renders as a bare button while the header is on screen; only once the header has actually left the viewport (html[data-header-sticky="off"].is-scrolled, a classmain.jstoggles on every page fromscrollY) does a slightly-faded chip (surface bg + 1px border + soft shadow,opacity: 0.85, full on hover/focus) fade in β only background/border/box-shadow/opacity transition, neverposition, which is what keeps both scroll-away and pinβunpin toggling smooth. (Earlier the toggle was in-flow until.is-scrolledthen snapped to a0.6rem/0.6remcorner floater β aposition-type change that teleported instead of transitioning; the fixed-from-the-start overlay replaces it.)--header-offset(not the raw--header-height) drives the globalscroll-padding-top, the post-bodyh2position: stickytopand itsscroll-margin-top, plus the reference pagesβ.cmd-widget/.electronics-sectionscroll-margin-topand the reference-table controls + wrapper (see Reference-table scaffolding), so unpinning re-anchors sticky headings flush to the very top (no empty header-height gap above them β the gap bug) and reclaims that vertical space for the big tables. The sticky-h2 IntersectionObserver inmain.jsalso keys its threshold off--header-offset; since that offset changes on toggle androotMarginis baked in at observer creation, the toggle handler dispatches aheadersticky:changeevent on<html>that the sticky-h2 module listens for to rebuild its observer (sentinels/content-spans stay one-time; only the observer is recreated).
Deployment & CI/CD
Seven GitHub Actions workflows live in .github/workflows/. The four auto-trigger workflows are each scoped with paths / paths-ignore filters so they only fire when their inputs change; the other three are manual-trigger-capable.
| Workflow | Trigger | Scope | Deploys |
|---|---|---|---|
ci.yml |
PR β main |
Per-app jobs gated by dorny/paths-filter β only changed apps run lint/test/build. |
Nothing (validation only). |
jekyll-gh-pages.yml |
Push β main |
Skips docs, Firebase configs, Cloud Functions, and Firestore/RTDB/Storage rules. | Full site to GitHub Pages (Jekyll + PolyVote + Blog Admin + Inventory Manager). |
feature-preview.yml |
Push β test + manual workflow_dispatch (with optional ref input, defaults to test) |
Same paths-ignore as jekyll-gh-pages.yml. |
Combined GitHub Pages artifact: main rebuilt at root (Jekyll + PolyVote + Blog Admin + Inventory Manager, identical to jekyll-gh-pages.ymlβs output) plus the test branch (or dispatch ref) rebuilt Jekyll-only under /test/ via bundle exec jekyll build --baseurl /test. Preview URL: https://www.ranzlappen.com/test/ (custom domain serves the artifact root, no /<repo>/ prefix). Shares the pages concurrency group with jekyll-gh-pages.yml so the two queue, never overlap. |
firebase-deploy.yml |
Push β main (Firebase/Functions paths) + manual |
Builds Cloud Functions, then deploys. | Firestore rules + indexes, RTDB rules, Storage rules, castBlogVote, all Blog Admin callables, admin user-management callables (setUserRole, adminListUsers, adminBanUser, adminUnbanUser), and all Inventory Manager callables (inventory*). |
firebase-deploy-manual.yml |
Manual only (workflow_dispatch) |
Accepts a target input passed straight to firebase deploy --only. Default functions redeploys every function in polyvote/functions/src/index.ts β future-proof for newly added functions. Shares the firebase-deploy concurrency group with the auto-deploy. |
Whatever the target input specifies (default: all Cloud Functions). |
search-crawl.yml |
Manual only (workflow_dispatch) |
Runs the search-crawler module (Node 22, no install) with the auto-provided GITHUB_TOKEN, then opens a PR with the refreshed search-external.json via the pre-installed gh CLI (pushes a side branch + gh pr create; main is protected, so it never pushes there directly β and no third-party action to download). Merging the PR triggers jekyll-gh-pages.yml and redeploys with the new index. contents: write + pull-requests: write; search-crawl concurrency group. One-time setup: enable Settings β Actions β βAllow GitHub Actions to create and approve pull requestsβ. The bot PR is GITHUB_TOKEN-authored, so CI does not auto-run on it (a data-only change β just merge it). |
Opens a PR bumping search-external.json (no deploy itself). |
Preview limitations: feature-preview.yml ships Jekyll-only under /test/. PolyVote, Blog Admin, and Inventory Manager are not rebuilt at the preview subpath because their Vite base and React Router basename are hardcoded to /polyvote/, /blog-admin/, and /inventory/. Navbar links to those apps will 404 inside the preview tree. To enable SPA previews later, make base env-driven in polyvote/vite.config.ts, blog-admin/vite.config.ts, inventory-manager/vite.config.ts, the three main.tsx files, PolyVoteβs ShareButton.tsx, and PolyVoteβs PWA manifest (defaults preserve current paths exactly).
What fires on a given change:
| Change | CI (on PR to main) |
Pages prod (push β main) |
Pages preview (push β test) |
Firebase (push β main) |
|---|---|---|---|---|
| Blog post / Jekyll page | β | β | β | β |
polyvote/src/** |
polyvote | β | β (rebuilt from main only) | β |
blog-admin/src/** |
blog-admin | β | β (rebuilt from main only) | β |
inventory-manager/src/** |
inventory-manager | β | β (rebuilt from main only) | β |
polyvote/functions/** |
functions | β | β | β |
firestore.rules / firestore.indexes.json |
β | β | β | β |
database.rules.json / storage.rules |
β | β | β | β |
search-crawler/** (crawler code) |
search-crawler | β | β | β |
search-external.json (crawl output) |
β | β | β | β |
CLAUDE.md / README.md / LICENSE |
β | β | β | β |
Concurrency: CI cancels superseded runs per PR branch. Pages deploys (both jekyll-gh-pages.yml and feature-preview.yml) and Firebase deploys queue (no cancel) to avoid half-applied state.
Node version: All JS jobs (CI + Pages build) run on Node 22.
Action pinning: Third-party Actions (anything outside the GitHub-maintained actions/* org β currently dorny/paths-filter in ci.yml and dependabot/fetch-metadata in dependabot-auto-merge.yml) are pinned to a full commit SHA with a trailing # vX.Y.Z comment, not a mutable tag. Dependabotβs github-actions ecosystem bumps the SHA and the comment, so pins stay current. (SHA-pinning is a supply-chain safeguard against a moved/compromised tag; it does not prevent transient codeload.github.com download outages β those are cleared by re-running the job. actions/* and the ruby/setup-ruby@v1 moving branch stay on tags.)
Required secrets:
FIREBASE_SERVICE_ACCOUNT(JSON service-account key) forfirebase-deploy.yml.GOOGLE_DRIVE_API_KEY(Firebase Functions secret, set viafirebase functions:secrets:set) for the inventoryinventoryListDriveFoldercallable. Restrict the key to the Drive API in the GCP console. This secret MUST exist in the project for ANY functions deploy to succeed βfirebase deployanalyzes the entire functions codebase and validates everydefineSecret-declared secret up front, even functions excluded from the--only TARGETS; in non-interactive (CI) mode an unset secret aborts the whole deploy (rules included) before anything is applied (this brokefirebase-deploy.ymlonce β the fix is to set the secret, a placeholder value is enough to pass analysis).inventoryListDriveFolderis still excluded fromfirebase-deploy.ymlβs auto-deployTARGETS(so the auto-deploy never tries to bind/redeploy it); deploy it manually (firebase deploy --only functions:inventoryListDriveFolder) or viafirebase-deploy-manual.ymlonce a real key is stored. The Drive folder picker is unavailable until a real key is set, but the rest of inventory deploys fine as long as the secret exists.
Dependabot (.github/dependabot.yml): weekly updates for all four npm packages (polyvote, polyvote/functions, blog-admin, inventory-manager), bundler, and GitHub Actions. Minor+patch are grouped. Each PR runs CI β ci.yml triggers on every PR to main (no paths: filter) so the ci-required aggregator always appears as a status check.
Auto-merge (.github/workflows/dependabot-auto-merge.yml): every Dependabot PR is queued for GitHub native auto-merge (gh pr merge --auto --merge) and lands once required status checks pass. Requires branch protection on main to mark ci-required as a required status check β without that, --auto merges immediately without waiting and a failing CI wonβt block the merge (this happened with PR #236, a firebase-admin v12βv13 major bump whose functions job failed but landed anyway because no required check was configured). ci-required is a single aggregator job in ci.yml that succeeds only when every conditional app job (polyvote, functions, blog-admin, inventory-manager) either passed or was skipped via path filter; mark it required and the conditional-job deadlock problem disappears.
Manual fallbacks:
- Trigger
firebase-deploy-manual.ymlviaworkflow_dispatch(preferred) β deploys via GitHub Actions using the shared service-account secret. Default target isfunctions(all Cloud Functions); override with any--onlytarget, e.g.functions:blogSaveDraft,functions:blogPublishToGitHuborfunctions,database,firestore. - Or, from
polyvote/authenticated viafirebase login:firebase deploy --only firestoreβ rules + indexesfirebase deploy --only functions:<name>β a specific function
- Re-run Pages: trigger
jekyll-gh-pages.ymlviaworkflow_dispatch
Tech Stack
| Layer | Blog | PolyVote | Blog Admin | Inventory Manager |
|---|---|---|---|---|
| Framework | Jekyll (Ruby) | React 19 + TypeScript | React 19 + TypeScript | React 19 + TypeScript |
| Styling | Custom CSS β main style.css (~3,200 lines), per-page stylesheets (spectrum.css, electronics-fundamentals.css, cmd-cheat-sheet.css), the shared abbreviations.css, and the shared reference-table.css (sticky-tab + live-search big-table scaffolding used by Spectrum and the CLI cheat sheet) |
Tailwind CSS v3 + Framer Motion | Tailwind CSS v4 (via @tailwindcss/vite) |
Tailwind CSS v4 (via @tailwindcss/vite) |
| Router | β | react-router-dom v6 | react-router-dom v7 | react-router-dom v7 |
| State | Vanilla JS | Zustand | Zustand | Zustand |
| Backend | GitHub Pages (static) | Firebase (Firestore, Auth, Functions) | Firebase (Firestore, Auth) | Firebase (Firestore, Auth, Storage) |
| Comments | Giscus (GitHub Discussions) | Firebase subcollections | β | β |
| Editor | β | β | CodeMirror 6 | β |
| Deployment | jekyll-gh-pages.yml β GitHub Pages |
Built by jekyll-gh-pages.yml into _site/polyvote/ |
Built by jekyll-gh-pages.yml into _site/blog-admin/ |
Built by jekyll-gh-pages.yml into _site/inventory/ |
Project Structure
βββ _config.yml # Jekyll configuration
βββ _data/
β βββ pages.yml # Navigation registry (nav + footer)
β βββ projects.yml # External app + reference-page favicons (footer strip)
β βββ abbreviations/ # Per-page glossary datasets (shared utility)
β β βββ cmd-cheat-sheet.yml
β β βββ electronics.yml
β β βββ spectrum.yml
β βββ cmd-cheat-sheet/ # CLI cheat sheet command data + maintenance README
β βββ spectrum/ # EM spectrum band data + maintenance README
β βββ references/electronics/ # Architecture / maintenance README for the EF page
βββ _includes/ # Jekyll partials (head, header, footerβ¦)
β βββ abbreviations-section.html # Shared glossary partial β see Key Conventions
βββ _layouts/ # Page templates (default, home, post, page)
βββ _posts/ # Blog content (Markdown)
βββ assets/
β βββ css/
β β βββ style.css # Main blog stylesheet
β β βββ abbreviations.css # Shared glossary styling
β β βββ reference-table.css # Shared big-table scaffolding (Spectrum + cmd cheat sheet)
β β βββ references-index.css # /references/ landing-page cards
β β βββ spectrum.css # Spectrum reference page (overrides)
β β βββ electronics-fundamentals.css # Electronics reference page
β β βββ cmd-cheat-sheet.css # CLI cheat sheet reference page (overrides)
β β βββ cmd-widgets.css # CLI cheat sheet Interactive Tools widgets
β β βββ cookie-consent.css
β βββ js/
β β βββ abbreviations.js # Shared glossary modal + decoration
β β βββ reference-table.js # Shared tab/search/empty/blurb wiring for big tables
β β βββ spectrum.js # Spectrum: thin wrapper over reference-table.js
β β βββ cmd-cheat-sheet.js # CLI cheat sheet: reference-table wrapper + worked-example modal wiring
β β βββ cmd-widget-*.js # CLI cheat sheet Interactive Tools (chmod/find/regex/curl + core + bundle)
β β βββ resize-handle.js # Shared custom textarea resize grabber (reference pages)
β β βββ electronics-*.js # 9-file EF widget bundle
β βββ images/favicons/ # Local copies of external app + reference page favicons
βββ pages/ # Static pages (about, contact, privacy, references/*β¦)
βββ feed.xml # Atom feed (custom, status-filtered)
βββ sitemap.xml # Sitemap (custom, status-filtered)
βββ search.json # Local search index (posts + pages + references), Liquid-generated, group-tagged
βββ search-external.json # External search index (subdomains/gh-pages/repos/gists), crawler-generated snapshot
βββ .github/
β βββ dependabot.yml # Weekly dependency updates
β βββ workflows/
β βββ ci.yml # PR validation (per-app jobs)
β βββ dependabot-auto-merge.yml # Queue Dependabot PRs for GitHub native auto-merge
β βββ feature-preview.yml # Build + deploy main + `test` preview to Pages
β βββ firebase-deploy.yml # Deploy Firestore/RTDB rules + castBlogVote + Blog Admin callables
β βββ firebase-deploy-manual.yml # Manual Cloud Functions deploys (workflow_dispatch)
β βββ jekyll-gh-pages.yml # Build + deploy prod site to Pages
β βββ search-crawl.yml # Manual re-crawl of search-external.json (workflow_dispatch)
βββ search-crawler/ # Dependency-free Node crawler β search-external.json (see README)
β βββ crawl.mjs # Orchestrator entry point
β βββ sources.config.js # Declarative seeds (web hosts + groups, github user, caps)
β βββ src/ # util / extract / web / github fetchers
βββ blog-admin/
β βββ src/
β β βββ components/ # Editor UI, auth, dialogs
β β βββ pages/ # Dashboard, Editor, Login
β β βββ firebase.ts # Firebase client config
β β βββ store.ts # Zustand store
β β βββ types.ts # TypeScript interfaces
β βββ eslint.config.js # ESLint flat config
βββ inventory-manager/
β βββ src/
β β βββ components/ # AdminGuard, Toast, Header, FieldInput, PhotoGrid, ImportDialog, ConfirmDialog
β β βββ pages/ # Dashboard, FolderTable, SchemaEditor, ItemEditor, EbayExport, Login
β β βββ firebase.ts # Firebase client config + httpsCallable wrappers
β β βββ store.ts # Zustand store (auth, folders, items, selection, toasts)
β β βββ types.ts # TypeScript interfaces (mirrors functions/src/inventory/shared.ts)
β β βββ ebay.ts # eBay condition IDs, durations, formats
β β βββ index.css # Tailwind import + theme variables
β βββ eslint.config.js # ESLint flat config
β βββ vite.config.ts # base: '/inventory/'
βββ polyvote/
βββ src/
β βββ components/ # React components
β βββ pages/ # Route-level pages + admin/
β βββ hooks/ # Zustand store, Firestore hooks
β βββ types/ # TypeScript interfaces
β βββ __tests__/ # Vitest tests
βββ functions/src/ # Firebase Cloud Functions (TypeScript)
β βββ inventory/ # Inventory Manager callables (folders, items, photos, import/export, eBay CSV)
βββ firestore.rules # Firestore security rules
βββ firestore.indexes.json # Firestore composite indexes
βββ database.rules.json # Realtime Database rules (vote aggregates)
βββ storage.rules # Firebase Storage rules (inventory photos, public-read)
βββ firebase.json # Firebase project config
βββ .firebaserc # Firebase project ID
βββ eslint.config.js # ESLint flat config
Post-task self-check
After every turn that produces a branch, PR, feature, or bug fix, do a quick self-check before replying: does the change introduce anything worth codifying in docs or automation? Scan for new env vars, npm scripts, path filters, deploy targets, secrets, setup steps, dependencies, or conventions that should be reflected in README.md, CLAUDE.md, .github/workflows/*.yml, or .github/dependabot.yml.
Decide per case:
- Auto-implement small, unambiguous updates β e.g. noting a newly introduced env var in README, extending a workflow
pathsfilter to a new directory, adding a new npm script to the relevant command list, bumping a Node version already changed in one workflow to match the others. Make the edit in the same turn and call it out in the summary. - Prompt first for anything ambiguous, opinionated, or structurally significant β rewriting a README section, adding a new top-level doc, restructuring a workflow, or changes whose wording/location isnβt obvious.
If nothing is warranted, say βno doc/workflow updates neededβ in one line. Skip this self-check entirely for pure Q&A turns that donβt change code.