From 2d953c53fcce497dc4b54c146a424fa409b1218c Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Mon, 9 Feb 2026 09:27:41 -0600 Subject: [PATCH 1/8] init --- README.md | 49 +++--- package-lock.json | 111 ++++++++++++- package.json | 6 +- src/index.ts | 403 ++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 526 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 2fd1f03..83383b8 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,30 @@ -# RoamJS Extension Base +# Breadcrumbs -Stock base for [RoamJS](https://roamjs.com) Roam Research extensions. **Fork this repo** to start a new extension. +RoamJS extension that shows clickable navigation breadcrumbs in the Roam top bar. -## What's included +## Features -- **roamjs-components** — shared utilities, DOM helpers, queries, writes, and UI components -- **Samepage build** — `samepage build` produces the Roam Depot–ready bundle -- **Settings panel** — example `extensionAPI.settings.panel.create` with an Enable switch -- **TypeScript** — tsconfig extending `@samepage/scripts` -- **CI** — GitHub Actions to build on push/PR (uses RoamJS secrets for publish) +- Tracks recently visited pages and block locations +- Renders oldest-to-current breadcrumb trail in the top bar +- Click any non-current breadcrumb to navigate back +- Distinguishes pages vs blocks with different styles +- Supports Blueprint dark mode (`.bp3-dark`) -## After forking +## Settings -1. **Rename the repo** and update `package.json`: - - `name`: your extension slug (e.g. `my-extension`) - - `description`: one line describing the extension +- `Enable breadcrumbs`: turn the extension on/off +- `Max breadcrumbs`: max number of prior locations to keep +- `Truncate length`: max label length before truncation -2. **Implement in `src/index.ts`**: - - Keep or replace the settings panel - - Add your logic using `roamjs-components` (e.g. `createHTMLObserver`, `createBlock`, `renderToast`) - - Return `{ unload }` to clean up on unload +## Development -3. **Optional**: Add React components under `src/components/` (see [autocomplete](https://github.com/RoamJS/autocomplete), [giphy](https://github.com/RoamJS/giphy) for examples). +```bash +npm install +npm start +``` -4. **Secrets (for publish)** — in the forked repo, configure: - - `ROAMJS_RELEASE_TOKEN`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - - `AWS_REGION`, `ROAMJS_PROXY` (vars) +Build dry-run for Roam: -## Scripts - -- `npm start` — samepage dev (local development) -- `npm run build:roam` — build for Roam (dry run; CI runs `npx samepage build`) - -## License - -MIT +```bash +npm run build:roam +``` diff --git a/package-lock.json b/package-lock.json index 5f72716..8e8fca6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,20 @@ { - "name": "extension-base", + "name": "breadcrumbs", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "extension-base", + "name": "breadcrumbs", "version": "1.0.0", "hasInstallScript": true, "license": "MIT", "dependencies": { "roamjs-components": "^0.86.4" + }, + "devDependencies": { + "prettier": "^3.2.5", + "prettier-plugin-tailwindcss": "^0.6.11" } }, "node_modules/@alloc/quick-lru": { @@ -6991,6 +6995,109 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.14", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", + "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", diff --git a/package.json b/package.json index 3ee5f29..68e064e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "extension-base", + "name": "breadcrumbs", "version": "1.0.0", - "description": "Stock RoamJS Roam Research Extension base — fork this repo to start new extensions.", + "description": "Navigation breadcrumbs for Roam Research.", "main": "index.js", "scripts": { "postinstall": "patch-package", @@ -10,7 +10,7 @@ "build:roam": "samepage build --dry", "test": "samepage test" }, - "keywords": ["roam", "roam-research", "roamjs"], + "keywords": ["roam", "roam-research", "roamjs", "breadcrumbs", "navigation"], "license": "MIT", "dependencies": { "roamjs-components": "^0.86.4" diff --git a/src/index.ts b/src/index.ts index 6ffa421..784c089 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,27 +1,410 @@ import runExtension from "roamjs-components/util/runExtension"; +type LocationType = "page" | "block"; + +type Location = { + type: LocationType; + uid: string; +}; + +type BreadcrumbItem = { + uid: string; + type: LocationType; + title: string; +}; + +type ExtensionSettings = { + maxBreadcrumbs: number; + truncateLength: number; +}; + +type ExtensionAPI = { + settings: { + get: (key: string) => unknown; + panel: { + create: (args: { + tabTitle: string; + settings: { + id: string; + name: string; + description: string; + action: { + type: string; + placeholder?: string; + }; + }[]; + }) => void; + }; + }; +}; + +const SETTINGS_DEFAULTS: ExtensionSettings = { + maxBreadcrumbs: 8, + truncateLength: 25, +}; + +const PANEL_ID = "roam-breadcrumbs-panel"; +const STYLE_ID = "roam-breadcrumbs-styles"; + +let history: BreadcrumbItem[] = []; +let currentLocation: Location | null = null; +let hashChangeListener: (() => void) | null = null; + +const toPositiveNumber = ({ + value, + fallback, +}: { + value: unknown; + fallback: number; +}): number => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; +}; + +const getSettings = ({ + extensionAPI, +}: { + extensionAPI: ExtensionAPI; +}): ExtensionSettings => ({ + maxBreadcrumbs: toPositiveNumber({ + value: extensionAPI.settings.get("maxBreadcrumbs"), + fallback: SETTINGS_DEFAULTS.maxBreadcrumbs, + }), + truncateLength: toPositiveNumber({ + value: extensionAPI.settings.get("truncateLength"), + fallback: SETTINGS_DEFAULTS.truncateLength, + }), +}); + +const getLocationFromHash = (): Location | null => { + const hash = window.location.hash; + + const blockMatch = hash.match(/\/page\/[^?]+\?.*block=([a-zA-Z0-9_-]+)/); + if (blockMatch?.[1]) { + return { type: "block", uid: blockMatch[1] }; + } + + const pageMatch = hash.match(/\/page\/([a-zA-Z0-9_-]+)/); + if (pageMatch?.[1]) { + return { type: "page", uid: pageMatch[1] }; + } + + return null; +}; + +const getBlockOrPageInfo = async ({ uid }: { uid: string }): Promise => { + const pageResult = await window.roamAlphaAPI.q<[[string]]>(` + [:find ?title + :where [?e :block/uid "${uid}"] + [?e :node/title ?title]] + `); + + if (pageResult?.[0]?.[0]) { + return { + uid, + type: "page", + title: pageResult[0][0], + }; + } + + const blockResult = await window.roamAlphaAPI.q<[[string]]>(` + [:find ?string + :where [?e :block/uid "${uid}"] + [?e :block/string ?string]] + `); + + if (blockResult?.length) { + return { + uid, + type: "block", + title: blockResult[0][0] || "(empty block)", + }; + } + + return null; +}; + +const stripMarkdown = ({ text }: { text: string }): string => + text + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/__([^_]+)__/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/_([^_]+)_/g, "$1") + .replace(/~~([^~]+)~~/g, "$1") + .replace(/\[\[([^\]]+)\]\]/g, "$1") + .replace(/\(\(([^)]+)\)\)/g, "->") + .replace(/```[^`]*```/g, "[code]") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[.*?\]\(.*?\)/g, "[img]") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/{{.*?}}/g, "") + .replace(/#\[\[([^\]]+)\]\]/g, "#$1") + .trim(); + +const truncateText = ({ text, maxLength }: { text: string; maxLength: number }): string => { + const cleaned = stripMarkdown({ text }); + if (cleaned.length <= maxLength) return cleaned; + return `${cleaned.substring(0, maxLength - 1)}...`; +}; + +const navigateTo = ({ uid, type }: { uid: string; type: LocationType }): void => { + if (type === "page") { + window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } }); + return; + } + + window.roamAlphaAPI.ui.mainWindow.openBlock({ block: { uid } }); +}; + +const createSeparator = (): HTMLSpanElement => { + const separator = document.createElement("span"); + separator.className = "breadcrumb-separator"; + separator.textContent = ">"; + return separator; +}; + +const createBreadcrumbElement = ({ + item, + isCurrent, + truncateLength, +}: { + item: BreadcrumbItem; + isCurrent: boolean; + truncateLength: number; +}): HTMLSpanElement => { + const element = document.createElement("span"); + element.className = `breadcrumb-item ${ + item.type === "page" ? "breadcrumb-page" : "breadcrumb-block" + } ${isCurrent ? "breadcrumb-current" : ""}`; + element.textContent = truncateText({ text: item.title, maxLength: truncateLength }); + element.title = stripMarkdown({ text: item.title }); + + if (!isCurrent) { + element.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + navigateTo({ uid: item.uid, type: item.type }); + }); + } + + return element; +}; + +const renderBreadcrumbs = ({ truncateLength }: { truncateLength: number }): void => { + const container = document.getElementById(PANEL_ID); + if (!container) return; + + const content = container.querySelector(".breadcrumbs-content"); + if (!(content instanceof HTMLElement)) return; + + content.innerHTML = ""; + + if (!history.length) { + content.textContent = "No history yet"; + return; + } + + const displayOrder = [...history].reverse(); + + displayOrder.forEach((item, index) => { + const isCurrent = index === displayOrder.length - 1; + + if (index > 0) { + content.appendChild(createSeparator()); + } + + content.appendChild( + createBreadcrumbElement({ item, isCurrent, truncateLength }) + ); + }); +}; + +const handleNavigation = async ({ + maxBreadcrumbs, + truncateLength, +}: ExtensionSettings): Promise => { + const location = getLocationFromHash(); + if (!location) return; + + if (currentLocation?.uid === location.uid) return; + + const info = await getBlockOrPageInfo({ uid: location.uid }); + if (!info) return; + + history = history.filter((item) => item.uid !== info.uid); + history.unshift(info); + + if (history.length > maxBreadcrumbs + 1) { + history = history.slice(0, maxBreadcrumbs + 1); + } + + currentLocation = location; + renderBreadcrumbs({ truncateLength }); +}; + +const createBreadcrumbsPanel = (): void => { + document.getElementById(PANEL_ID)?.remove(); + + const panel = document.createElement("div"); + panel.id = PANEL_ID; + panel.innerHTML = ``; + + const topbar = document.querySelector(".rm-topbar"); + if (!(topbar instanceof HTMLElement)) return; + + const firstChild = topbar.firstChild; + if (firstChild?.nextSibling) { + topbar.insertBefore(panel, firstChild.nextSibling); + return; + } + + topbar.appendChild(panel); +}; + +const injectStyles = (): void => { + if (document.getElementById(STYLE_ID)) return; + + const styles = document.createElement("style"); + styles.id = STYLE_ID; + styles.textContent = ` + #${PANEL_ID} { + display: flex; + align-items: center; + padding: 0 12px; + flex-grow: 1; + min-width: 0; + overflow: hidden; + } + + .breadcrumbs-content { + display: flex; + align-items: center; + gap: 4px; + overflow: hidden; + white-space: nowrap; + font-size: 13px; + } + + .breadcrumb-item { + padding: 3px 8px; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.15s ease; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; + } + + .breadcrumb-item:hover:not(.breadcrumb-current) { + background-color: rgba(0, 0, 0, 0.08); + } + + .breadcrumb-page { + color: #137cbd; + font-weight: 500; + } + + .breadcrumb-block { + color: #5c7080; + font-style: italic; + } + + .breadcrumb-block::before { + content: "*"; + margin-right: 4px; + opacity: 0.5; + } + + .breadcrumb-current { + background-color: rgba(19, 124, 189, 0.1); + cursor: default; + } + + .breadcrumb-separator { + color: #8a9ba8; + margin: 0 2px; + user-select: none; + } + + .bp3-dark #${PANEL_ID} .breadcrumb-item:hover:not(.breadcrumb-current) { + background-color: rgba(255, 255, 255, 0.1); + } + + .bp3-dark #${PANEL_ID} .breadcrumb-page { + color: #48aff0; + } + + .bp3-dark #${PANEL_ID} .breadcrumb-block { + color: #a7b6c2; + } + + .bp3-dark #${PANEL_ID} .breadcrumb-current { + background-color: rgba(72, 175, 240, 0.15); + } + + .bp3-dark #${PANEL_ID} .breadcrumb-separator { + color: #5c7080; + } + `; + + document.head.appendChild(styles); +}; + +const cleanup = (): void => { + if (hashChangeListener) { + window.removeEventListener("hashchange", hashChangeListener); + hashChangeListener = null; + } + + document.getElementById(PANEL_ID)?.remove(); + document.getElementById(STYLE_ID)?.remove(); + + history = []; + currentLocation = null; +}; + export default runExtension(async ({ extensionAPI }) => { extensionAPI.settings.panel.create({ - tabTitle: "Extension", + tabTitle: "Breadcrumbs", settings: [ { id: "enabled", - name: "Enable", - description: "Turn the extension on or off", + name: "Enable breadcrumbs", + description: "Show navigation breadcrumbs in the Roam top bar", action: { type: "switch" }, }, + { + id: "maxBreadcrumbs", + name: "Max breadcrumbs", + description: "Maximum number of previous locations to keep", + action: { type: "input", placeholder: `${SETTINGS_DEFAULTS.maxBreadcrumbs}` }, + }, + { + id: "truncateLength", + name: "Truncate length", + description: "Maximum breadcrumb label length before truncation", + action: { type: "input", placeholder: `${SETTINGS_DEFAULTS.truncateLength}` }, + }, ], }); - const enabled = extensionAPI.settings.get("enabled") as boolean | undefined; - if (enabled === false) return; + if ((extensionAPI.settings.get("enabled") as boolean | undefined) === false) { + return; + } + + const settings = getSettings({ extensionAPI: extensionAPI as ExtensionAPI }); + + injectStyles(); + createBreadcrumbsPanel(); + + hashChangeListener = () => { + void handleNavigation(settings); + }; + window.addEventListener("hashchange", hashChangeListener); - // Add your extension logic here. - // Use roamjs-components: dom/*, queries/*, writes/*, util/*, components/* + await handleNavigation(settings); return { - unload: () => { - // Clean up observers, listeners, command palette, etc. - }, + unload: cleanup, }; }); From f2af0a13fca6b5ed88a34659c5cce662f41b69b6 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Mon, 9 Feb 2026 09:51:05 -0600 Subject: [PATCH 2/8] refactor: improve breadcrumbs structure/readability --- src/index.ts | 230 +++++++++++++++++++++++++++++---------------------- 1 file changed, 131 insertions(+), 99 deletions(-) diff --git a/src/index.ts b/src/index.ts index 784c089..0a3281b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,39 +18,45 @@ type ExtensionSettings = { truncateLength: number; }; +type SettingsPanelField = { + id: string; + name: string; + description: string; + action: { + type: string; + placeholder?: string; + }; +}; + type ExtensionAPI = { settings: { get: (key: string) => unknown; panel: { - create: (args: { - tabTitle: string; - settings: { - id: string; - name: string; - description: string; - action: { - type: string; - placeholder?: string; - }; - }[]; - }) => void; + create: (args: { tabTitle: string; settings: SettingsPanelField[] }) => void; }; }; }; -const SETTINGS_DEFAULTS: ExtensionSettings = { +const DEFAULT_SETTINGS: ExtensionSettings = { maxBreadcrumbs: 8, truncateLength: 25, }; -const PANEL_ID = "roam-breadcrumbs-panel"; -const STYLE_ID = "roam-breadcrumbs-styles"; +const UI_IDS = { + panel: "roam-breadcrumbs-panel", + styles: "roam-breadcrumbs-styles", +} as const; + +const UI_SELECTORS = { + topbar: ".rm-topbar", + content: ".breadcrumbs-content", +} as const; -let history: BreadcrumbItem[] = []; +let breadcrumbHistory: BreadcrumbItem[] = []; let currentLocation: Location | null = null; let hashChangeListener: (() => void) | null = null; -const toPositiveNumber = ({ +const parsePositiveInteger = ({ value, fallback, }: { @@ -61,18 +67,14 @@ const toPositiveNumber = ({ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; }; -const getSettings = ({ - extensionAPI, -}: { - extensionAPI: ExtensionAPI; -}): ExtensionSettings => ({ - maxBreadcrumbs: toPositiveNumber({ +const readSettings = ({ extensionAPI }: { extensionAPI: ExtensionAPI }): ExtensionSettings => ({ + maxBreadcrumbs: parsePositiveInteger({ value: extensionAPI.settings.get("maxBreadcrumbs"), - fallback: SETTINGS_DEFAULTS.maxBreadcrumbs, + fallback: DEFAULT_SETTINGS.maxBreadcrumbs, }), - truncateLength: toPositiveNumber({ + truncateLength: parsePositiveInteger({ value: extensionAPI.settings.get("truncateLength"), - fallback: SETTINGS_DEFAULTS.truncateLength, + fallback: DEFAULT_SETTINGS.truncateLength, }), }); @@ -92,36 +94,45 @@ const getLocationFromHash = (): Location | null => { return null; }; -const getBlockOrPageInfo = async ({ uid }: { uid: string }): Promise => { +const queryPageTitle = async ({ uid }: { uid: string }): Promise => { const pageResult = await window.roamAlphaAPI.q<[[string]]>(` [:find ?title :where [?e :block/uid "${uid}"] [?e :node/title ?title]] `); - if (pageResult?.[0]?.[0]) { - return { - uid, - type: "page", - title: pageResult[0][0], - }; - } + return pageResult?.[0]?.[0] || null; +}; +const queryBlockString = async ({ uid }: { uid: string }): Promise => { const blockResult = await window.roamAlphaAPI.q<[[string]]>(` [:find ?string :where [?e :block/uid "${uid}"] [?e :block/string ?string]] `); - if (blockResult?.length) { + if (!blockResult?.length) return null; + return blockResult[0][0] || "(empty block)"; +}; + +const getBreadcrumbItemByUid = async ({ uid }: { uid: string }): Promise => { + const pageTitle = await queryPageTitle({ uid }); + if (pageTitle) { return { uid, - type: "block", - title: blockResult[0][0] || "(empty block)", + type: "page", + title: pageTitle, }; } - return null; + const blockString = await queryBlockString({ uid }); + if (!blockString) return null; + + return { + uid, + type: "block", + title: blockString, + }; }; const stripMarkdown = ({ text }: { text: string }): string => @@ -144,7 +155,7 @@ const stripMarkdown = ({ text }: { text: string }): string => const truncateText = ({ text, maxLength }: { text: string; maxLength: number }): string => { const cleaned = stripMarkdown({ text }); if (cleaned.length <= maxLength) return cleaned; - return `${cleaned.substring(0, maxLength - 1)}...`; + return `${cleaned.slice(0, maxLength - 1)}...`; }; const navigateTo = ({ uid, type }: { uid: string; type: LocationType }): void => { @@ -156,7 +167,7 @@ const navigateTo = ({ uid, type }: { uid: string; type: LocationType }): void => window.roamAlphaAPI.ui.mainWindow.openBlock({ block: { uid } }); }; -const createSeparator = (): HTMLSpanElement => { +const createSeparatorElement = (): HTMLSpanElement => { const separator = document.createElement("span"); separator.className = "breadcrumb-separator"; separator.textContent = ">"; @@ -173,9 +184,9 @@ const createBreadcrumbElement = ({ truncateLength: number; }): HTMLSpanElement => { const element = document.createElement("span"); - element.className = `breadcrumb-item ${ - item.type === "page" ? "breadcrumb-page" : "breadcrumb-block" - } ${isCurrent ? "breadcrumb-current" : ""}`; + const typeClass = item.type === "page" ? "breadcrumb-page" : "breadcrumb-block"; + + element.className = `breadcrumb-item ${typeClass} ${isCurrent ? "breadcrumb-current" : ""}`.trim(); element.textContent = truncateText({ text: item.title, maxLength: truncateLength }); element.title = stripMarkdown({ text: item.title }); @@ -190,35 +201,78 @@ const createBreadcrumbElement = ({ return element; }; -const renderBreadcrumbs = ({ truncateLength }: { truncateLength: number }): void => { - const container = document.getElementById(PANEL_ID); - if (!container) return; +const getOrCreatePanel = (): HTMLElement | null => { + const existingPanel = document.getElementById(UI_IDS.panel); + if (existingPanel instanceof HTMLElement) return existingPanel; + + const topbar = document.querySelector(UI_SELECTORS.topbar); + if (!(topbar instanceof HTMLElement)) return null; + + const panel = document.createElement("div"); + panel.id = UI_IDS.panel; + + const content = document.createElement("div"); + content.className = "breadcrumbs-content"; + panel.appendChild(content); + + const firstChild = topbar.firstChild; + if (firstChild?.nextSibling) { + topbar.insertBefore(panel, firstChild.nextSibling); + return panel; + } + + topbar.appendChild(panel); + return panel; +}; - const content = container.querySelector(".breadcrumbs-content"); - if (!(content instanceof HTMLElement)) return; +const getContentContainer = (): HTMLElement | null => { + const panel = getOrCreatePanel(); + if (!panel) return null; + + const content = panel.querySelector(UI_SELECTORS.content); + return content instanceof HTMLElement ? content : null; +}; + +const renderBreadcrumbs = ({ truncateLength }: { truncateLength: number }): void => { + const content = getContentContainer(); + if (!content) return; content.innerHTML = ""; - if (!history.length) { + if (!breadcrumbHistory.length) { content.textContent = "No history yet"; return; } - const displayOrder = [...history].reverse(); + const displayOrder = [...breadcrumbHistory].reverse(); displayOrder.forEach((item, index) => { const isCurrent = index === displayOrder.length - 1; if (index > 0) { - content.appendChild(createSeparator()); + content.appendChild(createSeparatorElement()); } - content.appendChild( - createBreadcrumbElement({ item, isCurrent, truncateLength }) - ); + content.appendChild(createBreadcrumbElement({ item, isCurrent, truncateLength })); }); }; +const updateBreadcrumbHistory = ({ + item, + maxBreadcrumbs, +}: { + item: BreadcrumbItem; + maxBreadcrumbs: number; +}): void => { + breadcrumbHistory = breadcrumbHistory.filter((historyItem) => historyItem.uid !== item.uid); + breadcrumbHistory.unshift(item); + + const maxEntries = maxBreadcrumbs + 1; + if (breadcrumbHistory.length > maxEntries) { + breadcrumbHistory = breadcrumbHistory.slice(0, maxEntries); + } +}; + const handleNavigation = async ({ maxBreadcrumbs, truncateLength, @@ -228,46 +282,22 @@ const handleNavigation = async ({ if (currentLocation?.uid === location.uid) return; - const info = await getBlockOrPageInfo({ uid: location.uid }); - if (!info) return; - - history = history.filter((item) => item.uid !== info.uid); - history.unshift(info); - - if (history.length > maxBreadcrumbs + 1) { - history = history.slice(0, maxBreadcrumbs + 1); - } + const breadcrumbItem = await getBreadcrumbItemByUid({ uid: location.uid }); + if (!breadcrumbItem) return; + updateBreadcrumbHistory({ item: breadcrumbItem, maxBreadcrumbs }); currentLocation = location; - renderBreadcrumbs({ truncateLength }); -}; - -const createBreadcrumbsPanel = (): void => { - document.getElementById(PANEL_ID)?.remove(); - - const panel = document.createElement("div"); - panel.id = PANEL_ID; - panel.innerHTML = ``; - - const topbar = document.querySelector(".rm-topbar"); - if (!(topbar instanceof HTMLElement)) return; - const firstChild = topbar.firstChild; - if (firstChild?.nextSibling) { - topbar.insertBefore(panel, firstChild.nextSibling); - return; - } - - topbar.appendChild(panel); + renderBreadcrumbs({ truncateLength }); }; const injectStyles = (): void => { - if (document.getElementById(STYLE_ID)) return; + if (document.getElementById(UI_IDS.styles)) return; const styles = document.createElement("style"); - styles.id = STYLE_ID; + styles.id = UI_IDS.styles; styles.textContent = ` - #${PANEL_ID} { + #${UI_IDS.panel} { display: flex; align-items: center; padding: 0 12px; @@ -326,23 +356,23 @@ const injectStyles = (): void => { user-select: none; } - .bp3-dark #${PANEL_ID} .breadcrumb-item:hover:not(.breadcrumb-current) { + .bp3-dark #${UI_IDS.panel} .breadcrumb-item:hover:not(.breadcrumb-current) { background-color: rgba(255, 255, 255, 0.1); } - .bp3-dark #${PANEL_ID} .breadcrumb-page { + .bp3-dark #${UI_IDS.panel} .breadcrumb-page { color: #48aff0; } - .bp3-dark #${PANEL_ID} .breadcrumb-block { + .bp3-dark #${UI_IDS.panel} .breadcrumb-block { color: #a7b6c2; } - .bp3-dark #${PANEL_ID} .breadcrumb-current { + .bp3-dark #${UI_IDS.panel} .breadcrumb-current { background-color: rgba(72, 175, 240, 0.15); } - .bp3-dark #${PANEL_ID} .breadcrumb-separator { + .bp3-dark #${UI_IDS.panel} .breadcrumb-separator { color: #5c7080; } `; @@ -356,15 +386,17 @@ const cleanup = (): void => { hashChangeListener = null; } - document.getElementById(PANEL_ID)?.remove(); - document.getElementById(STYLE_ID)?.remove(); + document.getElementById(UI_IDS.panel)?.remove(); + document.getElementById(UI_IDS.styles)?.remove(); - history = []; + breadcrumbHistory = []; currentLocation = null; }; export default runExtension(async ({ extensionAPI }) => { - extensionAPI.settings.panel.create({ + const typedExtensionAPI = extensionAPI as ExtensionAPI; + + typedExtensionAPI.settings.panel.create({ tabTitle: "Breadcrumbs", settings: [ { @@ -377,25 +409,25 @@ export default runExtension(async ({ extensionAPI }) => { id: "maxBreadcrumbs", name: "Max breadcrumbs", description: "Maximum number of previous locations to keep", - action: { type: "input", placeholder: `${SETTINGS_DEFAULTS.maxBreadcrumbs}` }, + action: { type: "input", placeholder: `${DEFAULT_SETTINGS.maxBreadcrumbs}` }, }, { id: "truncateLength", name: "Truncate length", description: "Maximum breadcrumb label length before truncation", - action: { type: "input", placeholder: `${SETTINGS_DEFAULTS.truncateLength}` }, + action: { type: "input", placeholder: `${DEFAULT_SETTINGS.truncateLength}` }, }, ], }); - if ((extensionAPI.settings.get("enabled") as boolean | undefined) === false) { + if ((typedExtensionAPI.settings.get("enabled") as boolean | undefined) === false) { return; } - const settings = getSettings({ extensionAPI: extensionAPI as ExtensionAPI }); + const settings = readSettings({ extensionAPI: typedExtensionAPI }); injectStyles(); - createBreadcrumbsPanel(); + getOrCreatePanel(); hashChangeListener = () => { void handleNavigation(settings); From ef94e75c2f78783a800d3baaf830c5bcace12dfa Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Mon, 9 Feb 2026 09:53:32 -0600 Subject: [PATCH 3/8] refactor: extract string handling into a utility function for improved readability --- src/index.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0a3281b..de0639e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -94,25 +94,32 @@ const getLocationFromHash = (): Location | null => { return null; }; +const extractFirstString = ({ result }: { result: unknown }): string | null => { + if (!Array.isArray(result) || !Array.isArray(result[0])) return null; + const firstValue = result[0][0]; + return typeof firstValue === "string" ? firstValue : null; +}; + const queryPageTitle = async ({ uid }: { uid: string }): Promise => { - const pageResult = await window.roamAlphaAPI.q<[[string]]>(` + const pageResult = await window.roamAlphaAPI.q(` [:find ?title :where [?e :block/uid "${uid}"] [?e :node/title ?title]] `); - return pageResult?.[0]?.[0] || null; + return extractFirstString({ result: pageResult }); }; const queryBlockString = async ({ uid }: { uid: string }): Promise => { - const blockResult = await window.roamAlphaAPI.q<[[string]]>(` + const blockResult = await window.roamAlphaAPI.q(` [:find ?string :where [?e :block/uid "${uid}"] [?e :block/string ?string]] `); - if (!blockResult?.length) return null; - return blockResult[0][0] || "(empty block)"; + const blockString = extractFirstString({ result: blockResult }); + if (blockString === null) return null; + return blockString || "(empty block)"; }; const getBreadcrumbItemByUid = async ({ uid }: { uid: string }): Promise => { From 9eeb8e6a40d519663ac298165e4670f4ecbce30e Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Mon, 9 Feb 2026 12:02:19 -0600 Subject: [PATCH 4/8] Guard breadcrumbs navigation against stale async results --- src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/index.ts b/src/index.ts index de0639e..f1eefd7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,6 +55,7 @@ const UI_SELECTORS = { let breadcrumbHistory: BreadcrumbItem[] = []; let currentLocation: Location | null = null; let hashChangeListener: (() => void) | null = null; +let navigationSeq = 0; const parsePositiveInteger = ({ value, @@ -284,12 +285,14 @@ const handleNavigation = async ({ maxBreadcrumbs, truncateLength, }: ExtensionSettings): Promise => { + const mySeq = ++navigationSeq; const location = getLocationFromHash(); if (!location) return; if (currentLocation?.uid === location.uid) return; const breadcrumbItem = await getBreadcrumbItemByUid({ uid: location.uid }); + if (mySeq !== navigationSeq) return; if (!breadcrumbItem) return; updateBreadcrumbHistory({ item: breadcrumbItem, maxBreadcrumbs }); @@ -398,6 +401,7 @@ const cleanup = (): void => { breadcrumbHistory = []; currentLocation = null; + navigationSeq = 0; }; export default runExtension(async ({ extensionAPI }) => { From 0d22b83d33722ed0c11f5070b6e14b7a7cda8a24 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Mon, 9 Feb 2026 12:10:43 -0600 Subject: [PATCH 5/8] Refresh settings on navigation and honor truncate length visually --- src/index.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index f1eefd7..bd27465 100644 --- a/src/index.ts +++ b/src/index.ts @@ -197,6 +197,7 @@ const createBreadcrumbElement = ({ element.className = `breadcrumb-item ${typeClass} ${isCurrent ? "breadcrumb-current" : ""}`.trim(); element.textContent = truncateText({ text: item.title, maxLength: truncateLength }); element.title = stripMarkdown({ text: item.title }); + element.style.maxWidth = `${Math.max(truncateLength, 20)}ch`; if (!isCurrent) { element.addEventListener("click", (event) => { @@ -331,6 +332,7 @@ const injectStyles = (): void => { cursor: pointer; transition: background-color 0.15s ease; overflow: hidden; + white-space: nowrap; text-overflow: ellipsis; max-width: 200px; } @@ -435,16 +437,16 @@ export default runExtension(async ({ extensionAPI }) => { return; } - const settings = readSettings({ extensionAPI: typedExtensionAPI }); - injectStyles(); getOrCreatePanel(); hashChangeListener = () => { + const settings = readSettings({ extensionAPI: typedExtensionAPI }); void handleNavigation(settings); }; window.addEventListener("hashchange", hashChangeListener); + const settings = readSettings({ extensionAPI: typedExtensionAPI }); await handleNavigation(settings); return { From 32683b4f6de35c6b88154e0db0cd87815825f0b7 Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Mon, 9 Feb 2026 12:13:07 -0600 Subject: [PATCH 6/8] refactor: update type definitions and improve code readability in index.ts --- src/index.ts | 107 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 67 insertions(+), 40 deletions(-) diff --git a/src/index.ts b/src/index.ts index bd27465..ecdb1e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { OnloadArgs } from "roamjs-components/types/native"; import runExtension from "roamjs-components/util/runExtension"; type LocationType = "page" | "block"; @@ -18,25 +19,6 @@ type ExtensionSettings = { truncateLength: number; }; -type SettingsPanelField = { - id: string; - name: string; - description: string; - action: { - type: string; - placeholder?: string; - }; -}; - -type ExtensionAPI = { - settings: { - get: (key: string) => unknown; - panel: { - create: (args: { tabTitle: string; settings: SettingsPanelField[] }) => void; - }; - }; -}; - const DEFAULT_SETTINGS: ExtensionSettings = { maxBreadcrumbs: 8, truncateLength: 25, @@ -68,7 +50,11 @@ const parsePositiveInteger = ({ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; }; -const readSettings = ({ extensionAPI }: { extensionAPI: ExtensionAPI }): ExtensionSettings => ({ +const readSettings = ({ + extensionAPI, +}: { + extensionAPI: OnloadArgs["extensionAPI"]; +}): ExtensionSettings => ({ maxBreadcrumbs: parsePositiveInteger({ value: extensionAPI.settings.get("maxBreadcrumbs"), fallback: DEFAULT_SETTINGS.maxBreadcrumbs, @@ -101,7 +87,11 @@ const extractFirstString = ({ result }: { result: unknown }): string | null => { return typeof firstValue === "string" ? firstValue : null; }; -const queryPageTitle = async ({ uid }: { uid: string }): Promise => { +const queryPageTitle = async ({ + uid, +}: { + uid: string; +}): Promise => { const pageResult = await window.roamAlphaAPI.q(` [:find ?title :where [?e :block/uid "${uid}"] @@ -111,7 +101,11 @@ const queryPageTitle = async ({ uid }: { uid: string }): Promise return extractFirstString({ result: pageResult }); }; -const queryBlockString = async ({ uid }: { uid: string }): Promise => { +const queryBlockString = async ({ + uid, +}: { + uid: string; +}): Promise => { const blockResult = await window.roamAlphaAPI.q(` [:find ?string :where [?e :block/uid "${uid}"] @@ -123,7 +117,11 @@ const queryBlockString = async ({ uid }: { uid: string }): Promise => { +const getBreadcrumbItemByUid = async ({ + uid, +}: { + uid: string; +}): Promise => { const pageTitle = await queryPageTitle({ uid }); if (pageTitle) { return { @@ -160,13 +158,25 @@ const stripMarkdown = ({ text }: { text: string }): string => .replace(/#\[\[([^\]]+)\]\]/g, "#$1") .trim(); -const truncateText = ({ text, maxLength }: { text: string; maxLength: number }): string => { +const truncateText = ({ + text, + maxLength, +}: { + text: string; + maxLength: number; +}): string => { const cleaned = stripMarkdown({ text }); if (cleaned.length <= maxLength) return cleaned; return `${cleaned.slice(0, maxLength - 1)}...`; }; -const navigateTo = ({ uid, type }: { uid: string; type: LocationType }): void => { +const navigateTo = ({ + uid, + type, +}: { + uid: string; + type: LocationType; +}): void => { if (type === "page") { window.roamAlphaAPI.ui.mainWindow.openPage({ page: { uid } }); return; @@ -192,10 +202,15 @@ const createBreadcrumbElement = ({ truncateLength: number; }): HTMLSpanElement => { const element = document.createElement("span"); - const typeClass = item.type === "page" ? "breadcrumb-page" : "breadcrumb-block"; - - element.className = `breadcrumb-item ${typeClass} ${isCurrent ? "breadcrumb-current" : ""}`.trim(); - element.textContent = truncateText({ text: item.title, maxLength: truncateLength }); + const typeClass = + item.type === "page" ? "breadcrumb-page" : "breadcrumb-block"; + + element.className = + `breadcrumb-item ${typeClass} ${isCurrent ? "breadcrumb-current" : ""}`.trim(); + element.textContent = truncateText({ + text: item.title, + maxLength: truncateLength, + }); element.title = stripMarkdown({ text: item.title }); element.style.maxWidth = `${Math.max(truncateLength, 20)}ch`; @@ -242,7 +257,11 @@ const getContentContainer = (): HTMLElement | null => { return content instanceof HTMLElement ? content : null; }; -const renderBreadcrumbs = ({ truncateLength }: { truncateLength: number }): void => { +const renderBreadcrumbs = ({ + truncateLength, +}: { + truncateLength: number; +}): void => { const content = getContentContainer(); if (!content) return; @@ -262,7 +281,9 @@ const renderBreadcrumbs = ({ truncateLength }: { truncateLength: number }): void content.appendChild(createSeparatorElement()); } - content.appendChild(createBreadcrumbElement({ item, isCurrent, truncateLength })); + content.appendChild( + createBreadcrumbElement({ item, isCurrent, truncateLength }), + ); }); }; @@ -273,7 +294,9 @@ const updateBreadcrumbHistory = ({ item: BreadcrumbItem; maxBreadcrumbs: number; }): void => { - breadcrumbHistory = breadcrumbHistory.filter((historyItem) => historyItem.uid !== item.uid); + breadcrumbHistory = breadcrumbHistory.filter( + (historyItem) => historyItem.uid !== item.uid, + ); breadcrumbHistory.unshift(item); const maxEntries = maxBreadcrumbs + 1; @@ -407,9 +430,7 @@ const cleanup = (): void => { }; export default runExtension(async ({ extensionAPI }) => { - const typedExtensionAPI = extensionAPI as ExtensionAPI; - - typedExtensionAPI.settings.panel.create({ + extensionAPI.settings.panel.create({ tabTitle: "Breadcrumbs", settings: [ { @@ -422,18 +443,24 @@ export default runExtension(async ({ extensionAPI }) => { id: "maxBreadcrumbs", name: "Max breadcrumbs", description: "Maximum number of previous locations to keep", - action: { type: "input", placeholder: `${DEFAULT_SETTINGS.maxBreadcrumbs}` }, + action: { + type: "input", + placeholder: `${DEFAULT_SETTINGS.maxBreadcrumbs}`, + }, }, { id: "truncateLength", name: "Truncate length", description: "Maximum breadcrumb label length before truncation", - action: { type: "input", placeholder: `${DEFAULT_SETTINGS.truncateLength}` }, + action: { + type: "input", + placeholder: `${DEFAULT_SETTINGS.truncateLength}`, + }, }, ], }); - if ((typedExtensionAPI.settings.get("enabled") as boolean | undefined) === false) { + if ((extensionAPI.settings.get("enabled") as boolean | undefined) === false) { return; } @@ -441,12 +468,12 @@ export default runExtension(async ({ extensionAPI }) => { getOrCreatePanel(); hashChangeListener = () => { - const settings = readSettings({ extensionAPI: typedExtensionAPI }); + const settings = readSettings({ extensionAPI }); void handleNavigation(settings); }; window.addEventListener("hashchange", hashChangeListener); - const settings = readSettings({ extensionAPI: typedExtensionAPI }); + const settings = readSettings({ extensionAPI }); await handleNavigation(settings); return { From 5b29b443724075bfd813682b219bf8117a9e6a7a Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Mon, 9 Feb 2026 12:31:04 -0600 Subject: [PATCH 7/8] refactor: remove breadcrumbs enable toggle and related logic for cleaner settings --- src/index.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index ecdb1e3..5ed69b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -433,12 +433,6 @@ export default runExtension(async ({ extensionAPI }) => { extensionAPI.settings.panel.create({ tabTitle: "Breadcrumbs", settings: [ - { - id: "enabled", - name: "Enable breadcrumbs", - description: "Show navigation breadcrumbs in the Roam top bar", - action: { type: "switch" }, - }, { id: "maxBreadcrumbs", name: "Max breadcrumbs", @@ -460,10 +454,6 @@ export default runExtension(async ({ extensionAPI }) => { ], }); - if ((extensionAPI.settings.get("enabled") as boolean | undefined) === false) { - return; - } - injectStyles(); getOrCreatePanel(); From 7cb9d4b394ff9ece68e6413aba86d23778912f2b Mon Sep 17 00:00:00 2001 From: Michael Gartner Date: Mon, 9 Feb 2026 12:50:03 -0600 Subject: [PATCH 8/8] docs: update README to include RoamJS logo and enhance description of Breadcrumbs functionality --- README.md | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 83383b8..aab4086 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,13 @@ # Breadcrumbs -RoamJS extension that shows clickable navigation breadcrumbs in the Roam top bar. + + RoamJS Logo + + +**Never lose your place in Roam again. Breadcrumbs adds a clickable trail of your recent pages and blocks in the top bar, so +you can jump back instantly and navigate your graph with confidence.** + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/RoamJS/breadcrumbs) ## Features @@ -12,19 +19,5 @@ RoamJS extension that shows clickable navigation breadcrumbs in the Roam top bar ## Settings -- `Enable breadcrumbs`: turn the extension on/off - `Max breadcrumbs`: max number of prior locations to keep - `Truncate length`: max label length before truncation - -## Development - -```bash -npm install -npm start -``` - -Build dry-run for Roam: - -```bash -npm run build:roam -```