Add summary: to character templates; fix two bugs that were hiding it

Add a summary: field (with a short explanatory comment) to
01_character_template.md, and fill the field in on the two shipped
examples, 01_protagonist.md and 02_antagonist.md. Documented in the
Story Bible README's "Adding a New Character" example and its new
"Automatic Linking in the Draft" section, and in the main README's
Story Bible docs.

While wiring this up against the real bundled template rather than a
hand-written test fixture, found two bugs that were silently defeating
last session's auto-linking feature for every project made from that
template:

- extract_from_frontmatter() only read a title: field for an item's
  name, but every bundled Story Bible template — character, location,
  lore, object, all of them — writes name:, never title:. Every real
  item's display name (and therefore everything matched against it in
  prose) was falling back to a garbled, uncapitalized filename guess
  instead. Now name: is read (title: still works too, for a sheet that
  uses that word instead).

- build_editor_job()'s fast path skipped straight to an unformatted
  run whenever there were no grammar/spelling issues and no active
  search — without checking whether there was a Story Bible reference
  to highlight. A name with no spelling issue of its own (which is
  most of them) never got its purple background, and with it no visible
  way to find the hover card that depends on it.

Both were invisible in the unit tests added last session because they
exercised find_references() directly against a hand-built index, never
through StoryBibleIndex::build() against real files, and the manual
smoke test happened to use a name ("Bixby") that the spell checker
flagged anyway — which incidentally kept the fast path from short-
circuiting. Added a disk-built-index test and a fast-path regression
test so both stay caught.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 11:32:56 -05:00
parent 0da52c3054
commit 229e2413c4
7 changed files with 134 additions and 18 deletions
+24 -12
View File
@@ -412,35 +412,47 @@ it shows:
The Story Bible window indexes all Story Bible items from your project's character, The Story Bible window indexes all Story Bible items from your project's character,
location, lore, and other reference directories. It supports: location, lore, and other reference directories. It supports:
* Automatic indexing from directories like `03-Characters`, `02-Story Bible`, * Automatic indexing from the first directory found: `03-Story-Bible` (the
`Characters`, `Locations`, `Lore`, etc. bundled template's own folder), then `03-Characters`, `02-Story Bible`,
* Two reference syntaxes in your draft text: `Characters`, `Locations`, `Lore`, and so on.
* **Automatic linking in the draft — no markup required.** A name spelled and
capitalized the same way as an item's `name:` or an `aliases:` entry is
matched wherever it appears in ordinary prose ("**Bixby** stood at the
door"). Two explicit syntaxes still work too, and win any overlap with an
automatic match:
- Wiki-style: `[[Character Name]]` or `[[John Doe]]` - Wiki-style: `[[Character Name]]` or `[[John Doe]]`
- Mention-style: `@CharacterName` or `@John` - Mention-style: `@CharacterName` or `@John`
* **Purple background highlighting** of Story Bible references in the editor * **Purple background highlighting** of every match — automatic or explicit —
in the editor. Hovering one shows a card with the item's name, type, and a
summary (see below), and a link to open its full entry; the card stays put
while you move the pointer onto it to click that link.
* Click items in the Story Bible window to see all references to them in the current file * Click items in the Story Bible window to see all references to them in the current file
* Click reference buttons to jump directly to that location in the editor * Click reference buttons to jump directly to that location in the editor
* Metadata extraction from YAML frontmatter (id, title, type, **aliases** fields) or from filenames * Metadata extraction from YAML frontmatter (id, name, type, **aliases**,
**summary** fields) or from filenames
* Case-insensitive search and lookup * Case-insensitive search and lookup
* **Aliases support**: References match item IDs, names, **and aliases** for flexible linking * **Aliases support**: References match item IDs, names, **and aliases** for flexible linking
Character sheets in the `Characters` folder are automatically indexed, and any Character sheets in the `Characters` folder are automatically indexed, and any
markdown file with YAML frontmatter containing `id:` and `title:` fields will be markdown file with YAML frontmatter containing `id:` and `name:` fields will be
included in the index. You can also add an `aliases:` field with alternative names included in the index (`title:` is read too, if a sheet uses that word instead).
for a character or item: You can also add `aliases:` for alternative names, and `summary:` for the one-line
hover card:
```yaml ```yaml
--- ---
id: john_smith id: john_smith
title: "John Smith" name: "John Smith"
type: character type: character
aliases: ["Johnny", "JS"] aliases: ["Johnny", "JS"]
summary: "A retired locksmith dragged back for one last job."
--- ---
``` ```
With aliases defined, references like `[[Johnny]]` or `@JS` will match the "John Smith" With aliases defined, references like `[[Johnny]]`, `@JS`, or a bare "Johnny" in
character entry. References in your draft text that match Story Bible items prose will all match the "John Smith" character entry. Every reference in your
by ID, name, or alias are highlighted and clickable. draft that matches a Story Bible item — by ID, name, or alias — is highlighted
and clickable, whether or not you typed any markup to get it there.
### Story Bible Editor (Edit ▸ Story Bible…) ### Story Bible Editor (Edit ▸ Story Bible…)
+28 -1
View File
@@ -489,7 +489,7 @@ pub(super) fn build_editor_job(
let mut job = LayoutJob::default(); let mut job = LayoutJob::default();
job.wrap.max_width = wrap_width; job.wrap.max_width = wrap_width;
if grammar.is_empty() && find.is_empty() { if grammar.is_empty() && find.is_empty() && bible.is_empty() {
job.append(text, 0.0, TextFormat::simple(font_id, color)); job.append(text, 0.0, TextFormat::simple(font_id, color));
return job; return job;
} }
@@ -809,6 +809,33 @@ pub(super) fn toggle_line_comment(
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn a_bible_only_reference_is_not_skipped_by_the_grammar_find_fast_path() {
// build_editor_job short-circuits to one plain run when there's
// nothing to highlight — a regression check that it doesn't take
// that shortcut when the only thing to highlight is a Story Bible
// mention (no grammar issues, no active search), which used to
// mean the purple background — and the hover card that depends on
// it being visible to find — never appeared for a name with no
// spelling issue of its own.
let text = "Protagonist stood at the door.";
let job = build_editor_job(
text,
14.0,
egui::Color32::WHITE,
&[],
&[],
None,
&[(0, 11)], // "Protagonist"
f32::INFINITY,
);
let highlighted = job
.sections
.iter()
.any(|s| s.format.background != egui::Color32::default());
assert!(highlighted, "a bible-only reference must still be formatted");
}
#[test] #[test]
fn byte_to_char_is_the_inverse_of_char_to_byte() { fn byte_to_char_is_the_inverse_of_char_to_byte() {
// "café!" — é is 2 bytes, so byte and char indices diverge after it. // "café!" — é is 2 bytes, so byte and char indices diverge after it.
+63 -4
View File
@@ -201,6 +201,7 @@ fn extract_from_frontmatter(relative_path: &str, content: &str) -> Option<StoryB
} }
let mut name: Option<String> = None; let mut name: Option<String> = None;
let mut title: Option<String> = None;
let mut id: Option<String> = None; let mut id: Option<String> = None;
let mut item_type: Option<String> = None; let mut item_type: Option<String> = None;
let mut aliases: Vec<String> = Vec::new(); let mut aliases: Vec<String> = Vec::new();
@@ -209,8 +210,13 @@ fn extract_from_frontmatter(relative_path: &str, content: &str) -> Option<StoryB
for line in &lines[start + 1..end] { for line in &lines[start + 1..end] {
let trimmed = line.trim(); let trimmed = line.trim();
if trimmed.starts_with("title:") { // `name:` is what every bundled Story Bible template actually
name = Some(trimmed["title:".len()..].trim().trim_matches('"').to_string()); // writes; `title:` is accepted too since a scene/draft file's own
// frontmatter uses that word and a sheet may borrow it by habit.
if trimmed.starts_with("name:") {
name = Some(trimmed["name:".len()..].trim().trim_matches('"').to_string());
} else if trimmed.starts_with("title:") {
title = Some(trimmed["title:".len()..].trim().trim_matches('"').to_string());
} else if trimmed.starts_with("id:") { } else if trimmed.starts_with("id:") {
id = Some(trimmed["id:".len()..].trim().trim_matches('"').to_string()); id = Some(trimmed["id:".len()..].trim().trim_matches('"').to_string());
} else if trimmed.starts_with("type:") { } else if trimmed.starts_with("type:") {
@@ -234,8 +240,8 @@ fn extract_from_frontmatter(relative_path: &str, content: &str) -> Option<StoryB
// `summary:` wins if a sheet somehow has both. // `summary:` wins if a sheet somehow has both.
let summary = summary.or(description); let summary = summary.or(description);
// Use name or filename as fallback // Use name, then title, then the filename as fallback.
let name = name.unwrap_or_else(|| { let name = name.or(title).unwrap_or_else(|| {
Path::new(relative_path) Path::new(relative_path)
.file_stem() .file_stem()
.and_then(|s| s.to_str()) .and_then(|s| s.to_str())
@@ -526,6 +532,34 @@ Some content here."#;
assert_eq!(item.item_type, "character"); assert_eq!(item.item_type, "character");
} }
#[test]
fn frontmatter_reads_name_the_way_every_bundled_template_actually_writes_it() {
// Every sheet the snowflake template ships — character, location,
// lore, object — writes `name:`, never `title:`. A regression check
// for the mismatch that made every item's name fall back to a
// filename-derived guess instead.
let content = r#"---
id: bixby_reynolds
name: Bixby Reynolds
type: protagonist
---
# Bixby Reynolds"#;
let item = extract_from_frontmatter("main_characters/01_bixby.md", content).unwrap();
assert_eq!(item.name, "Bixby Reynolds");
}
#[test]
fn frontmatter_prefers_name_over_title_when_a_sheet_somehow_has_both() {
let content = r#"---
name: Real Name
title: Old Spelling
---
"#;
let item = extract_from_frontmatter("test.md", content).unwrap();
assert_eq!(item.name, "Real Name");
}
#[test] #[test]
fn frontmatter_summary_feeds_the_hover_card() { fn frontmatter_summary_feeds_the_hover_card() {
let content = r#"--- let content = r#"---
@@ -725,6 +759,31 @@ description: The long version.
assert_eq!(&"Bixby walked into the room."[refs[0].start..refs[0].end], "Bixby"); assert_eq!(&"Bixby walked into the room."[refs[0].start..refs[0].end], "Bixby");
} }
#[test]
fn a_bare_mention_matches_against_a_real_disk_built_index() {
// The hand-built `index_with` above exercises the matching rules;
// this instead goes through `StoryBibleIndex::build`, the same path
// a real project's character sheet takes, as a check that nothing
// is lost crossing that boundary (frontmatter parsing, `id`
// lowercasing, and so on).
let dir = std::env::temp_dir().join(format!(
"md_manuscript_test_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("protagonist.md"),
"---\nid: protagonist\nname: Protagonist\nsummary: \"hero\"\nslug: protagonist\ntype: protagonist\n---\n\n# Protagonist\n",
)
.unwrap();
let index = StoryBibleIndex::build(&dir);
let refs = find_references("Protagonist stood at the door.", &index);
std::fs::remove_dir_all(&dir).ok();
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].item_id, "protagonist");
}
#[test] #[test]
fn a_bare_mention_respects_word_boundaries() { fn a_bare_mention_respects_word_boundaries() {
let index = index_with("bixby", "Bixby", &[]); let index = index_with("bixby", "Bixby", &[]);
@@ -1,6 +1,10 @@
--- ---
id: character_id id: character_id
name: Character Name name: Character Name
# One line the app can show in a hover card when this name appears in a
# draft — not a substitute for the full sheet below, just enough to
# place them at a glance.
summary: ""
slug: character-name slug: character-name
pronunciation: "" pronunciation: ""
type: protagonist type: protagonist
@@ -10,7 +14,7 @@ gender: ""
age: 0 age: 0
species: "" species: ""
occupation: "" occupation: ""
tags: [] tags: []
appearances: [] appearances: []
related_characters: [] related_characters: []
related_locations: [] related_locations: []
@@ -1,6 +1,7 @@
--- ---
id: protagonist id: protagonist
name: Protagonist name: Protagonist
summary: ""
slug: protagonist slug: protagonist
pronunciation: "" pronunciation: ""
type: protagonist type: protagonist
@@ -1,6 +1,7 @@
--- ---
id: antagonist id: antagonist
name: Antagonist name: Antagonist
summary: ""
slug: antagonist slug: antagonist
pronunciation: "" pronunciation: ""
type: antagonist type: antagonist
@@ -75,6 +75,7 @@ The Story Bible is a comprehensive reference system that helps you maintain cons
--- ---
id: unique_id id: unique_id
name: Character Name name: Character Name
summary: "One line the app can show in a hover card"
slug: character-name slug: character-name
type: protagonist|antagonist|ally|enemy|mentor type: protagonist|antagonist|ally|enemy|mentor
status: active|draft|deceased|retired status: active|draft|deceased|retired
@@ -142,6 +143,17 @@ Use `[[Link Text|file-name]]` or `[[file-name]]` to create cross-references:
- **Related Event:** [[The War of Ashes|history/war_of_ashes]] - **Related Event:** [[The War of Ashes|history/war_of_ashes]]
``` ```
### Automatic Linking in the Draft
The app does not require `[[wiki-links]]` to connect a draft to the Story
Bible — a name written in ordinary prose ("**Bixby** stood at the door") is
matched automatically against every entry's `name:` and `aliases:`, as long
as it's spelled and capitalized the same way in both places. Hovering a
matched name shows a card with the entry's `summary:` field and a link that
opens the full sheet; `[[wiki-links]]` and `@mentions` still work too, and
take priority if they ever land on the same word. A character sheet with no
`summary:` still gets matched and links open fine — the card just says so
and lets you add one.
### Mermaid Diagrams ### Mermaid Diagrams
Create visual relationship maps and timelines: Create visual relationship maps and timelines: