714 lines
24 KiB
Typst
714 lines
24 KiB
Typst
#import "@preview/valkyrie:0.2.2" as z
|
|
|
|
#import "./schemas.typ": *
|
|
#import "./themes.typ": *
|
|
#import "./utils.typ": *
|
|
|
|
#let __gloss_entries = state("__gloss_entries", (:))
|
|
|
|
#let __gloss_label_prefix = "__gloss:"
|
|
#let __gloss_first_use_counter_postfix = ":first-use-count"
|
|
#let __gloss_entry_postfix = ":entry"
|
|
|
|
// Normalizes a dictionary entry by ensuring all required and optional keys exist
|
|
// with appropriate default values.
|
|
//
|
|
// Parameters:
|
|
// key: The key from the input dictionary
|
|
// entry (dictionary): Input dictionary containing at least 'short' and optionally
|
|
// 'long', 'plural', 'longplural', 'description', and 'group'
|
|
//
|
|
// entry (string): If you pass a string to entry, we assume you're using the
|
|
// abbreviated syntax `short: "long"`, where the `key` is used
|
|
// for `short` and `entry` is used for `long`.
|
|
//
|
|
// Returns:
|
|
// dictionary: Normalized dictionary with all expected keys populated
|
|
//
|
|
// Panics:
|
|
// - If 'short' key is missing when using normal (non-abbreviated) syntax
|
|
// - If 'short' value is not a string
|
|
// - If 'short' value is an empty string
|
|
//
|
|
#let __normalize_entry(key, entry) = {
|
|
// If it's a string, it's using the abbreviated syntax `short: "long"`.
|
|
if (type(entry) == str) {
|
|
entry = (short: key, long: entry)
|
|
}
|
|
|
|
// Validate required fields
|
|
if not "short" in entry {
|
|
panic("Entry must contain a 'short' key")
|
|
}
|
|
if type(entry.short) != str {
|
|
panic("Entry 'short' must be a string")
|
|
}
|
|
if entry.short.trim() == "" {
|
|
panic("Entry 'short' cannot be empty")
|
|
}
|
|
|
|
// Extract values with defaults, using consistent none for missing optionals
|
|
let long = entry.at("long", default: none)
|
|
|
|
// Return the normalized entry
|
|
(
|
|
short: entry.short,
|
|
plural: entry.at("plural", default: __pluralize(entry.short)),
|
|
article: entry.at("article", default: __determine_article(entry.short)),
|
|
long: long,
|
|
longplural: entry.at("longplural", default: __pluralize(long)),
|
|
longarticle: entry.at("longarticle", default: __determine_article(long)),
|
|
description: entry.at("description", default: none),
|
|
group: entry.at("group", default: "")
|
|
)
|
|
}
|
|
|
|
// Checks if a key exists in the glossary state.
|
|
//
|
|
// Parameters:
|
|
// key (string): The key to look up in the glossary entries
|
|
//
|
|
// Returns:
|
|
// boolean: True if the key exists in the glossary, false otherwise
|
|
//
|
|
#let __has_entry(key) = {
|
|
key in __gloss_entries.final().keys()
|
|
}
|
|
|
|
// Updates the glossary state by adding or updating an entry.
|
|
//
|
|
// Parameters:
|
|
// key (string): The key under which to store the entry
|
|
// entry (dictionary): The normalized glossary entry to store
|
|
//
|
|
// Returns:
|
|
// none: Updates state as a side effect
|
|
//
|
|
#let __add_entry(key, entry) = {
|
|
__gloss_entries.update(state => {
|
|
state.insert(key, entry)
|
|
state
|
|
})
|
|
}
|
|
|
|
// Retrieves a glossary entry from the state.
|
|
//
|
|
// Parameters:
|
|
// key (string): The key of the entry to retrieve
|
|
//
|
|
// Returns:
|
|
// dictionary: The glossary entry associated with the key
|
|
//
|
|
// Panics:
|
|
// - If the key does not exist in the glossary
|
|
//
|
|
#let __get_entry(key) = {
|
|
let entries = __gloss_entries.final()
|
|
if key not in entries.keys() {
|
|
panic("Glossary error: Missing key '" + key + "'")
|
|
}
|
|
|
|
entries.at(key)
|
|
}
|
|
|
|
// Creates a label object for term usage in documents.
|
|
//
|
|
// Parameters:
|
|
// key (string): The glossary entry key
|
|
//
|
|
// Returns:
|
|
// label: A Typst label object for the term usage
|
|
//
|
|
#let __term_label(key) = {
|
|
label(__gloss_label_prefix + key)
|
|
}
|
|
|
|
// Creates a label object for the entry in the glossary.
|
|
//
|
|
// Parameters:
|
|
// key (string): The glossary entry key
|
|
//
|
|
// Returns:
|
|
// label: A Typst label object for the term usage
|
|
//
|
|
#let __entry_label(key) = {
|
|
label(__gloss_label_prefix + key + __gloss_entry_postfix)
|
|
}
|
|
|
|
// Updates the term usage in the glossary state.
|
|
//
|
|
// Parameters:
|
|
// key (string): The glossary entry key
|
|
//
|
|
// Returns:
|
|
// none: Updates state as a side effect
|
|
//
|
|
#let __mark_term_used(key, count-as-first-use) = {
|
|
counter(__gloss_label_prefix + key).step()
|
|
if count-as-first-use {
|
|
counter(__gloss_label_prefix + key + __gloss_first_use_counter_postfix).step()
|
|
}
|
|
}
|
|
|
|
// Queries whether the term is used ANYWHERE
|
|
//
|
|
// Parameters:
|
|
// key (string): The glossary entry key
|
|
// location (location): The location in the document. Use none for end of document
|
|
//
|
|
// Returns:
|
|
// boolean: If the entry is used above the location in the document
|
|
//
|
|
#let __is_term_ever_used(key, location: none) = {
|
|
let c = counter(__gloss_label_prefix + key)
|
|
c = if location == none {c.final()} else {c.at(location)}
|
|
c.at(0) > 0
|
|
}
|
|
|
|
// Queries whether the term has been "first used"
|
|
//
|
|
// "First used" is used to determine how to display the term, either "long
|
|
// (short)" or just "(short)"
|
|
//
|
|
// Parameters:
|
|
// key (string): The glossary entry key
|
|
// location (location): The location in the document. Use none for end of document
|
|
//
|
|
// Returns:
|
|
// boolean: If the entry is used above the location in the document
|
|
//
|
|
#let __is_term_first_used(key, location: none) = {
|
|
let c = counter(__gloss_label_prefix + key + __gloss_first_use_counter_postfix)
|
|
c = if location == none {c.final()} else {c.at(location)}
|
|
c.at(0) > 0
|
|
}
|
|
|
|
// Determine if the glossary contains a visible entry
|
|
//
|
|
// Parameters:
|
|
// key (string): The glossary entry key
|
|
//
|
|
// Returns:
|
|
// boolean: If the term is visible in the glossary (and the label is unique)
|
|
//
|
|
#let __has_glossary_entry(key) = {
|
|
query(__entry_label(key)).len() > 0
|
|
}
|
|
|
|
// Renders a glossary term with various formatting options.
|
|
//
|
|
// Parameters:
|
|
// key (string): The glossary entry key to render.
|
|
// modifiers (array): Array of modifier strings that control term rendering:
|
|
// - "cap": Capitalize the first letter of the term.
|
|
// - "pl": Use plural form of the term.
|
|
// - "both": Show "Long form (short form)".
|
|
// - "short": Show only the short form.
|
|
// - "long": Show only the long form.
|
|
// - "def" or "desc": Show the term's description instead of its name.
|
|
// - "a" or "an": Prepend an article, chosen from the entry (short or long form).
|
|
// - "hide" or "hidden": Use term (for the sake of the glossary/index),
|
|
// but don't display it.
|
|
// show-term (function): A function that renders the chosen term.
|
|
// term-links (boolean): If terms should be clickable links leading to glossary
|
|
//
|
|
// Behavior:
|
|
// - Without modifiers, the first use of a term shows "Long form (short form)",
|
|
// subsequent uses show only the short form.
|
|
// - Explicit modifiers ("long", "short", "both") override the default behavior.
|
|
// - If a requested long form doesn't exist, the logic falls back to the short form.
|
|
// - A usage counter tracks how many times a term has been referenced.
|
|
//
|
|
// Returns:
|
|
// content: The fully formatted term, including optional article, capitalization,
|
|
// usage tracking metadata, and the chosen form (short, long, or both).
|
|
//
|
|
#let __gls(key, modifiers: array, format-term: function, show-term: function, term-links: true, display-text: none) = {
|
|
// ---------------------------------------------------------------------------
|
|
// Check for illegal modifier combinations
|
|
// ---------------------------------------------------------------------------
|
|
if ("def" in modifiers or "desc" in modifiers) and modifiers.len() > 1 {
|
|
panic("Cannot use 'def'/'desc' with other modifiers.")
|
|
}
|
|
if ("a" in modifiers or "an" in modifiers) and ("pl" in modifiers) {
|
|
panic("Cannot use 'a'/'an' and 'pl' together.")
|
|
}
|
|
if ("hidden" in modifiers or "hide" in modifiers) and modifiers.len() > 1{
|
|
panic("Cannot use 'hide'/'hidden' with other modifiers.")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Retrieve the glossary entry and its label
|
|
// ---------------------------------------------------------------------------
|
|
let entry = __get_entry(key)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// If "def" or "desc" modifier is present, show the entry's description immediately
|
|
// ---------------------------------------------------------------------------
|
|
if "def" in modifiers or "desc" in modifiers {
|
|
if entry.description == none {
|
|
panic("Use of 'def'/'desc' requires a description be defined.")
|
|
}
|
|
return show-term([#entry.description])
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Manage term usage counting and determine if it's the first reference
|
|
// ---------------------------------------------------------------------------
|
|
let is_first_use = not __is_term_first_used(key, location: here())
|
|
let count-as-first-use = ("short" not in modifiers and "long" not in modifiers and "both" not in modifiers)
|
|
let wants_reference = ("noref" not in modifiers and "noindex" not in modifiers)
|
|
if wants_reference {
|
|
__mark_term_used(key, count-as-first-use)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper Functions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// pluralize_term(singular, plural): Returns plural form if "pl" modifier is present and available;
|
|
// otherwise returns the singular form.
|
|
let pluralize_term = (singular, plural) => {
|
|
if "pl" in modifiers and plural != none {
|
|
plural
|
|
} else {
|
|
singular
|
|
}
|
|
}
|
|
|
|
// capitalize_term(article, term): If "cap" is in modifiers, capitalizes the first letter
|
|
// of whichever text appears first (the article if present, otherwise the term).
|
|
// Returns a tuple (article, term) which may be modified to have uppercase first letters.
|
|
let capitalize_term = (article, term) => {
|
|
if "cap" not in modifiers {
|
|
(article, term)
|
|
} else if article == "" {
|
|
// No article present, so capitalize the term's first letter.
|
|
(article, upper(term.first()) + term.slice(1))
|
|
} else {
|
|
// Article is present; capitalize its first letter instead.
|
|
(upper(article.first()) + article.slice(1), term)
|
|
}
|
|
}
|
|
|
|
// get_article(mode): Returns the appropriate article string based on the mode.
|
|
// - If wants_article is false, returns an empty string.
|
|
// - If mode is "short", return the short article.
|
|
// - If mode is "long" or "both", return the long article.
|
|
// The calling logic ensures that when mode = "long" or "both", a long form exists.
|
|
let get_article = (mode) => {
|
|
let wants_article = "a" in modifiers or "an" in modifiers
|
|
if not wants_article {
|
|
""
|
|
} else if mode == "short" {
|
|
entry.article + " "
|
|
} else {
|
|
// mode == "long" or "both"
|
|
entry.longarticle + " "
|
|
}
|
|
}
|
|
|
|
// determine_mode(wants_both, wants_long, wants_short, is_first_use, long_available):
|
|
// Decides which mode ("short", "long", or "both") to use.
|
|
// Preference is given to explicit modifiers. If the requested mode can't be fulfilled due to no long form,
|
|
// fall back to "short".
|
|
// If no explicit mode is given, the default behavior is:
|
|
// - On first use and if a long form exists, "both".
|
|
// - Otherwise, "short".
|
|
let determine_mode = (wants_both, wants_long, wants_short, is_first_use, long_available, wants_hidden) => {
|
|
if wants_hidden {
|
|
// If it's hidden, nothing else matters
|
|
"hidden"
|
|
} else if wants_both {
|
|
// Explicit request for "both":
|
|
// If a long form exists, use "both", otherwise fall back to "short".
|
|
if long_available {
|
|
"both"
|
|
} else {
|
|
"short"
|
|
}
|
|
} else if wants_long {
|
|
// Explicit request for "long":
|
|
// If a long form exists, use "long", otherwise fall back to "short".
|
|
if long_available {
|
|
"long"
|
|
} else {
|
|
"short"
|
|
}
|
|
} else if wants_short {
|
|
// Explicit request for "short":
|
|
// Always "short" regardless of availability.
|
|
"short"
|
|
} else {
|
|
// No explicit mode given:
|
|
// On first use with a long form available, default to "both".
|
|
// Otherwise, use "short".
|
|
if is_first_use and long_available {
|
|
"both"
|
|
} else {
|
|
"short"
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Determine desired options based on modifiers and entry.long availability
|
|
// ---------------------------------------------------------------------------
|
|
let wants_both = "both" in modifiers
|
|
let wants_long = "long" in modifiers
|
|
let wants_short = "short" in modifiers
|
|
let wants_hidden = ("hidden" in modifiers or "hide" in modifiers)
|
|
let long_available = entry.long != none
|
|
|
|
// Determine mode using the helper function
|
|
let mode = determine_mode(wants_both, wants_long, wants_short, is_first_use, long_available, wants_hidden)
|
|
|
|
// Pluralize (if requested)
|
|
let short-form = pluralize_term(entry.short, entry.plural)
|
|
let long-form = pluralize_term(entry.long, entry.longplural) // `none` safe here
|
|
|
|
// Apply format-term() to get the term
|
|
let formatted-term = if display-text != none and display-text != auto {
|
|
// Display text was overriden by user, just accept it (string or content)
|
|
display-text
|
|
} else {
|
|
// Normal display (ie no override from user)
|
|
format-term(mode, short-form, long-form)
|
|
}
|
|
if "cap" in modifiers and type(formatted-term) != str {
|
|
// This is because we still need to capitalize the term, and we cannot do
|
|
// that with content, thus requiring a string here.
|
|
// TODO: consider if we want to capitalize *before* this. First intuition is
|
|
// "no".
|
|
panic("Your cutsom format-term() function must return a string.")
|
|
}
|
|
|
|
// Get the article, then capitalize either the article or term (if requested)
|
|
let (article, term) = capitalize_term(get_article(mode), formatted-term)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Construct and return the final output
|
|
// ---------------------------------------------------------------------------
|
|
context {
|
|
let linked-term = if term-links and query(__entry_label(key)).len() > 0 {
|
|
link(__entry_label(key), term)
|
|
} else {
|
|
term
|
|
}
|
|
// Figure out our label (unless not wanted)
|
|
let term-label = if wants_reference {
|
|
__term_label(key)
|
|
} else {
|
|
[]
|
|
}
|
|
|
|
// Create the output content
|
|
if mode == "hidden" {
|
|
// just put the metadata+label out there
|
|
[#metadata("hidden term")#term-label]
|
|
} else {
|
|
// normal term display (ie not hidden)
|
|
[#article#show-term(linked-term)#metadata(term)#term-label]
|
|
// |^^^^^|^^^^^^^^^ |^^^^^^^^ |^^^^^^^^^^^^
|
|
// \_art.| | |
|
|
// \_ apply user formatting function to the term
|
|
// | |
|
|
// \_ metadata lets us label (ie makes it "labelable")
|
|
// |
|
|
// \_ i.e. <__gloss:key>, etc.
|
|
// for backlink from glossary
|
|
}
|
|
}
|
|
}
|
|
|
|
// Creates page number links back to each usage of a glossary term.
|
|
//
|
|
// This function generates a comma-separated list of page numbers, where each
|
|
// number links back to a usage of the term in the document. Duplicate page
|
|
// numbers are removed to avoid redundancy.
|
|
//
|
|
// Parameters:
|
|
// key (string): The glossary entry key
|
|
//
|
|
// Returns:
|
|
// content: Comma-separated list of linked page numbers
|
|
//
|
|
// Example output: "1, 3, 5" where each number links to the term usage on that page
|
|
//
|
|
#let __create_backlinks(key) = {
|
|
return context query(__term_label(key)) // find all reference
|
|
.map(meta => { // extract location and page number (or symbol)
|
|
let loc = meta.location()
|
|
let page = numbering(__default(loc.page-numbering(), "1"), ..counter(page).at(loc))
|
|
(loc, page)
|
|
})
|
|
.dedup(key: ((loc, page)) => page) // deduplicate by page
|
|
.map(((loc, page)) => link(loc, page)) // create links
|
|
.join(", ")
|
|
}
|
|
|
|
// Formats a term string based on the specified mode.
|
|
//
|
|
// Given mode, long-form, and short-form, this function returns the formatted
|
|
// term based on the mode. When mode is ...
|
|
//
|
|
// - "short" -> returns "short-form"
|
|
// - "long" -> returns "long-form"
|
|
// - "both" -> returns "short-form (long-form)"
|
|
//
|
|
// Parameters:
|
|
// mode (string): The mode in which to format the term. Possible values are "short", "long", or "both".
|
|
// short-form (string): The short form of the term.
|
|
// long-form (string): The long form of the term.
|
|
//
|
|
// Returns:
|
|
// string: The formatted term based on the specified mode.
|
|
//
|
|
#let __default-format-term(mode, short-form, long-form) = {
|
|
if mode == "short" {
|
|
short-form
|
|
} else if mode == "long" {
|
|
long-form
|
|
} else {
|
|
// mode assumed to be "both"
|
|
long-form + " (" + short-form + ")"
|
|
}
|
|
}
|
|
|
|
// Styles a term to control its display in a document.
|
|
//
|
|
// Parameters:
|
|
// term-body (content'ish): the unstyled output from __gls()
|
|
//
|
|
// Returns:
|
|
// content: display ready content (assumed based on `term-body`)
|
|
//
|
|
#let __default-show-term(term-body) = {
|
|
// Default: just render it like normal text
|
|
term-body
|
|
}
|
|
|
|
// Initializes the glossary system and sets up term reference handling.
|
|
//
|
|
// This function is typically used with `#show: init-glossary`. It performs
|
|
// three main tasks:
|
|
// 1. Validates and loads glossary entries into the state
|
|
// 2. Sets up reference handling to intercept and format term usage
|
|
// 3. Applies custom term formatting if provided
|
|
//
|
|
// Parameters:
|
|
// entries (dictionary): Dictionary of glossary entries where:
|
|
// - keys are term identifiers
|
|
// - values are entry dictionaries containing term details
|
|
// show-term ((content) => content): Optional function to customize term rendering
|
|
// Default: Returns term content unchanged
|
|
// term-links (boolean): If terms should be clickable links leading to glossary
|
|
// body (content): Document content to process
|
|
//
|
|
// Returns:
|
|
// content: Processed document content with glossary functionality enabled
|
|
//
|
|
// Panics:
|
|
// - If entries parameter is not a dictionary
|
|
//
|
|
#let init-glossary(
|
|
entries,
|
|
format-term: __default-format-term,
|
|
show-term: __default-show-term,
|
|
term-links: false,
|
|
body
|
|
) = context {
|
|
// Type checking
|
|
let checked-entries = (:)
|
|
for (key, entry) in entries {
|
|
let checked-key = z.parse(key, z.string(), scope: ("dictionary key",))
|
|
let checked-entry = z.parse(entry, dict-schema, scope: ("dictionary entry",))
|
|
checked-entries.insert(checked-key, checked-entry)
|
|
}
|
|
let checked-format-term = z.parse(show-term, z.function(), scope: ("format-term",))
|
|
let checked-show-term = z.parse(show-term, z.function(), scope: ("show-term",))
|
|
let checked-body = z.parse(body, z.content(), scope: ("body",))
|
|
|
|
// Process and store each glossary entry
|
|
for (key, entry) in entries {
|
|
__add_entry(key, __normalize_entry(key, entry))
|
|
// Create placeholder labels for autocompletion
|
|
[#metadata(key)#label(key)]
|
|
}
|
|
|
|
// Set up reference handling for glossary terms
|
|
show ref: r => {
|
|
let (raw_key, ..raw_modifiers) = str(r.target).split(":")
|
|
let supplement = r.supplement // used for term display overrides
|
|
|
|
// Determine if we need to swap the key and first modifier.
|
|
// Conditions for swapping:
|
|
// - The original key is "a" or "an" (case-insensitive),
|
|
// - That "key" does not correspond to an actual entry,
|
|
// - There is at least one modifier,
|
|
// - The first modifier corresponds to an existing entry key.
|
|
let can_swap = (lower(raw_key) == "a" or lower(raw_key) == "an") and not __has_entry(raw_key) and raw_modifiers.len() > 0 and __has_entry(raw_modifiers.first())
|
|
|
|
// If we can swap, use the first modifier as the key and insert "a" as the first modifier.
|
|
let (key, modifiers) = if can_swap {
|
|
(raw_modifiers.first(), ("a",) + raw_modifiers.slice(1))
|
|
} else {
|
|
(raw_key, raw_modifiers)
|
|
}
|
|
|
|
// Now see if this is an actual glossary term key
|
|
if __has_entry(key) {
|
|
// Found in dictionary, render via __gls()
|
|
__gls(key, modifiers: modifiers.map(lower), format-term: format-term, show-term: show-term, term-links: term-links, display-text: supplement)
|
|
} else {
|
|
// Not one of ours, so just pass it through
|
|
r
|
|
}
|
|
}
|
|
|
|
body
|
|
}
|
|
|
|
// Renders a complete glossary with customizable formatting and grouping.
|
|
//
|
|
// The glossary displays all used terms, optionally grouped by category, with page
|
|
// references back to term usage. The appearance is controlled by a theme that
|
|
// defines how sections, groups, and entries are formatted.
|
|
//
|
|
// Parameters:
|
|
// title (string): Glossary section title (default: "Glossary")
|
|
// theme (dictionary): Controls glossary appearance with three functions:
|
|
// - section(title, body): Renders the main glossary section
|
|
// - title: The glossary title
|
|
// - body: Content containing all groups and entries
|
|
// - group(name, index, total, body): Renders a group of related terms
|
|
// - name: Group name
|
|
// - index: Zero-based group index
|
|
// - total: Total number of groups
|
|
// - body: Content containing the group's entries
|
|
// - entry(entry, index, total): Renders a single glossary entry
|
|
// - entry: Dictionary containing:
|
|
// - short: Short form of term
|
|
// - long: Long form of term (optional)
|
|
// - description: Term description (optional)
|
|
// - label: Term's dictionary label
|
|
// - pages: Linked page numbers where term appears
|
|
// - index: Zero-based entry index within group
|
|
// - total: Total entries in group
|
|
// groups (array): Optional list of groups to include
|
|
// If empty, includes all groups
|
|
//
|
|
// Returns:
|
|
// content: Formatted glossary content
|
|
//
|
|
// Panics:
|
|
// - If a requested group doesn't exist
|
|
//
|
|
#let glossary(
|
|
title: "Glossary",
|
|
theme: theme-academic,
|
|
sort: true,
|
|
ignore-case: false,
|
|
groups: (),
|
|
show-all: false,
|
|
) = context {
|
|
// Type checking
|
|
let checked-title = z.parse(title, z.content(), scope: ("title",))
|
|
let checked-groups = z.parse(groups, groups-list-schema, scope: ("groups",))
|
|
let checked-ignore-case = z.parse(ignore-case, z.boolean(), scope: ("ignore-case",))
|
|
let checked-theme = z.parse(theme, theme-schema, scope: ("theme",))
|
|
|
|
// Collect and organize entries by group
|
|
let output = (:)
|
|
let all_entries = __gloss_entries.final()
|
|
let all_used = if not show-all {
|
|
all_entries.keys().filter(key => __is_term_ever_used(key))
|
|
} else {
|
|
all_entries.keys()
|
|
}
|
|
|
|
// Determine which groups to process
|
|
let all_groups = all_entries
|
|
.values()
|
|
.map(e => e.at("group"))
|
|
.map(g => { if g == none { "" } else { g } })
|
|
.dedup()
|
|
.sorted()
|
|
|
|
let target_groups = if checked-groups.len() == 0 {
|
|
all_groups
|
|
} else {
|
|
// Validate requested groups exist
|
|
for g in checked-groups {
|
|
if g == none {
|
|
g = ""
|
|
}
|
|
if g not in all_groups {
|
|
panic("Requested group, '" + g + "', not found.")
|
|
}
|
|
}
|
|
checked-groups
|
|
}
|
|
|
|
// Process entries group by group
|
|
for group in target_groups {
|
|
let current_entries = ()
|
|
|
|
// Collect all used entries for this group
|
|
for key in all_used {
|
|
let entry = all_entries.at(key)
|
|
if entry.at("group") == group {
|
|
current_entries.push((
|
|
short: entry.at("short"),
|
|
long: entry.at("long"),
|
|
description: entry.at("description"),
|
|
label: [#metadata(key)#__entry_label(key)],
|
|
pages: __create_backlinks(key)
|
|
))
|
|
}
|
|
}
|
|
|
|
// Add non-empty groups to output
|
|
if current_entries.len() > 0 {
|
|
group = if group == none { "" } else { group }
|
|
|
|
// sort entries by case insensitivity if requested
|
|
let sorted_entries = if sort {
|
|
current_entries
|
|
// 1. create array of tuples with (lower [if ignore-case], entry)
|
|
.map(e => { if ignore-case { (lower(e.short), e) } else { (e.short, e) } })
|
|
// 2. sort the tuples (by first element then second)
|
|
.sorted() // NOTE: sorted() is NOT language-aware
|
|
// 3. strip away the tuple's first element, leaving an array of entries
|
|
.map(t => t.last())
|
|
} else {
|
|
current_entries
|
|
}
|
|
|
|
// add entries to this group's output map
|
|
output.insert(group, sorted_entries)
|
|
}
|
|
}
|
|
|
|
// Render the glossary using the theme
|
|
let group_index = 0
|
|
|
|
[
|
|
#metadata("glossary")<glossary>
|
|
#(checked-theme.section)(
|
|
title,
|
|
for (group, entries) in output {
|
|
(checked-theme.group)(
|
|
group,
|
|
group_index,
|
|
output.len(),
|
|
for (i, entry) in entries.enumerate() {
|
|
(checked-theme.entry)(entry, i, entries.len())
|
|
}
|
|
)
|
|
group_index += 1
|
|
}
|
|
)
|
|
]
|
|
}
|