diff --git a/preview/glossy/README.md b/preview/glossy/README.md index 0a5d378..bc7b5c2 100644 --- a/preview/glossy/README.md +++ b/preview/glossy/README.md @@ -16,94 +16,97 @@ different goals: 1. Provide a simple interface which allows for complete control over glossary display. To do this, `glossy`'s `#glossary()` function accepts a theme parameter. The goal here is to separate presentation and logic. -1. Simplify the user interface as much as possible. Glossy has exactly two +2. Simplify the user interface as much as possible. Glossy has exactly two exports, `init-glossary` and `glossary`. -1. Double-down on `glossy`'s excellent `@term` reference approach, completely +3. Double-down on `glossy`'s excellent `@term` reference approach, completely eliminating the need to make any calls to `gls()` and friends. -1. Mimic established patterns and best practices. For example, `glossy`'s +4. Mimic established patterns and best practices. For example, `glossy`'s `#glossary()` function is intentionally similar (in naming and parameters) to - `typst`'s built-int `#bibliography`, to the degree possible. -1. Simplify the implementation. The `glossy` code is significantly shorter and + `typst`'s built-in `#bibliography`, to the degree possible. +5. Simplify the implementation. The `glossy` code is significantly shorter and easier to understand. ## Features -- Define glossary terms with short and long forms, descriptions, and grouping. -- Automatically tracks term usage in the document, making references simple - through @labels. -- Supports modifiers to adjust how terms are displayed (capitalize, pluralize, - etc.). -- Generates a formatted glossary section with backlinks to term occurrences in - the document. -- Easily customizable themes for rendering glossary sections, groups, and - entries. +- Define glossary terms with short and long forms, descriptions, and grouping +- Automatically tracks term usage in the document through `@labels` +- Supports modifiers to adjust how terms are displayed (capitalize, pluralize, etc.) +- Generates a formatted glossary section with backlinks to term occurrences +- Customizable themes for rendering glossary sections, groups, and entries +- Automatic pluralization of terms with custom override options +- Page number references back to term usage locations ## Usage ### Import the package ```typst -#import "@preview/glossy:0.1.2": * - +#import "@preview/glossy:0.2.0": * ``` ### Defining Glossary Terms -Use the `init-glossary` function to initialize glossary entries and process -document content: +Use the `init-glossary` function to initialize glossary entries: ```typst #let myGlossary = ( - ( - key: "html", + html: ( short: "HTML", long: "Hypertext Markup Language", description: "A standard language for creating web pages", group: "Web" ), - ( - key: "css", + css: ( short: "CSS", long: "Cascading Style Sheets", description: "A stylesheet language used for describing the presentation of a document", group: "Web" ), - ( - key: "tps", + tps: ( short: "TPS", - long: "test procedure specification" - ), - // Add more entries as needed + long: "test procedure specification", + description: "A formal document describing test steps and expected results", + // Optional: Override automatic pluralization + plural: "TPSes", + longplural: "test procedure specifications" + ) ) #show: init-glossary.with(myGlossary) - -// Your document content here, where glossary terms are used. ``` -Note that you could also load glossary entries from a data file using -[#yaml()](https://typst.app/docs/reference/data-loading/yaml/), -[#json()](https://typst.app/docs/reference/data-loading/json/), or -[similar](https://typst.app/docs/reference/data-loading/). +Each glossary entry supports the following fields: + +- `short` (required): Short form of the term +- `long` (optional): Long form of the term +- `description` (optional): Term description (often a definition) +- `group` (optional): Category grouping +- `plural` (optional): Override automatic pluralization of short form +- `longplural` (optional): Override automatic pluralization of long form + +You can also load glossary entries from a data file using #yaml(), #json(), or similar. For example, the above glossary could be in this YAML file: ```yaml -- key: html, - short: HTML, - long: Hypertext Markup Language, - description: A standard language for creating web pages, +html: + short: HTML + long: Hypertext Markup Language + description: A standard language for creating web pages group: Web -- key: css - short: CSS, - long: Cascading Style Sheets, - description: A stylesheet language used for describing the presentation of a document, +css: + short: CSS + long: Cascading Style Sheets + description: A stylesheet language used for describing the presentation of a document group: Web -- key: tps, - short: TPS, +tps: + short: TPS long: test procedure specification + description: A formal document describing test steps and expected results + plural: TPSes + longplural: test procedure specifications ``` And then loaded during initialization as follows: @@ -112,115 +115,139 @@ And then loaded during initialization as follows: #show: init-glossary.with(yaml("glossary.yaml")) ``` -### Using Glossary Terms in Your Document +### Using Glossary Terms -Use glossary terms within your text using the `@reference` functionality in -typst. - -For example, `@html` and `@css` are used in the following text: +Reference glossary terms using Typst's `@reference` syntax: ```typst In modern web development, languages like @html and @css are essential. - -Now make sure I get your @tps:short reports by 2pm! +The @tps:pl need to be submitted by Friday. ``` -... which should output: +Available modifiers: -```text -In modern web development, languages like Hypertext Markup Language (HTML) and -Cascading Style Sheets (CSS) are essential. +- **cap**: Capitalizes the term +- **pl**: Uses the plural form +- **both**: Shows "Long Form (Short Form)" +- **short**: Shows only short form +- **long**: Shows only long form +- **def** or **desc**: Shows the description -Now make sure I get your TPS reports by 2pm! -``` +Modifiers can be combined with colons: -You can control how each term is displayed using the following modifiers: - -- **cap**: Capitalizes the term. -- **pl**: Uses the plural form of the term. -- **both**: Displays both the long and short forms (e.g., "Long Form (Short Form)"). -- **short**: Displays only the short form. -- **long**: Displays only the long form. - -Each modifier is separated by a colon (':') character. Examples: - -| **Input** | **Output** | -| ----------------------- | -------------------------------------------- | -| `@tps` (first use) | "technical procedure specification (TPS)" | -| `@tps` (subsequent use) | "TPS" | -| `@tps:short` | "TPS" | -| `@tps:long` | "technical procedure specification (TPS)" | -| `@tps:both` | "technical procedure specification (TPS)" | -| `@tps:long:cap` | "Technical procedure specification" | -| `@tps:long:pl` | "technical procedure specifications" | -| `@tps:short:pl` | "TPSes" | -| `@tps:both:pl:cap` | "Technical procedure specifications (TPSes)" | - -Combine modifiers in any way you like. But know that if you combine any of -`short`, `long`, and/or `both`, only one will be used. +| **Input** | **Output** | +| ------------------- | -------------------------------------------------------------- | +| `@tps` (first use) | "test procedure specification (TPS)" | +| `@tps` (subsequent) | "TPS" | +| `@tps:short` | "TPS" | +| `@tps:long` | "test procedure specification" | +| `@tps:both` | "test procedure specification (TPS)" | +| `@tps:long:cap` | "Test procedure specification" | +| `@tps:long:pl` | "test procedure specifications" | +| `@tps:short:pl` | "TPSes" | +| `@tps:both:pl:cap` | "Technical procedure specifications (TPSes)" | +| `@tps:def` | "A formal document describing test steps and expected results" | ### Generating the Glossary -To display the glossary in your document, use the `glossary` function. Customize -the title and specify a theme if desired: +Display the glossary using the `glossary()` function: ```typst -#glossary(title: "Web Development Glossary", theme: my-theme, groups: ("Web")) +#glossary( + title: "Web Development Glossary", + theme: my-theme, + groups: ("Web") // Optional: Filter to specific groups +) ``` -See more on theming below in "Glossary Themes." +### Customizing Term Display -### Customizing the Output - -#### Term usage in documents - -You can control how terms are displayed when using the `@term` reference syntax -in your document by passing a custom function to `init-glossary` in the -`show-term` parameter. - -For example, if you wanted all terms to be italicized, you could do this: +Control how terms appear in the document by providing a custom `show-term` function: ```typst -#let emph-term(body) = { emph(body) } +#let emph-term(term-body) = { emph(term-body) } -#show: init-glossary.with(myGlossary, show-term: emph-term) - -// remainder of document follows +#show: init-glossary.with( + myGlossary, + show-term: emph-term +) ``` -#### Glossary Themes +### Glossary Themes -Customize the appearance of the glossary using themes. Each theme defines how -sections, groups, and entries are displayed. +#### Included Themes -- **section**: This is a main glossary section. It contains multiple glossary groups. -- **group**: A group within the main glossary section. Contains multiple - entries. Can be an empty string for terms with no group (`""`). -- **entry**: A single entry within a group. This is the actual term, and can - include any of the fields in entry. Contains the following fields: - - `key`: The term key. (always present and unique) - - `short`: The short form of the term. (always present) - - `long`: The long form. (can be `none`) - - `description`: The description or definition. (can be `none`) - - `pages`: Pages with links to term usage in doc. (always present) +Glossy comes with several built-in themes that can be used directly or serve as examples for custom themes: -Example of a minimal theme: +##### theme-twocol + +A professional two-column layout ideal for technical documentation. Features: + +- Two-column layout for efficient space usage +- Dotted leaders to page numbers +- Clear hierarchy with optional group headings +- Compact but readable formatting +- Terms in regular weight with long forms and descriptions inline + +```typst +#glossary(theme: theme-twocol) +``` + +##### theme-basic + +A traditional single-column layout similar to book glossaries. Features: + +- Bold terms with indented content +- Clear separation between entries +- Hanging indentation for wrapped lines +- Parenthetical long forms +- Page numbers with "pp." prefix +- Simple, clean design + +```typst +#glossary(theme: theme-basic) +``` + +##### theme-compact + +A space-efficient layout perfect for technical documents or appendices. Features: + +- Reduced vertical spacing +- Smaller font sizes for secondary information +- Color-coded term components +- Grid-based alignment +- Minimal decorative elements +- Gray text for supplementary information +- Bullet separators between components + +```typst +#glossary(theme: theme-compact) +``` + +#### Custom Themes + +Customize glossary appearance by defining a theme with three functions: ```typst #let my-theme = ( + // Main glossary section section: (title, body) => { heading(level: 1, title) body }, - group: (name, i, n, body) => { - // i is the group's index, n is total number of groups - if name != "" and n > 1 { + + // Group of related terms + group: (name, index, total, body) => { + // index = group index, total = total groups + if name != "" and total > 1 { heading(level: 2, name) } body }, - entry: (entry, i, n) => { - // i is the entry's index, n is total number of entries + + // Individual glossary entry + entry: (entry, index, total) => { + // index = entry index, total = total entries in group let output = [#entry.short] if entry.long != none { output = [#output -- #entry.long] @@ -240,6 +267,14 @@ Example of a minimal theme: ) ``` +Entry fields available to themes: + +- `short`: Short form (always present) +- `long`: Long form (can be `none`) +- `description`: Term description (can be `none`) +- `label`: Term's dictionary label +- `pages`: Linked page numbers where term appears + ## License This project is licensed under the MIT License. diff --git a/preview/glossy/examples/readme.pdf b/preview/glossy/examples/readme.pdf index 8703a04..bd1de40 100644 Binary files a/preview/glossy/examples/readme.pdf and b/preview/glossy/examples/readme.pdf differ diff --git a/preview/glossy/examples/readme.typ b/preview/glossy/examples/readme.typ index 1ae581b..755baea 100644 --- a/preview/glossy/examples/readme.typ +++ b/preview/glossy/examples/readme.typ @@ -1,12 +1,22 @@ // a minimal example, also listed in the README -#import "@preview/glossy:0.1.2": * +#import "@preview/glossy:0.2.0": * #let myGlossary = ( - (key: "html", short: "HTML", long: "Hypertext Markup Language", description: "A standard language for creating web pages", group: "Web"), - (key: "css", short: "CSS", long: "Cascading Style Sheets", description: "A language used for describing the presentation of a document", group: "Web"), - (key: "tps", short: "TPS", long: "test procedure specification"), - // Add more entries as needed + html: ( + short: "HTML", + long: "Hypertext Markup Language", + description: "A standard language for creating web pages", + group: "Web"), + css: ( + short: "CSS", + long: "Cascading Style Sheets", + description: "A language used for describing the presentation of a document", + group: "Web"), + tps: ( + short: "TPS", + long: "test procedure specification", + description: "A document on how to run all the test procedures"), ) #show: init-glossary.with(myGlossary, show-term: (body) => [#emph(body)]) @@ -14,11 +24,12 @@ #set heading(numbering: "1.1") = Hello, `glossy`! - In modern web development, languages like @html and @css are essential. Now make sure I get your @tps:short reports by 2pm! +@tps:cap is defined as: @tps:def. + = Examples with modifiers #table( @@ -32,6 +43,8 @@ Now make sure I get your @tps:short reports by 2pm! [`@tps:long:pl` ], [@tps:long:pl], [`@tps:short:pl` ], [@tps:short:pl], [`@tps:both:pl:cap`], [@tps:both:pl:cap], + [`@tps:def` ], [@tps:def], + [`@tps:desc` ], [@tps:desc], ) == Using conflicting modifiers (short, long, both) @@ -82,91 +95,5 @@ which one to display. ) #glossary(theme: my-theme) -#glossary(title: "Glossary with just empty group", groups: ("",), theme: my-theme) -#glossary(title: "Glossary with just Web group", groups: ("Web",), theme: my-theme) -// a minimal example, also listed in the README - -#import "@preview/glossy:0.1.2": * - -#let myGlossary = ( - (key: "html", short: "HTML", long: "Hypertext Markup Language", description: "A standard language for creating web pages", group: "Web"), - (key: "css", short: "CSS", long: "Cascading Style Sheets", description: "A language used for describing the presentation of a document", group: "Web"), - (key: "tps", short: "TPS", long: "test procedure specification"), - // Add more entries as needed -) - -#show: init-glossary.with(myGlossary) - -#set heading(numbering: "1.1") - -= Hello, `glossy`! - -In modern web development, languages like @html and @css are essential. - -Now make sure I get your @tps:short reports by 2pm! - -= Examples with modifiers - -#table( - columns: 2, - table.header([*Input*], [*Output*]), - -[`@tps:short` ], [@tps:short], -[`@tps:long` ], [@tps:long], -[`@tps:both` ], [@tps:both], -[`@tps:long:cap` ], [@tps:long:cap], -[`@tps:long:pl` ], [@tps:long:pl], -[`@tps:short:pl` ], [@tps:short:pl], -[`@tps:both:pl:cap`], [@tps:both:pl:cap], -) - -== Using conflicting modifiers (short, long, both) - -These modifiers are semantically mutually exclusive, by they can be combined -syntactically. When multiple are used, `glossy` tries to make a smart choice on -which one to display. - -#table( - columns: 2, - table.header([*Input*], [*Output*]), - - [`@tps:short:long` ], [@tps:short:long], - [`@tps:short:both` ], [@tps:short:both], - [`@tps:long:both` ], [@tps:long:both], - [`@tps:short:long:both` ], [@tps:short:long:both], -) - -#let my-theme = ( - section: (title, body) => { - heading(level: 1, title) - body - }, - group: (name, i, n, body) => { - if name != "" and n > 1 { - heading(level: 2, name) - } - body - }, - entry: (entry, i, n) => { - // i is the entry's index, n is total number of entries - let output = [#entry.short] - if entry.long != none { - output = [#output -- #entry.long] - } - if entry.description != none { - output = [#output: #entry.description] - } - block( - grid( - columns: (auto, 1fr, auto), - output, - repeat([#h(0.25em) . #h(0.25em)]), - entry.pages, - ) - ) - } -) - -#glossary(theme: my-theme) -#glossary(title: "Glossary with just empty group", groups: ("",), theme: my-theme) -#glossary(title: "Glossary with just Web group", groups: ("Web",), theme: my-theme) +#glossary(title: "Glossary with just empty group", groups: ("",), theme: theme-basic) +#glossary(title: "Glossary with just Web group", groups: ("Web",), theme: theme-compact) diff --git a/preview/glossy/lib.typ b/preview/glossy/lib.typ index f50143e..eeff265 100644 --- a/preview/glossy/lib.typ +++ b/preview/glossy/lib.typ @@ -1 +1,2 @@ #import "src/gloss.typ": init-glossary, glossary +#import "src/themes.typ": * diff --git a/preview/glossy/src/gloss.typ b/preview/glossy/src/gloss.typ index f5306b6..092a887 100644 --- a/preview/glossy/src/gloss.typ +++ b/preview/glossy/src/gloss.typ @@ -6,223 +6,340 @@ #let __gloss_label_prefix = "__gloss:" -// given an array of dictionaries, make sure each has all the keys we'll -// reference, using default values if needed -#let __normalize_entries(entry-list) = { - // TODO: panic if key or short missing, all others optional - let new-list = () - for entry in entry-list { - let long = entry.at("long", default: none) - let longplural = entry.at("longplural", default: none) - if long != none and longplural == none { - longplural = __pluralize(long) - } - - new-list.push(( - key: entry.key, - short: entry.short, - plural: entry.at("plural", default: __pluralize(entry.short)), - long: long, - longplural: longplural, - description: entry.at("description", default: none), - group: entry.at("group", default: ""), - )) +// Normalizes a dictionary entry by ensuring all required and optional keys exist +// with appropriate default values. +// +// Parameters: +// entry (dictionary): Input dictionary containing at least 'short' and optionally +// 'long', 'plural', 'longplural', 'description', and 'group' +// +// Returns: +// dictionary: Normalized dictionary with all expected keys populated +// +// Throws: +// - If 'short' key is missing +// - If 'short' value is not a string +// - If 'short' value is an empty string +// +#let __normalize_entry(entry) = { + // Validate required fields + if not "short" in entry { + panic("Entry must contain a 'short' key") } - return new-list + if type(entry.short) != "string" { + 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)), + long: long, + longplural: entry.at("longplural", default: __pluralize(long)), + description: entry.at("description", default: none), + group: entry.at("group", default: "") + ) } -// update our state with a glossary entry -#let __add_entry(entry) = { - // make sure our final glossary state does not already have this key - if __gloss_entries.final().at(entry.key, default: false) == true { - panic("Glossary error. Duplicate key: " + entry.key) - } +// 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() +} - // add it to the state - __gloss_entries.update(st => { - st.insert(entry.key, entry) - return st +// 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 }) } -// fetch a glossary entry from our state, or panic +// 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 +// +// Throws: +// - If the key does not exist in the glossary +// #let __get_entry(key) = { - let entries = __gloss_entries.final() - if key not in entries { - panic("Glossary error. Missing key: " + key) - } + let entries = __gloss_entries.final() + if key not in entries.keys() { + panic("Glossary error: Missing key '" + key + "'") + } - entries.at(key) + entries.at(key) } -// returns true if an entry with `key` is in our glossary state -#let __has_entry(key) = { - let entries = __gloss_entries.final() - key in entries -} - -// helper to prefix a key for label/reference scoping for the dictionary entry +// Creates a prefixed label string for dictionary entries and term usage. +// +// Parameters: +// key (string): The glossary entry key +// +// Returns: +// string: The prefixed label string for dictionary entries +// #let __dict_label_str(key) = { __gloss_label_prefix + key } -// helper to get a label with prefix for the dictionary entry +// Creates a label object for dictionary entries. +// +// Parameters: +// key (string): The glossary entry key +// +// Returns: +// label: A Typst label object for the dictionary entry +// #let __dict_label(key) = { label(__dict_label_str(key)) } -// helper to prefix a key for label/reference scoping for the term use in a doc +// Creates a prefixed label string for term usage in documents. +// +// Parameters: +// key (string): The glossary entry key +// index (int): The occurrence index of the term in the document +// +// Returns: +// string: The prefixed label string for term usage +// #let __term_label_str(key, index) = { __gloss_label_prefix + key + "." + str(index) } -// helper to get a label with prefix for the term use in a doc +// Creates a label object for term usage in documents. +// +// Parameters: +// key (string): The glossary entry key +// index (int): The occurrence index of the term in the document +// +// Returns: +// label: A Typst label object for the term usage +// #let __term_label(key, index) = { label(__term_label_str(key, index)) } -// update the usage count for a given key +// Updates the term usage count in the glossary state. +// +// Parameters: +// key (string): The glossary entry key +// count (int): The number of times the term has been used +// +// Returns: +// none: Updates state as a side effect +// #let __save_term_usage(key, count) = { - __gloss_used.update(st => { - st.insert(key, count) - return st; + __gloss_used.update(state => { + state.insert(key, count) + state }) } -// the main function which emits terms used in a document +// Renders a glossary term with various formatting options. // -// handles all the modifiers: +// This function handles the display of terms in the document, managing both first +// and subsequent uses, pluralization, capitalization, and different display forms +// (short, long, or combined forms). // -// - cap: capitalize the term -// - pl: pluralize the term -// - both: emit "Long form (short form)" -// - short: emit just the short form -// - long: emit just the long form -#let __gls( - key, - modifiers: array -) = { - - // Get the term +// 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 +// +// Returns: +// content: Formatted term with appropriate labels and metadata +// +// Behavior: +// - Without modifiers, first use shows "Long form (short form)", subsequent uses show short form +// - Explicit modifiers override the default first-use behavior +// - If long form is requested but unavailable, falls back to short form +// - Maintains usage counter for term references +// +#let __gls(key, modifiers: array) = { + // Fetch the entry and prepare label let entry = __get_entry(key) let entry_label = __dict_label_str(key) - // Lookup and increment the count + // Handle description mode + if "def" in modifiers or "desc" in modifiers { + return [#entry.description] + } + + // Track term usage let entry_counter = counter(entry_label) entry_counter.step() - - // See if this is the first use let key_index = entry_counter.get().first() let first = key_index == 0 - // Count the entry as used so we can link back from glossary - __save_term_usage(entry.key, key_index + 1) + __save_term_usage(key, key_index + 1) - // Helper: Apply pluralization if needed + // Helper: Apply pluralization if requested let pluralize_term = (singular, plural) => { if "pl" in modifiers and plural != none { plural } else { singular } } - // Helper: Capitalize term if "cap" modifier present + // Helper: Apply capitalization if requested let capitalize_term = (term) => { if "cap" in modifiers { upper(term.first()) + term.slice(1) } else { term } } - // Helper: Format a term with pluralization and capitalization + // Helper: Apply both pluralization and capitalization let format_term = (term, plural) => { capitalize_term(pluralize_term(term, plural)) } - // Helper: Select and format the displayed term (long, short, or both) + // Helper: Select and format the appropriate term form let select_term = (is_long_mode, use_both) => { - // Derive pluralized and capitalized versions of the long and short forms let short_form = format_term(entry.short, entry.plural) - let long_form = if entry.long != none { format_term(entry.long, entry.longplural) } else { none } + let long_form = if entry.long != none { + format_term(entry.long, entry.longplural) + } else { + none + } - // Return logic for term selection: if use_both and long_form != none { - // 1. If "both" is requested AND we have a long form available: - // Show "Long Form (Short Form)" [#long_form (#short_form)] } else if is_long_mode and long_form != none { - // 2. If long mode is requested AND we have a long form available: - // Show just the long form [#long_form] } else { - // 3. Fallback case - use short form when: - // - "both" was requested but no long form exists - // - long mode was requested but no long form exists - // - short form was explicitly requested - // - no specific mode was requested [#short_form] } } - // Determine which form to display: "short", "long", or "both" + // Determine display mode from modifiers let is_both = "both" in modifiers let is_long = "long" in modifiers and not is_both let is_short = "short" in modifiers and not is_both and not is_long context { - // Final display logic + // Generate final display based on modifiers or default behavior let display = if is_both or is_long or is_short { - // User requested specific behavior via modifiers select_term(is_long, is_both) } else { - // Default behavior: show "both" on first use, else "short" select_term(false, first) } - // TODO: figure out how to link to __dict_label(key) if the glossary exists - // NOTE: this is a low priority, I don't think it's that important or useful. - - // Emit with labels for this instance of the term usage + // Emit term with metadata and usage label [#display#metadata(display)#__term_label(key, key_index)] } } -// Create all the backlinks to term uses in a doc, in the form of page numbers -// linked. Used when emitting a glossary, so its page numbers can link back to -// the uses. +// 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 +// count (int): Number of times the term appears in the document +// +// 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, count) = { - // create array of labels to link to + // Generate labels for each term usage let labels = for i in range(count) { (__term_label(key, i),) } - // create arrays of locations, and page number display text + // Get page numbers for each usage let pages = labels - .map(l => { locate(l) }) - .map((loc) => { numbering(__default(loc.page-numbering(), "1"), loc.page()) }) + .map(l => locate(l)) + .map(loc => numbering( + __default(loc.page-numbering(), "1"), + loc.page() + )) - // convert labels to links, filtering out duplicated pages + // Create links, excluding duplicate page numbers let seen = () let links = for i in range(labels.len()) { - let l = labels.at(i) - let p = pages.at(i) - if seen.contains(p) { + let label = labels.at(i) + let page = pages.at(i) + + if seen.contains(page) { (none,) } else { - seen.push(p) - (link(l, p),) + seen.push(page) + (link(label, page),) } }.filter(l => l != none) - // connect links with commas and return + // Join links with commas links.join(", ") } -// Main wrapper (usually used in a `#show: init-glossary`) which loads the -// entries passed in into our state. Furthermore, hooks into references so that -// we can intercept term usage in a doc and label them appropriately. +// Initializes the glossary system and sets up term reference handling. // -// Pass a function in `show-term(term-body)` to control how you display terms. -#let init-glossary(entries, show-term: (term-body) => term-body, body) = context { - for entry in __normalize_entries(entries) { - __add_entry(entry) +// 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 +// body (content): Document content to process +// +// Returns: +// content: Processed document content with glossary functionality enabled +// +// Throws: +// - If entries parameter is not a dictionary +// +#let init-glossary( + entries, + show-term: (term-body) => term-body, + body +) = context { + // Validate entries parameter type + if type(entries) != dictionary { + panic("entries must be a dictionary, instead got: " + repr(entries)) } - // convert refs we recognize into links with labels + // Process and store each glossary entry + for (key, entry) in entries { + __add_entry(key, __normalize_entry(entry)) + } + + // Set up reference handling for glossary terms show ref: r => { let (key, ..modifiers) = str(r.target).split(":") if __has_entry(key) { @@ -235,93 +352,113 @@ body } -// Used to print a glossary. Can customize title, theme, and/or specify groups. +// Renders a complete glossary with customizable formatting and grouping. // -// A theme is a dictionary with three attributes: +// 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. // -// #let my-theme = ( -// section: (title, body) => { -// // how to display the glossary section and its body which will contain -// // groups, each with their entries -// }, +// 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 // -// group: (name, body) => { -// // how to display a group name and its body (which will contain the -// // entries in that group) -// }, +// Returns: +// content: Formatted glossary content // -// entry: (entry, i, n) => { -// // how to display a single entry, along with its index and total count -// } -// } - -#let glossary(title: "Glossary", theme: theme-2col, groups: ()) = context { - // our output is a map of group name to array of entries (each entry is a map) +// Throws: +// - If a requested group doesn't exist +// +#let glossary( + title: "Glossary", + theme: theme-twocol, + groups: () +) = context { + // Collect and organize entries by group let output = (:) - - // pull in entire dictionary let all_entries = __gloss_entries.final() - - // filter down to just what we used let all_used = __gloss_used.final() - // TODO: what about entries with no group? Or group == "" or == none + // Determine which groups to process + let all_groups = all_entries + .values() + .map(e => e.at("group")) + .dedup() + .sorted() - // get all groups - let all_groups = all_entries.values().map(e => e.at("group")).dedup().sorted() - let groups = if groups.len() == 0 { all_groups } else { groups } - - // make sure requested groups are legit - for g in groups { - if g not in all_groups { - panic("Requested group not found: " + g) - } - } - - // iterate one group at a time - for g in groups { - // collect used entries in this group - let cur = () - for (key, count) in all_used { - let e = all_entries.at(key) - if e.at("group") == g { - // this term is both in this group and was used, add to our output - let short = e.at("short") - let long = e.at("long") - let description = e.at("description") - let label = [#metadata(key)#__dict_label(key)] - let pages = __create_backlinks(key, count) - cur.push((short: short, long: long, description: description, label: label, pages: pages)) + let target_groups = if groups.len() == 0 { + all_groups + } else { + // Validate requested groups exist + for g in groups { + if g not in all_groups { + panic("Requested group not found: " + g) } } - if cur.len() > 0 { - if g == none { g = "" } - output.insert(g, cur.sorted(key: e => e.short)) - } + groups } - // TODO: rendering for a) just one group, or b) the default group (ie terms - // with no group)?? -- in this case should we just not use the theme.group() - // function? + // Process entries group by group + for group in target_groups { + let current_entries = () - // index for group because dictionary don't have enumerate() - let i_group = 0; + // Collect all used entries for this group + for (key, count) 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)#__dict_label(key)], + pages: __create_backlinks(key, count) + )) + } + } + + // Add non-empty groups to output + if current_entries.len() > 0 { + group = if group == none { "" } else { group } + output.insert( + group, + current_entries.sorted(key: e => e.short) + ) + } +} + + // Render the glossary using the theme + let group_index = 0 - // render it using our theme (theme.section)( title, - // section body (all groups) for (group, entries) in output { (theme.group)( group, - i_group, + group_index, output.len(), - // group body (all entries) - for (i,e) in entries.enumerate() { - (theme.entry)(e, i, entries.len()) - }, + for (i, entry) in entries.enumerate() { + (theme.entry)(entry, i, entries.len()) + } ) - i_group += 1 + group_index += 1 } ) } diff --git a/preview/glossy/src/themes.typ b/preview/glossy/src/themes.typ index 2f61ee1..7775002 100644 --- a/preview/glossy/src/themes.typ +++ b/preview/glossy/src/themes.typ @@ -1,19 +1,46 @@ -#let theme-2col = ( +// Theme definition for a two-column glossary layout with hierarchical sections +// +// This theme provides formatting for three levels of glossary content: +// 1. Section: The overall glossary container +// 2. Groups: Optional categorization of related terms +// 3. Entries: Individual glossary terms and their definitions +// +#let theme-twocol = ( + // Renders the main glossary section as a two-column layout + // Parameters: + // title: The glossary section title + // body: Content containing all groups and entries section: (title, body) => { set par.line(numbering: none) heading(level: 1, title) columns(2, body) }, - group: (name, i, n, body) => { - if name != "" and n > 1 { + // Renders a group of related glossary terms + // Parameters: + // name: Group name (empty string for ungrouped terms) + // index: Zero-based group index + // total: Total number of groups + // body: Content containing the group's entries + group: (name, index, total, body) => { + if name != "" and total > 1 { heading(level: 2, name) } body }, - entry: (entry, i, n) => { - // build our title, etc + // Renders a single glossary entry with term, definition, and page references + // Parameters: + // entry: Dictionary containing term data: + // - 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 + entry: (entry, index, total) => { + // Format the short form and optional long form of the term let short-display = text(weight: "regular", entry.short) let long-display = if entry.long == none { [] @@ -21,27 +48,189 @@ [#h(0.25em) -- #entry.long] } - // build our description + // Format the optional description let description = if entry.description == none { [] } else { [: #entry.description] } + // Render the complete entry with dotted leader line to page numbers text( size: 0.75em, weight: "light", grid( columns: (auto,auto,auto,1fr,1em,auto), align: (left, left, left, center, center, right), - [#short-display#entry.label], - [#long-display], - [#description], - [#repeat(h(0.25em) + "." + h(0.25em))], - [ . ], - [#entry.pages] + [#short-display#entry.label], // Term with label + [#long-display], // Long form (if any) + [#description], // Description (if any) + [#repeat(h(0.25em) + "." + h(0.25em))], // Dotted leader line + [ . ], // Final dot + [#entry.pages] // Page references ) ) }, ) +// Theme definition for a basic single-column glossary layout +// +// This theme provides a traditional glossary format with: +// 1. Bold terms +// 2. Indented descriptions +// 3. Simple page references +// 4. Optional group headings +// +#let theme-basic = ( + // Renders the main glossary section as a single column + // Parameters: + // title: The glossary section title + // body: Content containing all groups and entries + section: (title, body) => { + heading(level: 1, title) + body + }, + + // Renders a group of related glossary terms + // Parameters: + // name: Group name (empty string for ungrouped terms) + // index: Zero-based group index + // total: Total number of groups + // body: Content containing the group's entries + group: (name, index, total, body) => { + if name != "" and total > 1 { + heading(level: 2, name) + } + body + }, + + // Renders a single glossary entry with term, definition, and page references + // Parameters: + // entry: Dictionary containing term data: + // - 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 + entry: (entry, index, total) => { + // Format the term parts + let term = text(weight: "bold", entry.short) + let long-form = if entry.long == none { + [] + } else { + [, #entry.long] + } + + // Format the description with proper spacing + let description = if entry.description == none { + [] + } else { + [: #entry.description] + } + + // Create the complete entry with hanging indent + block( + spacing: 0.5em, + pad( + left: 1em, + bottom: 0.5em, + block( + [#term#entry.label#long-form#description #h(1em) (pp. #entry.pages)] + ) + ) + ) + }, +) + +// Theme definition for a compact glossary layout optimized for space efficiency +// +// This theme provides a dense but readable format with: +// 1. Terms and definitions on same line +// 2. Smaller font size +// 3. Minimal vertical spacing +// 4. Left-aligned groups for quick scanning +// 5. Condensed page references +// +#let theme-compact = ( + // Renders the main glossary section with minimal spacing + // Parameters: + // title: The glossary section title + // body: Content containing all groups and entries + section: (title, body) => { + set par(leading: 0.65em) + heading(level: 1, outlined: false, text(weight: "bold", size: 1.1em, title)) + body + }, + + // Renders a group of related glossary terms + // Parameters: + // name: Group name (empty string for ungrouped terms) + // index: Zero-based group index + // total: Total number of groups + // body: Content containing the group's entries + group: (name, index, total, body) => { + if name != "" and total > 1 { + block( + spacing: 0.5em, + pad( + top: 0.5em, + text(weight: "bold", size: 0.9em, name) + ) + ) + } + body + }, + + // Renders a single glossary entry with term, definition, and page references + // Parameters: + // entry: Dictionary containing term data: + // - 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 + entry: (entry, index, total) => { + // Format term components with minimal spacing + let term = text( + size: 0.65em, + weight: "medium", + fill: gray.darken(60%), + entry.short + ) + + let long-form = if entry.long == none { + [] + } else { + text(size: 0.65em, fill: gray.darken(20%), [ (#entry.long)]) + } + + let description = if entry.description == none { + [] + } else { + text(size: 0.65em, [ยท #entry.description]) + } + + // Create the complete entry with tight spacing + block( + spacing: 0.4em, + grid( + columns: (auto, 1fr, auto), + gutter: 0.5em, + // Term and description column + box[#term#entry.label#long-form #description], + // Dots.... + repeat(h(0.25em) + text(fill: gray, ".") + h(0.25em)), + // Page references with smaller font + text( + size: 0.6em, + fill: gray.darken(20%), + entry.pages + ) + ) + ) + }, +) diff --git a/preview/glossy/src/utils.typ b/preview/glossy/src/utils.typ index 9bc45bc..0acab16 100644 --- a/preview/glossy/src/utils.typ +++ b/preview/glossy/src/utils.typ @@ -1,19 +1,38 @@ +// Returns the provided value if it is not none, otherwise returns the default value +// Parameters: +// val: The value to check +// default: The fallback value to use if val is none +// Returns: val if not none, otherwise default #let __default(val, default) = if val == none { default } else { val } +// Converts an English word to its plural form following standard English pluralization rules +// Parameters: +// word: The singular word to pluralize +// Returns: The pluralized form of the word, or none if input is none #let __pluralize(word) = { + // early exit with invalid input + if word == none { + return none + } + // Define helper functions for checking endings let ends_with = (suffix) => lower(word).ends-with(suffix) let ends_with_any = (suffixes) => suffixes.any((suffix) => ends_with(suffix)) - if ends_with_any(( "s", "x", "z", "sh", "ch" )) { // Handle special cases for "es" endings + // Handle special cases for "es" endings (s, x, z, sh, ch) + if ends_with_any(( "s", "x", "z", "sh", "ch" )) { word + "es" - } else if ends_with("y") and not ends_with_any(( "a", "e", "i", "o", "u" )) { // Handle "y" -> "ies" + } else if ends_with("y") and not ends_with_any(( "a", "e", "i", "o", "u" )) { + // Convert y to ies when y follows a consonant word.slice(0, -1) + "ies" - } else if ends_with("f") { // Handle "f" or "fe" -> "ves" + } else if ends_with("f") { + // Convert f to ves word.slice(0, -1) + "ves" } else if ends_with("fe") { + // Convert fe to ves word.slice(0, -2) + "ves" - } else { // Default to adding "s" + } else { + // Default case: just add s word + "s" } } diff --git a/preview/glossy/thumbnail.png b/preview/glossy/thumbnail.png index 503272e..7635045 100644 Binary files a/preview/glossy/thumbnail.png and b/preview/glossy/thumbnail.png differ diff --git a/preview/glossy/typst.toml b/preview/glossy/typst.toml index d858aa9..c68c503 100644 --- a/preview/glossy/typst.toml +++ b/preview/glossy/typst.toml @@ -1,6 +1,6 @@ [package] name = "glossy" -version = "0.1.0" +version = "0.2.0" entrypoint = "lib.typ" authors = ["Stephen Waits "] license = "MIT"