diff --git a/glossy/README.md b/glossy/README.md index 5ed11da..429fbbc 100644 --- a/glossy/README.md +++ b/glossy/README.md @@ -79,7 +79,9 @@ Use the `init-glossary` function to initialize glossary entries: Each glossary entry supports the following fields: - `short` (required): Short form of the term +- `article` (optional): Article for use with the short form (i.e. "a", "an") - `long` (optional): Long form of the term +- `longarticle` (optional): Article for use with the long form (i.e. "a", "an") - `description` (optional): Term description (often a definition) - `group` (optional): Category grouping - `plural` (optional): Override automatic pluralization of short form @@ -96,7 +98,9 @@ For example, the above glossary could be in this YAML file: ```yaml html: short: HTML + article: an long: Hypertext Markup Language + longarticle: a description: A standard language for creating web pages group: Web @@ -139,6 +143,8 @@ Available modifiers: - **short**: Shows only short form - **long**: Shows only long form - **def** or **desc**: Shows the description +- **a** or **an**: Include the article (`an` is just an alias of `a`, they're + equivalent) Modifiers can be combined with colons: @@ -155,6 +161,25 @@ Modifiers can be combined with colons: | `@tps:both:pl:cap` | "Technical procedure specifications (TPSes)" | | `@tps:def` | "A formal document describing test steps and expected results" | +The `a`/`an` modifier is special because it can either precede or follow the +term's key. + +For the English language, you don't need to define `article` and `longarticle` +in most cases. Glossy makes a decent attempt at computing those automatically. + +For example: + +| **Input** | **Output** | +| --------------------- | ---------------------------------------------- | +| `@a:tps`(first use) | "a test procedure specification (TPS)" | +| `@tps:an`(subsequent) | "a TPS" | +| `@an:tps:long:cap` | "A test procedure specification" | +| `@tps:long:an` | "a test procedure specifications" | +| `@tps:a:both:cap` | "A technical procedure specifications (TPSes)" | + +Note that the `a`/`an` (article) modifier cannot be combined with the `pl` +(plural) modifier. + ### Generating the Glossary Display the glossary using the `glossary()` function: diff --git a/glossy/src/gloss.typ b/glossy/src/gloss.typ index bd52468..06b207d 100644 --- a/glossy/src/gloss.typ +++ b/glossy/src/gloss.typ @@ -53,8 +53,10 @@ ( 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: "") ) @@ -176,95 +178,203 @@ // Renders a glossary term with various formatting options. // -// 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). -// // Parameters: -// key (string): The glossary entry key to render +// 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 +// - "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). +// show-term (function): A function that renders the chosen term. // // 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 +// - 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. // -#let __gls(key, modifiers: array) = { - // Fetch the entry and prepare label +// 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, show-term: function) = { + // --------------------------------------------------------------------------- + // 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.") + } + + // --------------------------------------------------------------------------- + // Retrieve the glossary entry and its label + // --------------------------------------------------------------------------- let entry = __get_entry(key) let entry_label = __dict_label_str(key) - // Handle description mode + // --------------------------------------------------------------------------- + // If "def" or "desc" modifier is present, show the entry's description immediately + // --------------------------------------------------------------------------- if "def" in modifiers or "desc" in modifiers { - return [#entry.description] + if entry.description == none { + panic("Use of 'def'/'desc' requires a description be defined.") + } + return show-term([#entry.description]) } - // Track term usage + // --------------------------------------------------------------------------- + // Manage term usage counting and determine if it's the first reference + // --------------------------------------------------------------------------- let entry_counter = counter(entry_label) entry_counter.step() let key_index = entry_counter.get().first() - let first = key_index == 0 + let is_first_use = key_index == 0 + // --------------------------------------------------------------------------- + // Record another usage of this term (increment the usage counter) + // --------------------------------------------------------------------------- __save_term_usage(key, key_index + 1) - // Helper: Apply pluralization if requested + // --------------------------------------------------------------------------- + // 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 } - } - - // Helper: Apply capitalization if requested - let capitalize_term = (term) => { - if "cap" in modifiers { upper(term.first()) + term.slice(1) } else { term } - } - - // Helper: Apply both pluralization and capitalization - let format_term = (term, plural) => { - capitalize_term(pluralize_term(term, plural)) - } - - // Helper: Select and format the appropriate term form - let select_term = (is_long_mode, use_both) => { - let short_form = format_term(entry.short, entry.plural) - let long_form = if entry.long != none { - format_term(entry.long, entry.longplural) + if "pl" in modifiers and plural != none { + plural } else { - none - } - - if use_both and long_form != none { - [#long_form (#short_form)] - } else if is_long_mode and long_form != none { - [#long_form] - } else { - [#short_form] + singular } } - // 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 + // 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_term(mode): Returns the appropriate term string based on the mode. + // mode is one of "short", "long", or "both". + // - "short": Returns the short form. + // - "long": Returns the long form. + // - "both": Returns "Long form (short form)". + // We rely on prior logic to ensure that when mode = "both" or "long", a long form is available. + let get_term = (mode) => { + let short_form = pluralize_term(entry.short, entry.plural) + let long_form = pluralize_term(entry.long, entry.longplural) // none safe here + if mode == "short" { + // Just the short form. + short_form + } else if mode == "long" { + // Just the long form. + long_form + } else { + // mode == "both": "Long form (short form)". + long_form + " (" + short_form + ")" + } + } + + // 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) => { + 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 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) + + // Get the article and term for the chosen mode, then apply capitalization if requested + let (article, term) = capitalize_term(get_article(mode), get_term(mode)) + + // --------------------------------------------------------------------------- + // Construct and return the final output + // --------------------------------------------------------------------------- context { - // Generate final display based on modifiers or default behavior - let display = if is_both or is_long or is_short { - select_term(is_long, is_both) - } else { - select_term(false, first) - } - - // Emit term with metadata and usage label - [#display#metadata(display)#__term_label(key, key_index)] + [#article#show-term(term)#metadata(term)#__term_label(key, key_index)] + // |^^^^^^^|^^^^^^^^^ |^^^^^^^^ |^^^^^^^^^^^^ + // \_art. | | | + // \_ apply user formatting function to the term + // | | + // \_ metadata lets us label (ie it's "labelable") + // | + // \_ i.e. <__gloss.key.0>, etc. + // for backlink from glossary } } @@ -352,10 +462,29 @@ // Set up reference handling for glossary terms show ref: r => { - let (key, ..modifiers) = str(r.target).split(":") - if __has_entry(key) { - show-term(__gls(key, modifiers: modifiers)) + let (raw_key, ..raw_modifiers) = str(r.target).split(":") + + // 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), show-term: show-term) + } else { + // Not one of ours, so just pass it through r } } diff --git a/glossy/src/utils.typ b/glossy/src/utils.typ index fbba69a..eda1e60 100644 --- a/glossy/src/utils.typ +++ b/glossy/src/utils.typ @@ -134,3 +134,128 @@ word + "s" } } + +// Determines the appropriate indefinite article ("a" or "an") for a given English word. +// +// Heuristic rules: +// 1. If word looks like an acronym (all uppercase or letters separated by dots): +// - Use "an" if the first letter's name starts with a vowel sound, e.g. A (ay), E (ee), F (eff), H (aitch), I (eye), L (el), M (em), N (en), O (oh), R (ar), S (ess), X (ex). +// - Otherwise "a". +// +// 2. Otherwise, lowercase the word and apply standard rules: +// - Starts with a vowel (a, e, i, o): "an". +// - Starts with "u": +// * If it begins with "uni", "use", "user", or "eu" (e.g. "university", "use", "Europe"), we treat it as a "yoo" sound -> "a". +// * Otherwise -> "an". +// - Special silent 'h' words: if it starts with "hour", "honest", "honor", "honour", or other, use "an". +// Otherwise if starts with 'h', use "a". +// - Default: "a". +// +// Notes: +// - This is a best-effort heuristic and won't be perfect. +// - These are for singular forms. If plural form is requested alongside +// articles, the caller should panic before calling this function. +// +// Returns: +// "a" or "an" +// +// Example: +// __determine_article("RF") -> "an" (Ar-Ef) +// __determine_article("radio") -> "a" +// __determine_article("hour") -> "an" +// __determine_article("honest") -> "an" +// __determine_article("university") -> "a" +// __determine_article("umbrella") -> "an" +// __determine_article("Europe") -> "a" (because we assume "yoo-ruhp") +#let __determine_article(word) = { + // Check input + if word == none or word.trim() == "" { + // If invalid, default to none + return none + } + + // Normalize the word + let upper_word = word.trim() + let lower_word = lower(upper_word) + + // When these letters are spelled out, they get an "an" (i.e. "an RFC") + let acronym_vowels = ( + "A", // "ay" + "E", // "ee" + "F", // "eff" + "H", // "aitch" + "I", // "eye" + "L", // "el" + "M", // "em" + "N", // "en" + "O", // "oh" + "R", // "ar" + "S", // "ess" + "X", // "ex" + ) + + // Helper to detect if word looks like an acronym + let is_acronym = { + // All uppercase or letters separated by non-alphabetical chars + let cleaned = upper_word.replace(".", "").replace("-", "") + cleaned.codepoints().all(c => c == upper(c)) and cleaned.len() > 0 + } + + if is_acronym { + // Use acronym rules + let first_char = upper_word.slice(0,1) + if acronym_vowels.contains(first_char) { + "an" + } else { + "a" + } + } else { + // Non-acronym rules + let first_char = lower_word.slice(0,1) + + // Special cases for words matching a set of known 'silent h' prefixes + let silent_h_words = ( + "heir", // "air" + "herb", // "erb" (in American English) + "homage", // "oh-mij" or "ah-mij" + "honest", // "on-est" + "honor", // "on-or" (American English) + "honour", // "on-our" (British English) + "hour", // "our" + ) + if silent_h_words.any(w => lower_word.starts-with(w)) { + return "an" + } + + // Words starting with 'eu', which has the 'yoo' sound + if lower_word.starts-with("eu") { + return "a" + } + + // Words starting with 'u', that get the 'yoo' sound + if first_char == "u" { + let yoo_sounds = ( + "ubi", // ubiquitous + "uni", // unicycle, uniform, universe, university, unilateral, unique, union, etc. + "usa", // usage, usability + "use", // use, useful, useless + "usu", // usual, usually + "usur", // usurp + "ute", // utensil, uterus + "uti", // utility, utilize, utilization + "uto", // utopia, utopian + ) + if yoo_sounds.any(w => lower_word.starts-with(w)) { + return "a" + } + } + + // Vowel start + if "aeiou".contains(first_char) { + return "an" + } + + // the default case, plus 'h' words not in the silent list + "a" + } +} diff --git a/glossy/tests/demo/ref/1.png b/glossy/tests/demo/ref/1.png index 84a3c89..1e6a9d9 100644 Binary files a/glossy/tests/demo/ref/1.png and b/glossy/tests/demo/ref/1.png differ diff --git a/glossy/tests/demo/test.typ b/glossy/tests/demo/test.typ index b347f4f..961be3a 100644 --- a/glossy/tests/demo/test.typ +++ b/glossy/tests/demo/test.typ @@ -17,17 +17,18 @@ short: "TPS", long: "test procedure specification", description: "A document on how to run all the test procedures"), + unused: "Unused term, which shouldn't print in the glossary." ) #show: init-glossary.with(myGlossary, show-term: (body) => [#emph(body)]) #set heading(numbering: "1.1") -#set page(numbering: "1") +#set page(height: auto, width: 6.5in, margin: 1em, numbering: "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! +Now make sure I get @tps:a:SHORT report by 2pm! @an:tps:cap is very important!!! @tps:cap is defined as: @tps:def. @@ -48,6 +49,31 @@ Now make sure I get your @tps:short reports by 2pm! [`@tps:desc` ], [@tps:desc], ) +== Adding articles via `a`/`an` modifiers + +You can add an article to your term by adding the `a` or `an` modifiers (they're +equivalent, just both available as a convenience). These modifiers are special +in that they can precede the term's key, like `@a:key`, or follow the key like +the other modifiers. + +Default English modifiers are chosen unless the `article` and/or `longarticle` +keys are defined for the given entry. + +Articles are incompatible with the `pl` (plural) modifier. + +#table( + columns: 2, + table.header([*Input*], [*Output*]), + + [`@a:tps:short` ], [@a:tps:short], + [`@a:tps:long` ], [@a:tps:long], + [`@an:tps:both` ], [@an:tps:both], + [`@an:tps:long:cap`], [@an:tps:long:cap], + [`@tps:a:long` ], [@tps:a:long], + [`@tps:a:short` ], [@tps:a:short], + [`@tps:an:both:cap`], [@tps:an:both:cap], +) + == Using conflicting modifiers (short, long, both) These modifiers are semantically mutually exclusive, by they can be combined diff --git a/glossy/tests/determine-article/ref/1.png b/glossy/tests/determine-article/ref/1.png new file mode 100644 index 0000000..d16c201 Binary files /dev/null and b/glossy/tests/determine-article/ref/1.png differ diff --git a/glossy/tests/determine-article/test.typ b/glossy/tests/determine-article/test.typ new file mode 100644 index 0000000..6440e4c --- /dev/null +++ b/glossy/tests/determine-article/test.typ @@ -0,0 +1,289 @@ +#import "/src/utils.typ": * + +#let tests = ( + // Main tests (covers most of English language) + ("Europe","a"), + ("European","a"), + ("Hannah","a"), + ("T-shirt","a"), + ("Xerox","a"), + ("Zebra","a"), + ("aardvark","an"), + ("apple","an"), + ("boat","a"), + ("car","a"), + ("cat","a"), + ("chair","a"), + ("dog","a"), + ("egg","an"), + ("elephant","an"), + ("emu","an"), + ("eucalyptus","a"), + ("eulogy","a"), + ("euphemism","a"), + ("exhausted","an"), + ("ghost","a"), + ("hat","a"), + ("heir","an"), + ("heiress","an"), + ("herb","an"), + ("herbal","an"), + ("historic","a"), + ("history","a"), + ("homage","an"), + ("honest","an"), + ("honestly","an"), + ("honor","an"), + ("honorary","an"), + ("honour","an"), + ("horse","a"), + ("hour","an"), + ("hourglass","an"), + ("house","a"), + ("iris","an"), + ("orange","an"), + ("owl","an"), + ("table","a"), + ("ugly","an"), + ("umbrella","an"), + ("umpire","an"), + ("unicorn","a"), + ("unique","a"), + ("university","a"), + ("urn","an"), + ("usage","a"), + ("user","a"), + ("utensil","a"), + + // Acronym tests + ("ASCII","an"), + ("CFO","a"), + ("F.A.Q.","an"), + ("FBI","an"), + ("GIF","a"), + ("HIV","an"), + ("JPEG","a"), + ("LED","an"), + ("MRI","an"), + ("NATO","an"), + ("PNG","a"), + ("Q&A","a"), + ("RF","an"), + ("RNG","an"), + ("SAS","an"), + ("SMS","an"), + ("SOS","an"), + ("SQL","an"), + ("UFO","a"), + ("URL","a"), + ("VCR","a"), + ("WWW","a"), + ("X.Y.Z.","an"), + ("YAML","a"), + + // Extra tests (courtesy of LLM) + + // Common English words starting with vowels + ("idea","an"), + ("island","an"), + ("offer","an"), + ("option","an"), + ("operation","an"), + ("appletree","an"), + ("iguana","an"), + ("octopus","an"), + ("atom","an"), + ("item","an"), + + // Words starting with vowel but "yoo" sound (thus 'a') + ("universe","a"), + ("unicycle","a"), + ("uniform","a"), + ("union","a"), + ("united","a"), + ("unique","a"), // already tested, reaffirmed + ("usual","a"), + ("useful","a"), + ("utility","a"), + ("utopia","a"), + + // More common vowel words with a/an + ("umbrella","an"), // repeated but reaffirming + ("eggplant","an"), + ("insect","an"), + ("eleven","an"), + ("olive","an"), + ("impulse","an"), + ("ox","an"), + ("aroma","an"), + ("ear","an"), + ("ocean","an"), + + // Words starting with 'h' but pronounced, so 'a' + ("house","a"), // repeated for emphasis + ("hammer","a"), + ("helmet","a"), + ("history","a"), // repeated + ("hero","a"), + ("hill","a"), + ("hobby","a"), + ("holiday","a"), + ("harmony","a"), + ("hover","a"), + + // More silent 'h' words (already in set, but variations) + ("honorific","an"), + ("honorarium","an"), + ("honesties","an"), + ("hourly","an"), + ("heirloom","an"), + + // Words starting with 'e' + 'u' combo with "yoo" sound + ("Europe","a"), // repeated + ("European","a"), // repeated + ("euphoric","a"), + ("eureka","a"), + ("euphemistic","a"), + ("euphonious","a"), + ("eucalyptus","a"), // repeated + ("euthanasia","a"), + ("eugenics","a"), + ("Eurasia","a"), + + // Common consonant-start words + ("book","a"), + ("pen","a"), + ("fork","a"), + ("spoon","a"), + ("knife","a"), + ("ball","a"), + ("tree","a"), + ("river","a"), + ("mountain","a"), + ("cloud","a"), + + // More vowel-start words + ("ostrich","an"), + ("amphibian","an"), + ("eleven","an"), // repeated + ("issue","an"), + ("incense","an"), + ("antenna","an"), + ("echo","an"), + ("aisle","an"), + ("oracle","an"), + ("overcoat","an"), + + // More acronym/initialism tests: + ("ATM","an"), // A = 'ay' + ("EPA","an"), // E = 'ee' + ("FAQ","an"), // F = 'eff', repeated as F.A.Q. + ("HTML","an"), // H = 'aitch' + ("IBM","an"), // I = 'eye' + ("LAN","an"), // L = 'el' + ("NFL","an"), // N = 'en' + ("OSHA","an"), // O = 'oh' + ("RAM","an"), // R = 'ar' + ("SMS","an"), // repeated, S = 'ess' + + // Acronyms starting with consonant sound letters + ("GIF","a"), // G = 'jee', repeated + ("CFO","a"), // C = 'see', repeated (consonant sound 's') + ("CEO","a"), // C = 'see' + ("DNA","a"), // D = 'dee' starts with 'd' consonant sound + ("PGA","a"), // P = 'pee' starts with 'p' consonant sound + ("QVC","a"), // Q = 'kyoo' starts with 'k' consonant sound + ("TNT","a"), // T = 'tee' starts with 't' consonant sound + ("UPS","a"), // U = 'yoo' sound, hence 'a' + ("VIP","a"), // V = 'vee' starts with 'v' consonant sound + ("WIPO","a"), // W = 'double-u' starts with 'd' consonant sound + + // More tricky 'u' words with 'yoo' sound for 'a' + ("unify","a"), + ("unilateral","a"), + ("ubiquitous","a"), // 'yoo-bik...' + ("usurp","a"), + ("usuary","a"), // 'yoo-zhoo-ary' (rare, but treat like usual) + ("uterus","a"), // 'yoo-terus' + ("usher","an"), // 'uh-sher' vowel sound + ("ugly","an"), // repeated, 'uh-glee' + ("upper","an"), // 'uh-pur' (vowel sound but actually 'uh' sound = 'an'? Actually "upper" starts with a simple vowel sound 'uh', so should it be 'an'? It's commonly "an upper hand". + ("update","an"), // 'up-date' starts with 'uh' vowel sound → 'an update' normally. Actually we say "an update" commonly. Correcting to 'an': + + // Words starting with 'u' that actually take "an" + // (because they start with a vowel sound 'uh'): + ("under","an"), + ("understand","an"), + ("unusual","an"), + ("unreal","an"), + ("unemployed","an"), + ("unequal","an"), + ("unopened","an"), + ("unarmed","an"), + ("uneasy","an"), + ("united","a"), // 'yoo-nited' is 'a united front' + ("universal","a"), + ("umbrella","an"), // repeated + ("upset","an"), // 'uh-pset' vowel start + + // More vowel words to ensure coverage: + ("animal","an"), + ("image","an"), + ("order","an"), + ("arrow","an"), + ("ace","an"), + ("acre","an"), + ("agent","an"), + ("artist","an"), + ("idea","an"), // repeated + ("aim","an"), + ("item","an"), // repeated + ("isotope","an"), + ("opera","an"), + + // Words starting with vowels but pronounced with consonant sound 'y' → 'a' + ("euphemistic","a"), // repeated + ("euphoric","a"), + ("eulogy","a"), // repeated + ("Europe","a"), // repeated + ("European","a"), // repeated + ("eugenics","a"), // repeated + ("euphemism","a"), // repeated + ("eureka","a"), // repeated + ("euphonious","a"), + ("Eurasia","a"), + + // Common consonant-start words: + ("milk","a"), + ("road","a"), + ("stone","a"), + ("book","a"), // repeated + ("pen","a"), // repeated + ("fork","a"), // repeated + ("spoon","a"), // repeated + ("knife","a"), // 'knife' actually starts with /n/ sound because 'k' is silent. "a knife" or "an knife"? It's "a knife" (starts with 'n' consonant sound?), actually 'kn' in 'knife' is silent, so 'knife' is pronounced 'nife' with a consonant sound 'n', so "a knife" is correct. + ("glass","a"), + ("window","a"), + + // Final checks with tricky silent letters: + ("gnome","a"), // 'g' silent, pronounced 'nome' with 'n' consonant sound → 'a gnome' + ("gnat","a"), // 'nat' consonant sound → 'a gnat' + ("xylophone","a"), // 'zylophone' → 'z' consonant sound → 'a xylophone' + ("xenon","a"), // 'zee-non' consonant sound 'z' → 'a xenon' + ("honorific","an"), // repeated but reaffirming + ("hourly","an"), // repeated but reaffirming + ("honorably","an"), + ("honorifics","an"), + ("honors","an"), +) + +#for (word, article) in tests { + let chosen = __determine_article(word) + if chosen != article { + panic("bad choice! word: " + word + ", article: " + article + ", chosen: " + chosen) + } +} + +#set page(height: auto, width: auto, margin: 1em) + +Success! All articles determined properly! diff --git a/glossy/tests/refs-exhaustive/test.typ b/glossy/tests/refs-exhaustive/test.typ new file mode 100644 index 0000000..ca19418 --- /dev/null +++ b/glossy/tests/refs-exhaustive/test.typ @@ -0,0 +1,390 @@ +// Brutal test for `glossy` functionality +// +// This file tries to exercise every known feature of `glossy` based on the demo. +// The goal is to cover all possible term definitions, modifiers, and reference forms. +// +// - Test terms with all combinations of fields: short, long, description, group, article, longarticle +// - Terms with missing fields +// - Terms with vowel/consonant starting for testing articles +// - Terms with conflicting modifiers (short/long/both), capitalization (cap), plural (pl), article (a/an), desc, def +// - Terms that appear and terms that are never referenced +// - Multiple glossaries with different themes and groups +// - Custom show-term callbacks +// +// This is not a narrative; it's a comprehensive brute-force test. +// +// Assumptions/Features Inferred: +// - @key references a glossary term by its key +// - Modifiers: short, long, both, cap, pl, a, an, def, desc +// - a/an modifiers can appear before or after the key +// - Terms can have custom 'article' or 'longarticle' fields to override default articles +// - Terms not referenced should not appear in a glossary unless forced +// - Conflicting modifiers: short/long/both - only one displays, in priority: both > long > short? +// - desc and def presumably show description or definition fields inline +// - The `show-term` callback in `init-glossary` modifies how inline references are displayed +// - `#glossary` can be called multiple times with different filters and themes +// - Terms can be grouped, and `#glossary` can be limited to certain groups +// - Terms with unusual keys or missing fields +// - Terms starting with vowels to test articles (e.g., 'an' vs 'a') +// - Terms that define custom `article` or `longarticle` fields +// +// We attempt to create a large dictionary with diverse entries. + +#import "/lib.typ": * + +#let exhaustiveGlossary = ( + 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 documents", + group: "Web" + ), + + // Term with no description + js: ( + short: "JS", + long: "JavaScript", + group: "Web" + ), + + // Term with only a short field + tcp: ( + short: "TCP", + group: "Networking" + ), + + // Term with only a long field + // should panic! + //udp: ( + // long: "User Datagram Protocol", + // group: "Networking" + //), + + // Term with short and description, but no long + ip: ( + short: "IP", + description: "A unique identifier for a host or network interface", + group: "Networking" + ), + + // Term with custom articles and everything defined + api: ( + short: "API", + long: "Application Programming Interface", + description: "A set of protocols and tools for building software", + article: "an", // custom article for short form + longarticle: "an", // custom article for long form + group: "Software" + ), + + // Term starting with a vowel, no custom article, to test default a/an logic + xml: ( + short: "XML", + long: "Extensible Markup Language", + description: "A markup language defining rules for encoding documents", + group: "Data" + ), + + // Term starting with a consonant, no custom article + cpu: ( + short: "CPU", + long: "Central Processing Unit", + description: "The primary component of a computer that performs calculations", + group: "Hardware" + ), + + // Term without any fields (just a string), should be handled gracefully + bare: "A bare term with no fields", + + // Term with only description + // should panic! + //desconly: ( + // description: "Just a description, no short or long", + // group: "Misc" + //), + + // Term with article fields but missing short and long (should test fallback) + // should panic! + //articonly: ( + // article: "an", + // longarticle: "a", + // description: "Has only articles and a description", + // group: "Misc" + //), + + // Term with uppercase key + SSL: ( + short: "SSL", + long: "Secure Sockets Layer", + description: "A protocol for encrypting information over the internet", + group: "Security" + ), + + // Term that will not be referenced + unusedterm: ( + short: "UNUSED", + long: "Unused Term", + description: "Should not appear since it's never referenced" + ), + + // Term that tests pluralization with custom fields maybe + doc: ( + short: "doc", + long: "document", + description: "A single piece of written matter", + group: "Misc" + ), + + // Terms that might require checking conflicting modifiers + tps: ( + short: "TPS", + long: "test procedure specification", + description: "A document describing test procedures", + group: "Testing" + ), + + // Term that might appear multiple times + http: ( + short: "HTTP", + long: "Hypertext Transfer Protocol", + description: "A protocol for distributed, collaborative, hypermedia information systems", + group: "Web" + ) +) + +// Initialize the glossary with a custom show-term callback +#show: init-glossary.with( + exhaustiveGlossary, + show-term: (body) => [#strong(body)] +) + +#set heading(numbering: "1.1") +#set page(height: 9.5in, width: 7in, margin: 1em, numbering: "1") + += Brutal `glossy` Test + +This document tries all reference forms and modifiers on a large set of terms. + +== Basic references without modifiers + +- @html +- @css +- @js +- @tcp +//- @udp // should panic! +- @ip +- @api +- @xml +- @cpu +- @bare +//- @desconly // should panic! +//- @articonly // should panic! +- @SSL +- @doc +- @tps +- @http + +== Using plural modifier (pl) + +- @html:pl +- @css:pl +- @js:pl +- @tcp:pl +//- @udp:pl // should panic! +- @ip:pl +- @api:pl +- @xml:pl +- @cpu:pl +- @bare:pl +//- @desconly:pl // should panic! +//- @articonly:pl // should panic! +- @SSL:pl +- @doc:pl +- @tps:pl +- @http:pl + +== Using capitalization modifier (cap) + +- @html:cap +- @css:cap +- @js:cap +- @tcp:cap +//- @udp:cap // should panic! +- @ip:cap +- @api:cap +- @xml:cap +- @cpu:cap +- @bare:cap +//- @desconly:cap // should panic! +//- @articonly:cap // should panic! +- @SSL:cap +- @doc:cap +- @tps:cap +- @http:cap + +== Using articles (a/an) before the key + +- @a:html (consonant start) +- @a:css +- @an:ip (starts with vowel sound?) +- @an:api (has custom article 'an') +- @a:cpu (consonant) +- @an:xml (starts with vowel) +//- @an:articonly (has custom article fields) // should panic! +- @an:SSL (S pronounced 'es') +- @a:doc + +== Using articles after the key + +- @html:a +- @css:an +- @ip:a +- @api:a (custom article should still apply) +- @cpu:an (might not make sense since CPU starts with C sound - test behavior) +- @xml:a +//- @articonly:an // should panic! +- @SSL:a +- @doc:an + +== Using def and desc modifiers + +- @html:def (show definition/long?) +- @css:desc (show description) +//- @js:def // should panic! +- @ip:desc +- @api:def +- @xml:desc +- @cpu:def +//- @desconly:def (no short/long, just desc) // should panic! +//- @articonly:desc // should panic! +- @tps:def +- @tps:desc +- @http:def +- @http:desc + +== Using short, long, both modifiers + +- @html:short +- @html:long +- @html:both +- @css:short +- @css:long +- @css:both +//- @udp:long (only has long) // should panic! +- @tcp:short (only has short) +- @ip:short +- @ip:long (no long defined, fallback?) +- @api:long +- @xml:both +- @cpu:both +- @doc:short +- @doc:long +- @doc:both +- @tps:short +- @tps:long +- @tps:both + +== Conflicting multiple modifiers combined (short, long, both together) + +- @tps:short:long +- @tps:short:both +- @tps:long:both +- @tps:short:long:both +- @html:both:long:short +- @doc:long:short +- @cpu:both:long:short +- @api:short:long:both + +== Combining capitalization and plural with short/long/both + +- @html:long:pl:cap +- @css:both:cap:pl +- @doc:short:pl:cap +- @api:both:pl:cap +- @ip:long:pl:cap (no long, see what happens) +- @bare:long:pl:cap (no fields) +//- @desconly:both:pl:cap (no short/long) // should panic! +//- @articonly:short:cap:pl (no short defined) // should panic! +- @SSL:both:pl:cap +- @tps:long:pl:cap +- @http:both:pl:cap + +== Combining articles with other modifiers + +- @a:html:long +- @an:ip:long +- @api:a:long (custom article) +//- @xml:an:both:pl (articles + plural? Should fail or ignore?) // should panic! +- @cpu:an:both:cap +- @SSL:a:short:cap +//- @doc:an:long:pl // should panic! + +(We know articles + pl is not compatible, checking behavior.) + +//== Attempt referencing nonexistent keys +// +//- @nonexistent (Should show something like ?? or error?) // should panic! +//- @nonexistent:long:pl:cap:a:def (extreme) // should panic! + +== Create a big table testing all fields of one term + +#table( + columns: 3, + table.header([*Input*],[*Output*],[*Comment*]), + [`@api` ], [@api], ["default"], + [`@api:short` ], [@api:short], ["short only"], + [`@api:long` ], [@api:long], ["long only"], + [`@api:both` ], [@api:both], ["both"], + [`@api:def` ], [@api:def], ["definition (long)"], + [`@api:desc` ], [@api:desc], ["description"], + [`@a:api` ], [@a:api], ["article before key"], + [`@api:a` ], [@api:a], ["article after key"], + [`@api:cap` ], [@api:cap], ["capitalize"], + [`@api:pl` ], [@api:pl], ["plural"], + [`@api:long:cap`], [@api:long:cap],["long + cap"], + [`@api:both:pl` ], [@api:both:pl], ["both + pl"] +) + +== Multiple glossaries with themes and group filtering + += Glossaries + +== Full Glossary (all terms) + +#glossary(title: "Full Glossary", theme: theme-chicago-index) + +== Web Only + +#glossary(title: "Web Terms", groups: ("Web",), theme: theme-compact) + +== Networking Only + +#glossary(title: "Networking Terms", groups: ("Networking",), theme: theme-basic) + +== Misc and Data + +#glossary(title: "Misc & Data", groups: ("Misc","Data"), theme: theme-basic) + +== Just Testing terms + +#glossary(title: "Testing Terms", groups: ("Testing",), theme: theme-chicago-index) + +== Hardware group + +#glossary(title: "Hardware Terms", groups: ("Hardware",), theme: theme-chicago-index) + +== Unreferenced entries + +#glossary(title: "Unreferenced (Should Contain Unused?)", groups: ("Security",), theme: theme-chicago-index) + +== Empty group glossary +#glossary(title: "Empty Group Glossary", groups: (""), theme: theme-basic) + += End of Brutal Test diff --git a/glossy/thumbnail.png b/glossy/thumbnail.png index 7635045..3c7f7e0 100644 Binary files a/glossy/thumbnail.png and b/glossy/thumbnail.png differ