fix(glossy): glossy 0.1.1

This commit is contained in:
swaits committed 2024-10-28 16:04:59 -06:00
1 parent 9f4ab25528
commit 0cf01366eb
10 files changed
+709

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Stephen Waits <steve@waits.net>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+199
View File
@@ -0,0 +1,199 @@
# Glossy
This package provides utilities to manage and render glossaries within
documents. It includes functions to define and use glossary terms, track their
usage, and generate a glossary list with references to where terms are used in
the document.
![image of sample output](thumbnail.png)
## Motivation
Glossy is heavily inspired by
[glossarium](https://typst.app/universe/package/glossarium), with a few key
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
exports, `init-glossary` and `glossary`.
1. 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
`#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
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.
## Usage
### Import the package
```typst
#import "@preview/glossy:0.1.1": *
```
### Defining Glossary Terms
Use the `init-glossary` function to initialize glossary entries and process
document content:
```typst
#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 stylesheet 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)
// 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/).
### Using Glossary Terms in Your Document
Use glossary terms within your text using the `@reference` functionality in
typst.
For example, `@html` and `@css` are used in the following text:
```typst
In modern web development, languages like @html and @css are essential.
Now make sure I get your @tps:short reports by 2pm!
```
... which should output:
```text
In modern web development, languages like Hypertext Markup Language (HTML) and
Cascading Style Sheets (CSS) are essential.
Now make sure I get your TPS reports by 2pm!
```
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.
### Generating the Glossary
To display the glossary in your document, use the `glossary` function. Customize
the title and specify a theme if desired:
```typst
#glossary(title: "Web Development Glossary", theme: my-theme, groups: ("Web"))
```
### Themes
Customize the appearance of the glossary using themes. Each theme defines how
sections, groups, and entries are displayed.
- **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)
Example of a minimal theme:
```typst
#let my-theme = (
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 {
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,
)
)
}
)
```
## License
This project is licensed under the MIT License.
Binary file not shown.
+86
View File
@@ -0,0 +1,86 @@
// a minimal example, also listed in the README
#import "@preview/glossy:0.1.1": *
#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)
+1
View File
@@ -0,0 +1 @@
#import "src/gloss.typ": init-glossary, glossary
+325
View File
@@ -0,0 +1,325 @@
#import "./themes.typ": *
#import "./utils.typ": *
#let __gloss_entries = state("__gloss_entries", (:))
#let __gloss_used = state("__gloss_used", (:))
#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: ""),
))
}
return new-list
}
// 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)
}
// add it to the state
__gloss_entries.update(st => {
st.insert(entry.key, entry)
return st
})
}
// fetch a glossary entry from our state, or panic
#let __get_entry(key) = {
let entries = __gloss_entries.final()
if key not in entries {
panic("Glossary error. Missing key: " + 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
#let __dict_label_str(key) = {
__gloss_label_prefix + key
}
// helper to get a label with prefix 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
#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
#let __term_label(key, index) = {
label(__term_label_str(key, index))
}
// update the usage count for a given key
#let __save_term_usage(key, count) = {
__gloss_used.update(st => {
st.insert(key, count)
return st;
})
}
// the main function which emits terms used in a document
//
// handles all the modifiers:
//
// - 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
let entry = __get_entry(key)
let entry_label = __dict_label_str(key)
// Lookup and increment the count
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)
// Helper: Apply pluralization if needed
let pluralize_term = (singular, plural) => {
if "pl" in modifiers and plural != none { plural } else { singular }
}
// Helper: Capitalize term if "cap" modifier present
let capitalize_term = (term) => {
if "cap" in modifiers { upper(term.first()) + term.slice(1) } else { term }
}
// Helper: Format a term with 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)
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 }
// 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"
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
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
[#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.
#let __create_backlinks(key, count) = {
// create array of labels to link to
let labels = for i in range(count) {
(__term_label(key, i),)
}
// create arrays of locations, and page number display text
let pages = labels
.map(l => { locate(l) })
.map((loc) => { numbering(__default(loc.page-numbering(), "1"), loc.page()) })
// convert labels to links, filtering out duplicated pages
let seen = ()
let links = for i in range(labels.len()) {
let l = labels.at(i)
let p = pages.at(i)
if seen.contains(p) {
(none,)
} else {
seen.push(p)
(link(l, p),)
}
}.filter(l => l != none)
// connect links with commas and return
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.
#let init-glossary(entries, body) = context {
for entry in __normalize_entries(entries) {
__add_entry(entry)
}
// convert refs we recognize into links with labels
show ref: r => {
let (key, ..modifiers) = str(r.target).split(":")
if __has_entry(key) {
__gls(key, modifiers: modifiers)
} else {
r
}
}
body
}
// Used to print a glossary. Can customize title, theme, and/or specify groups.
//
// A theme is a dictionary with three attributes:
//
// #let my-theme = (
// section: (title, body) => {
// // how to display the glossary section and its body which will contain
// // groups, each with their entries
// },
//
// group: (name, body) => {
// // how to display a group name and its body (which will contain the
// // entries in that group)
// },
//
// 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)
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
// 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))
}
}
if cur.len() > 0 {
if g == none { g = "" }
output.insert(g, cur.sorted(key: e => e.short))
}
}
// 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?
// index for group because dictionary don't have enumerate()
let i_group = 0;
// render it using our theme
(theme.section)(
title,
// section body (all groups)
for (group, entries) in output {
(theme.group)(
group,
i_group,
output.len(),
// group body (all entries)
for (i,e) in entries.enumerate() {
(theme.entry)(e, i, entries.len())
},
)
i_group += 1
}
)
}
+47
View File
@@ -0,0 +1,47 @@
#let theme-2col = (
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 {
heading(level: 2, name)
}
body
},
entry: (entry, i, n) => {
// build our title, etc
let short-display = text(weight: "regular", entry.short)
let long-display = if entry.long == none {
[]
} else {
[#h(0.25em) -- #entry.long]
}
// build our description
let description = if entry.description == none {
[]
} else {
[: #entry.description]
}
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]
)
)
},
)
+19
View File
@@ -0,0 +1,19 @@
#let __default(val, default) = if val == none { default } else { val }
#let __pluralize(word) = {
// 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
word + "es"
} else if ends_with("y") and not ends_with_any(( "a", "e", "i", "o", "u" )) { // Handle "y" -> "ies"
word.slice(0, -1) + "ies"
} else if ends_with("f") { // Handle "f" or "fe" -> "ves"
word.slice(0, -1) + "ves"
} else if ends_with("fe") {
word.slice(0, -2) + "ves"
} else { // Default to adding "s"
word + "s"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

+11
View File
@@ -0,0 +1,11 @@
[package]
name = "glossy"
version = "0.1.1"
entrypoint = "lib.typ"
authors = ["Stephen Waits <steve@waits.net>"]
license = "MIT"
description = "A very simple glossary system with easily customizable output."
categories = ["model"]
keywords = ["glossary", "index", "dictionary", "gloss"]
repository = "https://github.com/swaits/typst-collection"
exclude = ["thumbnail.png", "examples/readme.pdf"]