In the meanwhile, FWIW, the extension scripting context can already ensure that a node has a .persistentId, and we can access that context through JXA.
Not as succinct or light-weight as the command line, but should also work well.
The following draft,
(you can experiment in Script Editor with the language selector at top-left set to JavaScript, rather than AppleScript),
has two options which you can set at the top, adjusting the depth and format of the TOC:
const options = {
maxLevel: 6,
format: unorderedWithLinks
};
and copies a TOC for the active outline (if it contains heading rows) to clipboard.
Expand disclosure triangle to view JS source
(() => {
"use strict";
// Copy a TABLE OF CONTENTS for the front Bike outline.
// Rob Trew @2026
// Ver 0.5
// ------------------- TOC FORMATS -------------------
// {path::[Int], level::Int, text:String, html:String, url:String}
// -> String
// Where:
// `path` is a list of zero-based sibling indices
// `level` is a zero-based index (top level is 0)
// `text` and `html` are alternative string formats
// `url` is a `bike://` link back to the heading in the document.
const legalNumbering = x =>
`${" ".repeat(x.level)}${x.path.map(v => 1 + v).join(".")} ${x.text}`;
const unorderedWithLinks = x =>
`${" ".repeat(x.level)}- [${x.text}](${x.url})`;
const multipleHash = x =>
`${"#".repeat(1 + x.level)} ${x.text}`;
// --------------------- OPTIONS ---------------------
const options = {
maxLevel: 6,
format: unorderedWithLinks
};
// The TOC is derived from any heading rows in the outline.
// ( Bike > Format > Row > Heading )
// and the heading level is a function of how many
// heading ancestors a given heading has in the outline.
// OPTIONS (set in the dictionary above)
// - You can restrict the depth of headings used by changing
// the integer value of `options.maxLevel`
// - If options.f is deleted from the options dictionary above,
// or given a value which is not a function, then
// the format of the TOC will default to a plain
// outline indented by four spaces at each level
// and with a " -".
// f can, however, be a function over (x, i)
// in which `x` is a dictionary with the keys:
// {path::[Int], level::Int, text::String, html::String, url::String}
// where `path` is an ancestral chain of zero-based peer indices,
// and `i` is the immediate zero-based peer index
// (also included as the last item of `path`)
// f can have the type (Dict, Int) -> String
// or simply (Dict -> String)
// For example to give the TOC an MD hash level format,
// you could specify:
// f: x => `${"#".repeat(1 + x.level)} ${x.text}`
// ---------------------- MAIN -----------------------
ObjC.import("AppKit");
const main = () => {
const
input = JSON.stringify(options),
bike = Application("Bike"),
render = ("function" === typeof options.format && options.format) || (
unorderedWithLinks
);
return either(
alert("Copy TOC for front Bike document")
)(
copyText
)(
bike.documents.at(0).exists()
? fmapLR(
outlineFromForest(render)
)(
jsonParseLR(
bike.evaluate({ script, input })
)
)
: Left("No document open in Bike.")
);
};
// ------------------ RENDERING ------------------
// outlineFromForest :: String ->
// (a -> String) -> [Tree a] -> [String]
const outlineFromForest = f =>
// Indented text representation of a list of Trees.
// f is an (a -> String) function defining
// the string representation of a tree node.
trees => {
const go = (x, i) => [
f(x.root, i),
...x.nest.flatMap(go)
];
return trees.flatMap(go)
.join("\n");
};
// ------------- BIKE EXTENSION CONTEXT --------------
const script = (inputJSON => {
const { maxLevel, withLinks } = JSON.parse(inputJSON);
// ------------------ BIKE MAIN ------------------
const bikeMain = () => {
const
deepest = maxLevel || Infinity,
root = bike.frontmostOutlineEditor.outline.root,
docId = root.persistentId;
return JSON.stringify(
forestWithPositions(
forestFromItemLevels(
headings(docId)(0)(deepest)(root)
)
)
.map(fmapTree(
([path, dict]) => Object.assign({ path }, dict)
)),
null, 2
);
};
// --------------------- TOC ---------------------
// forestWithPositions :: [Tree a] -> [Tree ([Int], a)]
const forestWithPositions = forest => {
const go = path =>
xs => xs.map(treeWithPositions(path));
return go([])(forest);
};
// treeWithPositions :: [Int] -> (Tree a, i) -> Tree ([Int], a)
const treeWithPositions = ancestralPath =>
(tree, index) => {
const fullPath = ancestralPath.concat(index);
return Node(
Tuple(fullPath)(
root(tree)
)
)(
nest(tree).map(
treeWithPositions(fullPath)
)
);
};
// headings :: String -> Int -> Row -> [{level::Int, text::String, HTML::String}]
const headings = docId =>
nLevel => maxDepth => row => {
const go = level =>
row => level < maxDepth && "heading" === row.type
? [
{
level,
text: row.text.string,
html: row.text.toHTML(),
url: `bike://${docId}/${row.persistentId}`
},
...row.children.flatMap(go(1 + level))
]
: row.children.flatMap(go(level));
return go(nLevel)(row);
};
// -------------- FOREST STRUCTURE ---------------
// forestFromLevels :: Int [{level::Int ...}] ->
// [Tree {level::Int ...}]
const forestFromItemLevels = dicts => {
const go = xs =>
0 < xs.length
? (() => {
// First line and its sub-tree,
const
item = xs[0],
level = item.level,
[tree, rest] = span(
x => level < x.level
)(
xs.slice(1)
);
return [
Node(item)(go(tree))
]
// followed by the rest.
.concat(go(rest));
})()
: [];
return go(dicts);
};
// ------------------- GENERIC -------------------
// Node :: a -> [Tree a] -> Tree a
const Node = v =>
// Constructor for a Tree node which connects a
// value of some kind to a list of zero or
// more child trees.
xs => ({
root: v,
nest: xs || []
});
// Tuple (,) :: a -> b -> (a, b)
const Tuple = a =>
// A pair of values, possibly of
// different types.
b => ({
type: "Tuple",
"0": a,
"1": b,
length: 2,
*[Symbol.iterator]() {
for (const k in this) {
if (!isNaN(k)) {
yield this[k];
}
}
}
});
// fmapTree :: (a -> b) -> Tree a -> Tree b
const fmapTree = f => {
// A new tree. The result of a
// structure-preserving application of f
// to each root in the existing tree.
const go = t => Node(
f(root(t))
)(
nest(t).map(go)
);
return go;
};
// nest :: Tree a -> [a]
const nest = tree =>
tree.nest;
// root :: Tree a -> a
const root = tree =>
// The value attached to a tree node.
tree.root;
// span :: (a -> Bool) -> [a] -> ([a], [a])
const span = p =>
// Longest prefix of xs consisting of elements which
// all satisfy p, tupled with the remainder of xs.
xs => {
const i = xs.findIndex(x => !p(x));
return -1 !== i
? Tuple(
xs.slice(0, i)
)(
xs.slice(i)
) : Tuple(xs)([]);
};
// MAIN ---
return bikeMain();
})
.toString()
// ------------------- JXA CONTEXT -------------------
// alert :: String => String -> IO String
const alert = title =>
s => {
const sa = Object.assign(
Application("System Events"), {
includeStandardAdditions: true
});
return (
sa.activate(),
sa.displayDialog(s, {
withTitle: title,
buttons: ["OK"],
defaultButton: "OK"
}),
s
);
};
// copyText :: String -> IO String
const copyText = s => {
// ObjC.import("AppKit");
const pb = $.NSPasteboard.generalPasteboard;
return (
pb.clearContents,
pb.setStringForType(
$(s),
$.NSPasteboardTypeString
),
s
);
};
// -------------------- GENERICS ---------------------
// Left :: a -> Either a b
const Left = x => ({
type: "Either",
Left: x
});
// Right :: b -> Either a b
const Right = x => ({
type: "Either",
Right: x
});
// either :: (a -> c) -> (b -> c) -> Either a b -> c
const either = fl =>
// Application of the function fl to the
// contents of any Left value in e, or
// the application of fr to its Right value.
fr => e => "Left" in e
? fl(e.Left)
: fr(e.Right);
// fmapLR (<$>) :: (b -> c) -> Either a b -> Either a c
const fmapLR = f =>
// Either f mapped into the contents of any Right
// value in e, or e unchanged if it is a Left value.
e => "Left" in e
? e
: Right(f(e.Right));
// jsonParseLR :: String -> Either String a
const jsonParseLR = s => {
try {
return Right(JSON.parse(s));
} catch (e) {
return Left(
[
e.message,
`(line:${e.line} col:${e.column})`
].join("\n")
);
}
};
return main();
})();