diff --git a/README.md b/README.md
index 2fd1f03..aab4086 100644
--- a/README.md
+++ b/README.md
@@ -1,37 +1,23 @@
-# RoamJS Extension Base
+# Breadcrumbs
-Stock base for [RoamJS](https://roamjs.com) Roam Research extensions. **Fork this repo** to start a new extension.
+
+
+
-## What's included
+**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.**
-- **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)
+[](https://deepwiki.com/RoamJS/breadcrumbs)
-## After forking
+## Features
-1. **Rename the repo** and update `package.json`:
- - `name`: your extension slug (e.g. `my-extension`)
- - `description`: one line describing the extension
+- 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`)
-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
+## Settings
-3. **Optional**: Add React components under `src/components/` (see [autocomplete](https://github.com/RoamJS/autocomplete), [giphy](https://github.com/RoamJS/giphy) for examples).
-
-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)
-
-## Scripts
-
-- `npm start` — samepage dev (local development)
-- `npm run build:roam` — build for Roam (dry run; CI runs `npx samepage build`)
-
-## License
-
-MIT
+- `Max breadcrumbs`: max number of prior locations to keep
+- `Truncate length`: max label length before truncation
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..5ed69b1 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,27 +1,472 @@
+import { OnloadArgs } from "roamjs-components/types/native";
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;
+};
+
+const DEFAULT_SETTINGS: ExtensionSettings = {
+ maxBreadcrumbs: 8,
+ truncateLength: 25,
+};
+
+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 breadcrumbHistory: BreadcrumbItem[] = [];
+let currentLocation: Location | null = null;
+let hashChangeListener: (() => void) | null = null;
+let navigationSeq = 0;
+
+const parsePositiveInteger = ({
+ value,
+ fallback,
+}: {
+ value: unknown;
+ fallback: number;
+}): number => {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
+};
+
+const readSettings = ({
+ extensionAPI,
+}: {
+ extensionAPI: OnloadArgs["extensionAPI"];
+}): ExtensionSettings => ({
+ maxBreadcrumbs: parsePositiveInteger({
+ value: extensionAPI.settings.get("maxBreadcrumbs"),
+ fallback: DEFAULT_SETTINGS.maxBreadcrumbs,
+ }),
+ truncateLength: parsePositiveInteger({
+ value: extensionAPI.settings.get("truncateLength"),
+ fallback: DEFAULT_SETTINGS.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 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(`
+ [:find ?title
+ :where [?e :block/uid "${uid}"]
+ [?e :node/title ?title]]
+ `);
+
+ return extractFirstString({ result: pageResult });
+};
+
+const queryBlockString = async ({
+ uid,
+}: {
+ uid: string;
+}): Promise => {
+ const blockResult = await window.roamAlphaAPI.q(`
+ [:find ?string
+ :where [?e :block/uid "${uid}"]
+ [?e :block/string ?string]]
+ `);
+
+ const blockString = extractFirstString({ result: blockResult });
+ if (blockString === null) return null;
+ return blockString || "(empty block)";
+};
+
+const getBreadcrumbItemByUid = async ({
+ uid,
+}: {
+ uid: string;
+}): Promise => {
+ const pageTitle = await queryPageTitle({ uid });
+ if (pageTitle) {
+ return {
+ uid,
+ type: "page",
+ title: pageTitle,
+ };
+ }
+
+ const blockString = await queryBlockString({ uid });
+ if (!blockString) return null;
+
+ return {
+ uid,
+ type: "block",
+ title: blockString,
+ };
+};
+
+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.slice(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 createSeparatorElement = (): 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");
+ 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`;
+
+ if (!isCurrent) {
+ element.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ navigateTo({ uid: item.uid, type: item.type });
+ });
+ }
+
+ return element;
+};
+
+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 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 (!breadcrumbHistory.length) {
+ content.textContent = "No history yet";
+ return;
+ }
+
+ const displayOrder = [...breadcrumbHistory].reverse();
+
+ displayOrder.forEach((item, index) => {
+ const isCurrent = index === displayOrder.length - 1;
+
+ if (index > 0) {
+ content.appendChild(createSeparatorElement());
+ }
+
+ 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,
+}: 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 });
+ currentLocation = location;
+
+ renderBreadcrumbs({ truncateLength });
+};
+
+const injectStyles = (): void => {
+ if (document.getElementById(UI_IDS.styles)) return;
+
+ const styles = document.createElement("style");
+ styles.id = UI_IDS.styles;
+ styles.textContent = `
+ #${UI_IDS.panel} {
+ 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;
+ white-space: nowrap;
+ 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 #${UI_IDS.panel} .breadcrumb-item:hover:not(.breadcrumb-current) {
+ background-color: rgba(255, 255, 255, 0.1);
+ }
+
+ .bp3-dark #${UI_IDS.panel} .breadcrumb-page {
+ color: #48aff0;
+ }
+
+ .bp3-dark #${UI_IDS.panel} .breadcrumb-block {
+ color: #a7b6c2;
+ }
+
+ .bp3-dark #${UI_IDS.panel} .breadcrumb-current {
+ background-color: rgba(72, 175, 240, 0.15);
+ }
+
+ .bp3-dark #${UI_IDS.panel} .breadcrumb-separator {
+ color: #5c7080;
+ }
+ `;
+
+ document.head.appendChild(styles);
+};
+
+const cleanup = (): void => {
+ if (hashChangeListener) {
+ window.removeEventListener("hashchange", hashChangeListener);
+ hashChangeListener = null;
+ }
+
+ document.getElementById(UI_IDS.panel)?.remove();
+ document.getElementById(UI_IDS.styles)?.remove();
+
+ breadcrumbHistory = [];
+ currentLocation = null;
+ navigationSeq = 0;
+};
+
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",
- action: { type: "switch" },
+ id: "maxBreadcrumbs",
+ name: "Max breadcrumbs",
+ description: "Maximum number of previous locations to keep",
+ 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}`,
+ },
},
],
});
- const enabled = extensionAPI.settings.get("enabled") as boolean | undefined;
- if (enabled === false) return;
+ injectStyles();
+ getOrCreatePanel();
+
+ hashChangeListener = () => {
+ const settings = readSettings({ extensionAPI });
+ void handleNavigation(settings);
+ };
+ window.addEventListener("hashchange", hashChangeListener);
- // Add your extension logic here.
- // Use roamjs-components: dom/*, queries/*, writes/*, util/*, components/*
+ const settings = readSettings({ extensionAPI });
+ await handleNavigation(settings);
return {
- unload: () => {
- // Clean up observers, listeners, command palette, etc.
- },
+ unload: cleanup,
};
});