Fix #2980: add MVP :define and glossary

Now I just have to write all the definitions :(
This commit is contained in:
Oliver Blanthorn 2026-07-27 12:44:08 +02:00
parent 1ad5c34ac6
commit 82985ae14c
No known key found for this signature in database
GPG key ID: 2BB8C36BB504BFF3
12 changed files with 189 additions and 11 deletions

3
doc/glossary.jsonl Normal file
View file

@ -0,0 +1,3 @@
{"word": "mozilla", "definition": "The foundation that controls Firefox and Thunderbird."}
{"word": "bovine3dom", "definition": "The esteemed maintainer of Tridactyl and author of this glossary."}
{"word": "vimperator", "definition": "A now defunct Firefox extension that was the main inspiration for Tridactyl."}

View file

@ -42,6 +42,7 @@ mkdir -p build
mkdir -p build/static
mkdir -p generated/static
mkdir -p generated/static/clippy
node scripts/make_glossary.js
if [ "$(isWindowsMinGW)" = "True" ]; then
$WIN_PYTHON scripts/excmds_macros.py

76
scripts/make_glossary.js Normal file
View file

@ -0,0 +1,76 @@
#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
const root = path.resolve(__dirname, "..")
const source = path.join(root, "doc/glossary.jsonl")
const entries = []
const anchors = new Set()
for (const [index, line] of fs
.readFileSync(source, "utf8")
.split("\n")
.entries()) {
if (!line.trim()) continue
let entry
try {
entry = JSON.parse(line)
} catch (error) {
throw new Error(`${source}:${index + 1}: ${error.message}`)
}
if (
!entry ||
typeof entry.word !== "string" ||
typeof entry.definition !== "string" ||
!entry.word.trim() ||
!entry.definition.trim()
)
throw new Error(
`${source}:${index + 1}: word and definition must be non-empty strings`,
)
const word = entry.word.trim().normalize("NFC")
const definition = entry.definition.trim()
const anchor = word.toLowerCase()
if (anchors.has(anchor))
throw new Error(
`${source}:${index + 1}: duplicate word ${JSON.stringify(word)}`,
)
anchors.add(anchor)
entries.push({ word, definition, anchor })
}
if (!entries.length) throw new Error(`${source}: glossary must not be empty`)
entries.sort((a, b) => (a.word < b.word ? -1 : a.word > b.word ? 1 : 0))
const escape = value =>
value.replace(/[&<>"']/g, char => `&#${char.charCodeAt(0)};`)
const body = [
"<h1>Glossary</h1>",
"<p>Terms used in Tridactyl&#39;s documentation.</p>",
'<dl class="glossary-list">',
...entries.map(
entry =>
`<div class="glossary-entry" id="${escape(entry.anchor)}"><dt><code>${escape(entry.word)}</code></dt><dd>${escape(entry.definition)}</dd></div>`,
),
"</dl>",
].join("\n")
let html = fs.readFileSync(
path.join(root, "src/static/clippy/tutor.template.html"),
"utf8",
)
for (const [marker, replacement] of [
["<title>Tridactyl Tutorial</title>", "<title>Tridactyl Glossary</title>"],
['href="./glossary.html"', 'aria-current="page" href="./glossary.html"'],
["REPLACETHIS", body],
]) {
if (html.split(marker).length !== 2)
throw new Error(
`Expected one ${JSON.stringify(marker)} in tutor.template.html`,
)
html = html.replace(marker, replacement)
}
fs.writeFileSync(
path.join(root, "src/.glossary.generated.json"),
JSON.stringify(entries),
)
fs.writeFileSync(path.join(root, "generated/static/clippy/glossary.html"), html)

View file

@ -35,6 +35,7 @@ import { ExtensionsCompletionSource } from "@src/completions/Extensions"
import { FileSystemCompletionSource } from "@src/completions/FileSystem"
import { GotoCompletionSource } from "@src/completions/Goto"
import { GuisetCompletionSource } from "@src/completions/Guiset"
import { GlossaryCompletionSource } from "@src/completions/Glossary"
import { HelpCompletionSource } from "@src/completions/Help"
import { HistoryCompletionSource } from "@src/completions/History"
import { PreferenceCompletionSource } from "@src/completions/Preferences"
@ -153,6 +154,7 @@ export function enableCompletions() {
FileSystemCompletionSource,
GotoCompletionSource,
GuisetCompletionSource,
GlossaryCompletionSource,
HelpCompletionSource,
AproposCompletionSource,
HistoryCompletionSource,

View file

@ -7,6 +7,7 @@ import {
} from "@src/.metadata.generated"
import * as aliases from "@src/lib/aliases"
import * as config from "@src/lib/config"
import { glossaryOptions } from "@src/completions/Glossary"
class AproposCompletionOption extends Completions.CompletionOptionHTML implements Completions.CompletionOptionFuse {
public fuseKeys = []
@ -139,6 +140,10 @@ export class AproposCompletionSource extends Completions.CompletionSourceFuse {
)
}),
),
"-g": (options, query) =>
options.concat(
glossaryOptions(this.createOption.bind(this), query, false),
),
}
const args = query.split(" ")

View file

@ -10,7 +10,7 @@ export class ExcmdCompletionOption extends Completions.CompletionOptionHTML impl
public documentation = "",
) {
super()
this.fuseKeys.push(this.value)
this.fuseKeys.push(this.value, this.documentation)
// Create HTMLElement
this.html = html`<tr class="ExcmdCompletionOption option">

View file

@ -0,0 +1,31 @@
import * as Completions from "@src/completions"
import { ExcmdCompletionOption } from "@src/completions/Excmd"
import glossary from "@src/.glossary.generated.json"
export function glossaryOptions(createOption, query: string, prefix: boolean) {
const needle = query.toLowerCase()
return glossary
.filter(entry =>
prefix
? entry.word.toLowerCase().startsWith(needle)
: (entry.word + entry.definition)
.toLowerCase()
.includes(needle),
)
.map(entry =>
createOption(entry.word, `Glossary. ${entry.definition}`, "-g"),
)
}
export class GlossaryCompletionSource extends Completions.CompletionSourceFuse {
public options: ExcmdCompletionOption[]
constructor(parent) {
super(["define"], "GlossaryCompletionSource", "Glossary")
this.options = glossary.map(
entry => new ExcmdCompletionOption(entry.word, entry.definition),
)
this.sortScoredOptions = true
parent.appendChild(this.node)
}
}

View file

@ -8,6 +8,7 @@ import {
} from "@src/.metadata.generated"
import * as aliases from "@src/lib/aliases"
import * as config from "@src/lib/config"
import { glossaryOptions } from "@src/completions/Glossary"
class HelpCompletionOption extends Completions.CompletionOptionHTML implements Completions.CompletionOptionFuse {
public fuseKeys = []
@ -116,6 +117,10 @@ export class HelpCompletionSource extends AproposCompletionSource {
)
}),
),
"-g": (options, query) =>
options.concat(
glossaryOptions(this.createOption.bind(this), query, true),
),
}
const args = query.split(" ")

View file

@ -88,7 +88,7 @@ import * as Logging from "@src/lib/logging"
import { AutoContain, markExplicitContainerTab } from "@src/lib/autocontainers"
import * as CSS from "css"
import * as Perf from "@src/perf"
import { staticThemes, defaultConfigMembers, memberType, typeKind, convert, convertMember } from "@src/.metadata.generated"
import { staticThemes, excmdsFunctions, defaultConfigMembers, memberType, typeKind, convert, convertMember } from "@src/.metadata.generated"
import * as Native from "@src/lib/native"
import * as TTS from "@src/lib/text_to_speech"
import * as excmd_parser from "@src/parsers/exmode"
@ -102,6 +102,7 @@ import * as R from "ramda"
import * as treestyletab from "@src/interop/tst"
import { uuidv4 } from "@src/lib/math"
import { ABOUT_PAGES } from "@src/lib/about_pages"
import glossary from "@src/.glossary.generated.json"
/**
* This is used to drive some excmd handling in `composite`.
@ -1806,11 +1807,11 @@ export function home(all: "false" | "true" = "false") {
/** Show this page.
`:help something` jumps to the entry for something. Something can be an excmd, an alias for an excmd, a binding or a setting.
`:help something` jumps to the entry for something. Something can be an excmd, an alias for an excmd, a binding, a setting or a glossary term.
On the ex command page, the "nmaps" list is a list of all the bindings for the command you're seeing and the "exaliases" list lists all its aliases.
If there's a conflict (e.g. you have a "go" binding that does something, a "go" excmd that does something else and a "go" setting that does a third thing), the binding is chosen first, then the setting, then the excmd. In such situations, if you want to let Tridactyl know you're looking for something specfic, you can specify the following flags:
If there's a conflict, bindings are chosen first, then settings, aliases, ex commands and finally glossary terms. You can select a category explicitly with the following flags:
`-a`: look for an alias
@ -1820,6 +1821,8 @@ export function home(all: "false" | "true" = "false") {
`-s`: look for a setting
`-g`: look in the glossary
`-B`: open the help page in a background tab
`-o`: open the help page in the current tab
@ -1840,6 +1843,7 @@ export async function help(...args: string[]) {
"-b": Boolean,
"-e": Boolean,
"-s": Boolean,
"-g": Boolean,
"-B": Boolean,
"-o": Boolean,
"-t": Boolean,
@ -1848,7 +1852,10 @@ export async function help(...args: string[]) {
{ argv: args, allowNegativePositional: true },
)
const openInCurrentWindow = option["-o"] || ((await activeTab()).url.startsWith(browser.runtime.getURL("static/docs/")) && !(option["-B"] || option["-t"] || option["-w"]))
const glossaryPage = browser.runtime.getURL("static/clippy/glossary.html")
const excmdPage = browser.runtime.getURL("static/docs/modules/_src_excmds_.html")
const activeUrl = (await activeTab()).url
const openInCurrentWindow = option["-o"] || ((activeUrl.startsWith(browser.runtime.getURL("static/docs/")) || activeUrl.startsWith(glossaryPage)) && !(option["-B"] || option["-t"] || option["-w"]))
const subject = option._.join(" ")
const settings = await config.getAsync()
let url = ""
@ -1866,7 +1873,7 @@ export async function help(...args: string[]) {
if (resolved.includes(helpItem)) break
}
if (resolved.length > 0) {
return browser.runtime.getURL("static/docs/modules/_src_excmds_.html") + "#" + helpItem
return excmdPage + "#" + helpItem
}
return ""
},
@ -1880,12 +1887,16 @@ export async function help(...args: string[]) {
if (helpItem in bindings) {
helpItem = bindings[helpItem].split(" ")
helpItem = ["composite", "fillcmdline"].includes(helpItem[0]) ? helpItem[1] : helpItem[0]
return browser.runtime.getURL("static/docs/modules/_src_excmds_.html") + "#" + helpItem
return excmdPage + "#" + helpItem
}
}
return ""
},
excmd: (helpItem: string) => browser.runtime.getURL("static/docs/modules/_src_excmds_.html") + "#" + helpItem,
excmd: (helpItem: string) => Object.prototype.hasOwnProperty.call(excmdsFunctions, helpItem) ? excmdPage + "#" + helpItem : "",
glossary: (helpItem: string) => {
const entry = glossary.find(entry => entry.word === helpItem)
return entry ? glossaryPage + "#" + encodeURIComponent(entry.anchor) : ""
},
setting: (helpItem: string) => {
let subSettings = settings
const settingNames = helpItem.split(".")
@ -1905,22 +1916,27 @@ export async function help(...args: string[]) {
}
if (subject === "") {
url = browser.runtime.getURL("static/docs/modules/_src_excmds_.html")
url = option["-g"] ? glossaryPage : excmdPage
} else {
const categoryFlags = ["-a", "-b", "-e", "-s", "-g"].filter(flag => option[flag])
if (categoryFlags.length > 1) throw new Error("Only one help category may be selected")
// If the user did specify what they wanted, specifically look for it
if (option["-a"]) url = strategies.alias(subject)
else if (option["-b"]) url = strategies.binding(subject)
else if (option["-e"]) url = strategies.excmd(subject)
else if (option["-s"]) url = strategies.setting(subject)
else if (option["-g"]) url = strategies.glossary(subject)
// Otherwise or if it couldn't be found, try all possible items
if (url === "") {
const priority = [strategies.binding, strategies.setting, strategies.alias, strategies.excmd]
if (url === "" && categoryFlags.length === 0) {
const priority = [strategies.binding, strategies.setting, strategies.alias, strategies.excmd, strategies.glossary]
for (const strategy of priority) {
url = strategy(subject)
if (url !== "") break
}
}
if (url === "" && categoryFlags.length) throw new Error(`No ${categoryFlags[0]} help found for ${subject}`)
if (url === "") url = excmdPage + "#" + subject
}
let done
@ -1937,6 +1953,12 @@ export async function help(...args: string[]) {
return done.then(() => undefined)
}
/** Look up a term in the glossary. */
//#background
export async function define(...words: string[]) {
return help("-g", ...words)
}
/**
* Search through the help pages. Accepts the same flags as [[help]]. Only useful in interactive usage with completions; the command itself is just a wrapper for [[help]].
*/

View file

@ -748,6 +748,7 @@ export class default_config {
sanitize: "sanitise",
"saveas!": "saveas --cleanup --overwrite",
tutorial: "tutor",
glossary: "define",
h: "help",
unmute: "mute unmute",
authors: "credits",

View file

@ -48,6 +48,7 @@
<li><a href="./8-marks.html">Marks</a></li>
<li><a href="./8-1-i18n.html">Internationalisation</a></li>
<li><a href="./9-help.html">Getting help</a></li>
<li><a href="./glossary.html">Glossary</a></li>
</ol>
</nav>
REPLACETHIS

View file

@ -82,6 +82,37 @@ code {
hyphens: none;
}
.glossary-list {
margin: 2em 0 0;
}
.glossary-entry {
padding: 1em 0.5em 1.1em;
border-bottom: 1px solid var(--tridactyl-highlight-box-bg);
scroll-margin-top: 1em;
}
.glossary-entry:first-child {
border-top: 1px solid var(--tridactyl-highlight-box-bg);
}
.glossary-entry:target {
background: var(--tridactyl-highlight-box-bg);
}
.glossary-entry dt {
font-weight: bold;
}
.glossary-entry dt code {
font-size: 1em;
}
.glossary-entry dd {
margin: 0.4em 0 0;
line-height: 140%;
}
img {
max-width: 100%;
display: block;