asciidoc_parser/document/author.rs
1use std::sync::LazyLock;
2
3use regex::Regex;
4
5use crate::{Parser, Span, content::Content};
6
7/// Represents a single author as (typically) described on the [author line].
8///
9/// The attributes `firstname`, `middlename`, `lastname`, and `authorinitials`
10/// are automatically derived from the full value of the author string. When
11/// assigned implicitly via the author line, the value includes all of the
12/// characters and words prior to the semicolon (`;`), angle bracket (`<`), or
13/// the end of the line. Note that when using the implicit author line, the full
14/// name can have a maximum of three space-separated names. If it has more, then
15/// the full name is assigned to the `firstname` attribute. You can adjoin names
16/// using an underscore (`_`) character.
17///
18/// [author line]: https://docs.asciidoctor.org/asciidoc/latest/document/author-line/
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct Author {
21 name: String,
22 firstname: String,
23 middlename: Option<String>,
24 lastname: Option<String>,
25 email: Option<String>,
26}
27
28impl Author {
29 /// Parse a single author from `source`.
30 ///
31 /// `names_only` distinguishes the two contexts in which Asciidoctor parses
32 /// an author. The implicit author line (`names_only == false`) recognizes
33 /// at most three space-separated names via the [`AUTHOR`] pattern and,
34 /// failing that, stores the whole line as the author. An author
35 /// supplied through an attribute entry such as `:author:` (`names_only
36 /// == true`) is instead partitioned by splitting on whitespace into at
37 /// most three parts, so a name with four or more parts still assigns
38 /// its trailing parts to `lastname` (see issue #758).
39 pub(crate) fn parse(source: &str, parser: &Parser, names_only: bool) -> Option<Self> {
40 let source = source.trim();
41 if source.is_empty() {
42 return None;
43 }
44
45 // Parse the raw input first to extract components, then apply attribute
46 // substitution to individual components afterwards. Special case: If the entire
47 // input is a single attribute reference, treat the expanded result as a single
48 // name.
49 let is_single_attribute = source.trim().starts_with('{')
50 && source.trim().ends_with('}')
51 && source.matches('{').count() == 1;
52
53 if is_single_attribute {
54 // Entire input is a single attribute reference: Expand and treat as single
55 // name.
56 let expanded_source = apply_author_subs(source, parser);
57
58 if names_only {
59 // An attribute-entry value is partitioned *after* its references
60 // are expanded, so a reference that resolves to a multi-part name
61 // (or one with a trailing email) yields the same metadata as the
62 // equivalent literal value.
63 Some(partition_names_only(&expanded_source))
64 } else {
65 let name_with_spaces = replace_underscores_with_spaces(expanded_source);
66 Some(Self {
67 name: name_with_spaces.clone(),
68 firstname: name_with_spaces,
69 middlename: None,
70 lastname: None,
71 email: None,
72 })
73 }
74 } else if let Some(captures) = AUTHOR.captures(source) {
75 // Raw input matches author pattern: Extract components then apply
76 // substitutions.
77
78 // Extract raw components first.
79 let firstname =
80 replace_underscores_with_spaces(apply_author_subs(&captures[1], parser));
81 let mut middlename = captures
82 .get(2)
83 .map(|m| replace_underscores_with_spaces(apply_author_subs(m.as_str(), parser)));
84 let mut lastname = captures
85 .get(3)
86 .map(|m| replace_underscores_with_spaces(apply_author_subs(m.as_str(), parser)));
87 let email = captures
88 .get(4)
89 .map(|m| apply_author_subs(m.as_str(), parser));
90
91 if middlename.is_some() && lastname.is_none() {
92 lastname = middlename;
93 middlename = None;
94 }
95
96 // Reconstruct the full name from its parsed parts so that any interior
97 // whitespace that appeared between the names in the source is condensed
98 // to a single space (matching Asciidoctor).
99 let name = join_name_parts(&firstname, middlename.as_deref(), lastname.as_deref());
100
101 Some(Self {
102 name,
103 firstname,
104 middlename,
105 lastname,
106 email,
107 })
108 } else if source.contains('{') {
109 // Input contains attributes that prevent regex match: Expand first, then try
110 // parsing.
111 let expanded_source = apply_author_subs(source, parser);
112
113 if let Some(captures) = AUTHOR.captures(&expanded_source) {
114 // After expansion, it matches the pattern: Parse normally.
115 let firstname = replace_underscores_with_spaces(captures[1].to_string());
116 let mut middlename = captures
117 .get(2)
118 .map(|m| replace_underscores_with_spaces(m.as_str().to_string()));
119 let mut lastname = captures
120 .get(3)
121 .map(|m| replace_underscores_with_spaces(m.as_str().to_string()));
122 let email = captures.get(4).map(|m| m.as_str().to_string());
123
124 if middlename.is_some() && lastname.is_none() {
125 lastname = middlename;
126 middlename = None;
127 }
128
129 // Reconstruct the full name from its parsed parts so interior
130 // whitespace between the names is condensed (matching Asciidoctor).
131 let name = join_name_parts(&firstname, middlename.as_deref(), lastname.as_deref());
132
133 Some(Self {
134 name,
135 firstname,
136 middlename,
137 lastname,
138 email,
139 })
140 } else if names_only {
141 // An attribute-entry value that still fails the pattern after
142 // expansion is partitioned by the names-only rules, so a
143 // reference resolving to a four-plus-part name behaves like its
144 // literal equivalent. The expanded value is used before any HTML
145 // encoding so a trailing `<email>` can still be split off.
146 Some(partition_names_only(&expanded_source))
147 } else {
148 // Even after expansion the value does not match the author
149 // pattern, so it becomes a single name. `apply_author_subs`
150 // already applied the header substitution group – special
151 // characters (escaping any literal `<`, `>`, or `&`) followed by
152 // attribute references – so the expanded value is stored as is,
153 // mirroring Asciidoctor's `apply_header_subs`.
154 let name_with_spaces = replace_underscores_with_spaces(expanded_source);
155 Some(Self {
156 name: name_with_spaces.clone(),
157 firstname: name_with_spaces,
158 middlename: None,
159 lastname: None,
160 email: None,
161 })
162 }
163 } else if names_only {
164 // Input comes from an attribute entry (e.g. `:author:`) and does not
165 // match the author pattern – typically a name with four or more parts
166 // or one containing punctuation such as a comma. Asciidoctor still
167 // partitions it by splitting on whitespace into at most three parts,
168 // assigning any trailing parts to `lastname`.
169 Some(partition_names_only(source))
170 } else {
171 // Input doesn't contain attributes and doesn't match the author
172 // pattern. Asciidoctor stores the whole line as the author,
173 // condensing interior whitespace. Underscores are left literal here:
174 // Asciidoctor only converts underscore-joined names while
175 // partitioning a *matching* line, not in this fallback.
176 //
177 // The header substitution group still applies, so any literal `<`,
178 // `>`, or `&` is escaped consistently – matching Asciidoctor's
179 // `apply_header_subs` and the attribute-reference path above, rather
180 // than returning the name raw only because it lacks an attribute
181 // reference.
182 let name = apply_author_special_characters(&condense_whitespace(source), parser);
183 Some(Self {
184 name: name.clone(),
185 firstname: name,
186 middlename: None,
187 lastname: None,
188 email: None,
189 })
190 }
191 }
192
193 /// Parse the single author described by an `:author:` attribute entry.
194 ///
195 /// `raw` is the entry's raw (pre-substitution) value and `substituted` is
196 /// its substituted value (the stored attribute value). When the entry is a
197 /// whole-value `pass:[…]` macro its substituted value is the resolved
198 /// content, so that value is partitioned – with any generated markup
199 /// stripped – rather than the raw macro syntax (see
200 /// [`parse_substituted_names_only`]). Every other value is partitioned from
201 /// the raw value as before, so plain names, attribute references, and
202 /// inline emails are unaffected.
203 ///
204 /// [`parse_substituted_names_only`]: Self::parse_substituted_names_only
205 pub(crate) fn parse_from_entry(
206 raw: &str,
207 substituted: Option<&str>,
208 parser: &Parser,
209 ) -> Option<Self> {
210 if crate::document::is_attribute_entry_pass_macro(raw) {
211 substituted.and_then(Self::parse_substituted_names_only)
212 } else {
213 Self::parse(raw, parser, true)
214 }
215 }
216
217 /// Parse a single author from a value that has *already* been through
218 /// attribute-value substitution and now carries generated inline HTML –
219 /// typically the rendered output of a `pass:[…]` macro in an `:author:`
220 /// entry.
221 ///
222 /// This mirrors the `<`-branch of Asciidoctor's `process_authors` under
223 /// `names_only`: the full rendered value – with name-joiner underscores
224 /// turned to spaces – becomes the author's `name`, while the name parts are
225 /// partitioned from the value with its HTML tags removed, so the formatting
226 /// does not leak into `firstname`/`middlename`/`lastname`. As in
227 /// Asciidoctor, no email is split off here – an email supplied through a
228 /// companion `:email:` entry is attached later.
229 pub(crate) fn parse_substituted_names_only(substituted: &str) -> Option<Self> {
230 let substituted = substituted.trim();
231 if substituted.is_empty() {
232 return None;
233 }
234
235 let name = replace_underscores_with_spaces(substituted.to_string());
236 let stripped = strip_xml_tags(substituted);
237
238 let mut segments = split_whitespace_max3(&stripped);
239
240 if segments.is_empty() {
241 // The value was nothing but markup: keep the rendered value as the
242 // single name.
243 return Some(Self {
244 firstname: name.clone(),
245 name,
246 middlename: None,
247 lastname: None,
248 email: None,
249 });
250 }
251
252 let firstname = replace_underscores_with_spaces(segments.remove(0));
253 let (middlename, lastname) = match segments.len() {
254 0 => (None, None),
255
256 1 => (
257 None,
258 Some(replace_underscores_with_spaces(segments.remove(0))),
259 ),
260
261 _ => (
262 Some(replace_underscores_with_spaces(segments.remove(0))),
263 Some(replace_underscores_with_spaces(segments.remove(0))),
264 ),
265 };
266
267 Some(Self {
268 name,
269 firstname,
270 middlename,
271 lastname,
272 email: None,
273 })
274 }
275
276 /// Overrides the author's email address, unless `email` is `None`.
277 ///
278 /// Used when an author is assembled from `author_N` document attributes,
279 /// where the name and the companion `email_N` attribute are parsed
280 /// separately.
281 pub(crate) fn with_email(mut self, email: Option<String>) -> Self {
282 if let Some(email) = email {
283 self.email = Some(email);
284 }
285
286 self
287 }
288
289 /// Returns the full name of the author.
290 ///
291 /// The name includes the entire author declaration except for email.
292 pub fn name(&self) -> &str {
293 &self.name
294 }
295
296 /// Returns the first, forename, or given name of the author.
297 ///
298 /// The first space-separated name in the value of the `author` attribute is
299 /// automatically assigned to `firstname`.
300 pub fn firstname(&self) -> &str {
301 &self.firstname
302 }
303
304 /// Returns the middle name or initial of the author.
305 ///
306 /// If author contains three space-separated names, the second name is
307 /// assigned to the `middlename` attribute.
308 pub fn middlename(&self) -> Option<&str> {
309 self.middlename.as_deref()
310 }
311
312 /// Returns the last, surname, or family name of the author.
313 ///
314 /// If the author name contains two or three space-separated names, the last
315 /// of those names is assigned to the `lastname` attribute.
316 pub fn lastname(&self) -> Option<&str> {
317 self.lastname.as_deref()
318 }
319
320 /// Returns the email address or URL associated with the author.
321 ///
322 /// When assigned via the author line, it’s enclosed in a pair of angle
323 /// brackets (`< >`). A URL can be used in place of the email address.
324 pub fn email(&self) -> Option<&str> {
325 self.email.as_deref()
326 }
327
328 /// Returns the initials of the author.
329 ///
330 /// The first character of the `firstname`, `middlename`, and `lastname`
331 /// attribute values are assigned to the `authorinitials` attribute. The
332 /// value of the `authorinitials` attribute will consist of three characters
333 /// or less depending on how many parts are in the author’s name.
334 pub fn initials(&self) -> String {
335 format!(
336 "{first}{middle}{last}",
337 first = first_char_or_empty_string(&self.firstname),
338 middle = opt_first_char_or_empty_string(self.middlename.as_deref()),
339 last = opt_first_char_or_empty_string(self.lastname.as_deref()),
340 )
341 }
342}
343
344/// Populates the derived author document attributes from a resolved author
345/// list, mirroring Asciidoctor's `process_authors`.
346///
347/// The first author sets the unsuffixed keys (`author`, `firstname`,
348/// `authorinitials`, …); each subsequent author sets its `_N` companions. Once
349/// a second author appears, the first author is also mirrored onto its `_1`
350/// companions. The `authors` attribute is rewritten to the comma-joined list of
351/// resolved author names.
352///
353/// This intentionally does **not** honor `authorinitials_from_entry`: the
354/// derived `authorinitials` always overwrites an explicit `:authorinitials:`
355/// entry for the `authors` and `author_N` forms. Only a single `:author:` entry
356/// (handled inline in [`Header::parse`](crate::document::Header)) preserves an
357/// explicit override, exactly as Asciidoctor does – its `authorinitials`
358/// deletion guard lives only in the `author` branch of `process_authors`, not
359/// the `authors`/indexed branches.
360pub(crate) fn set_author_metadata(parser: &mut Parser, authors: &[Author]) {
361 for (idx, author) in authors.iter().enumerate() {
362 set_author_keys(parser, author, if idx == 0 { None } else { Some(idx + 1) });
363
364 // The `_1` companions are only assigned once a second author is seen.
365 if idx == 1
366 && let Some(first) = authors.first()
367 {
368 set_author_keys(parser, first, Some(1));
369 }
370 }
371
372 let joined = authors
373 .iter()
374 .map(Author::name)
375 .collect::<Vec<_>>()
376 .join(", ");
377
378 parser.set_attribute_by_value_from_header("authors", joined);
379}
380
381/// Sets the author attributes for a single author, either as the unsuffixed
382/// keys (`index` is `None`) or the `_N` companions (`index` is `Some(n)`).
383fn set_author_keys(parser: &mut Parser, author: &Author, index: Option<usize>) {
384 let key = |name: &str| match index {
385 None => name.to_string(),
386 Some(n) => format!("{name}_{n}"),
387 };
388
389 parser.set_attribute_by_value_from_header(key("author"), author.name());
390
391 parser.set_attribute_by_value_from_header(key("firstname"), author.firstname());
392
393 if let Some(middlename) = author.middlename() {
394 parser.set_attribute_by_value_from_header(key("middlename"), middlename);
395 }
396
397 if let Some(lastname) = author.lastname() {
398 parser.set_attribute_by_value_from_header(key("lastname"), lastname);
399 }
400
401 parser.set_attribute_by_value_from_header(key("authorinitials"), author.initials());
402
403 if let Some(email) = author.email() {
404 parser.set_attribute_by_value_from_header(key("email"), email);
405 }
406}
407
408fn first_char_or_empty_string(s: &str) -> String {
409 s.chars().next().map_or(String::new(), |c| c.to_string())
410}
411
412fn opt_first_char_or_empty_string(s: Option<&str>) -> String {
413 s.map(first_char_or_empty_string).unwrap_or_default()
414}
415
416/// Replace underscores with spaces in a name component.
417fn replace_underscores_with_spaces(name: String) -> String {
418 name.replace('_', " ")
419}
420
421/// Remove HTML tags from `source`, mirroring Asciidoctor's `XmlSanitizeRx`
422/// (`/<[^>]+>/`). Used to partition an author value whose substitution produced
423/// inline markup (see [`Author::parse_substituted_names_only`]).
424fn strip_xml_tags(source: &str) -> String {
425 XML_TAG.replace_all(source, "").into_owned()
426}
427
428/// Join an author's parsed name parts with a single space.
429///
430/// Asciidoctor reconstructs the full name from its partitioned parts, which
431/// condenses any interior whitespace that appeared between the names in the
432/// source down to a single space.
433fn join_name_parts(firstname: &str, middlename: Option<&str>, lastname: Option<&str>) -> String {
434 let mut name = String::from(firstname);
435
436 if let Some(middlename) = middlename {
437 name.push(' ');
438 name.push_str(middlename);
439 }
440
441 if let Some(lastname) = lastname {
442 name.push(' ');
443 name.push_str(lastname);
444 }
445
446 name
447}
448
449/// Partition an author value that does not match the [`AUTHOR`] pattern using
450/// Asciidoctor's `names_only` rules (the path taken for an attribute-entry
451/// value such as `:author:`).
452///
453/// A trailing `<email>` (or URL) is first split off so it is not absorbed into
454/// the name – mirroring the email group of the author pattern and Asciidoctor's
455/// XML sanitization of a names-only value. The remaining name is then split on
456/// whitespace into at most three segments (Ruby's `String#split(nil, 3)`, which
457/// also drops leading whitespace). The trailing segment retains its interior
458/// text – so a four-plus-part name keeps its later parts in `lastname` – but
459/// has repeating spaces condensed to a single space. Each segment then has
460/// underscore joiners replaced with spaces.
461///
462/// `source` may already have attribute references substituted (the expanded
463/// value of a reference such as `{full-name}`); in that case any email it
464/// carries is likewise already substituted.
465fn partition_names_only(source: &str) -> Author {
466 let source = source.trim();
467
468 let (name_source, email) = match NAMES_ONLY_EMAIL.captures(source) {
469 Some(captures) => (
470 captures.get(1).map_or(source, |m| m.as_str()),
471 Some(captures[2].to_string()),
472 ),
473 None => (source, None),
474 };
475
476 let mut segments = split_whitespace_max3(name_source);
477
478 let firstname = replace_underscores_with_spaces(segments.remove(0));
479 let (middlename, lastname) = match segments.len() {
480 0 => (None, None),
481 1 => (
482 None,
483 Some(replace_underscores_with_spaces(segments.remove(0))),
484 ),
485 _ => (
486 Some(replace_underscores_with_spaces(segments.remove(0))),
487 Some(replace_underscores_with_spaces(segments.remove(0))),
488 ),
489 };
490
491 let name = join_name_parts(&firstname, middlename.as_deref(), lastname.as_deref());
492
493 Author {
494 name,
495 firstname,
496 middlename,
497 lastname,
498 email,
499 }
500}
501
502/// Split `source` on runs of whitespace into at most three segments, mirroring
503/// Ruby's `String#split(nil, 3)`. Leading whitespace is dropped and the first
504/// two whitespace runs delimit the first two segments; the remainder becomes
505/// the third segment, with its repeating spaces condensed to a single space
506/// (Ruby's `String#squeeze ' '`).
507///
508/// Only ASCII whitespace is treated as a delimiter, matching Ruby's split
509/// (which does not break on non-breaking or other Unicode spaces), so a name
510/// joined by such a space stays a single segment. The returned vector always
511/// has at least one element because the caller has already rejected empty
512/// input.
513fn split_whitespace_max3(source: &str) -> Vec<String> {
514 let is_ascii_ws = |c: char| c.is_ascii_whitespace();
515
516 let mut segments: Vec<String> = Vec::with_capacity(3);
517 let mut rest = source;
518
519 for _ in 0..2 {
520 rest = rest.trim_start_matches(is_ascii_ws);
521 match rest.find(is_ascii_ws) {
522 Some(index) => {
523 segments.push(rest[..index].to_string());
524 rest = &rest[index..];
525 }
526 None => break,
527 }
528 }
529
530 rest = rest.trim_start_matches(is_ascii_ws);
531 if !rest.is_empty() {
532 segments.push(condense_whitespace(rest));
533 }
534
535 segments
536}
537
538/// Condense runs of spaces into a single space, mirroring Ruby's
539/// `String#tr_s(' ', ' ')`, which Asciidoctor applies to an author line that
540/// does not match the author pattern.
541fn condense_whitespace(s: &str) -> String {
542 let mut result = String::with_capacity(s.len());
543 let mut prev_was_space = false;
544
545 for c in s.chars() {
546 if c == ' ' {
547 if !prev_was_space {
548 result.push(' ');
549 }
550 prev_was_space = true;
551 } else {
552 result.push(c);
553 prev_was_space = false;
554 }
555 }
556
557 result
558}
559
560/// Matches a single HTML tag, mirroring Asciidoctor's `XmlSanitizeRx`.
561static XML_TAG: LazyLock<Regex> = LazyLock::new(|| {
562 #[allow(clippy::unwrap_used)]
563 Regex::new(r"<[^>]+>").unwrap()
564});
565
566static AUTHOR: LazyLock<Regex> = LazyLock::new(|| {
567 #[allow(clippy::unwrap_used)]
568 Regex::new(
569 r#"(?x)
570 ^
571
572 # Group 1: First name (required)
573 ([a-zA-Z0-9_\p{L}\p{N}&\#;][a-zA-Z0-9_\p{L}\p{N}\-'.&\#;]*)
574
575 # Group 2: Middle name (optional)
576 (?:\ +([a-zA-Z0-9_\p{L}\p{N}&\#;][a-zA-Z0-9_\p{L}\p{N}\-'.&\#;]*))?
577
578 # Group 3: Last name (optional)
579 (?:\ +([a-zA-Z0-9_\p{L}\p{N}&\#;][a-zA-Z0-9_\p{L}\p{N}\-'.&\#;]*))?
580
581 # Group 4: Email address (optional)
582 (?:\ +<([^>]+)>)?
583
584 $
585 "#,
586 )
587 .unwrap()
588});
589
590/// Splits a names-only author value into its name portion (group 1) and a
591/// trailing `<email>` (group 2). The name must contain at least one
592/// non-whitespace character and is followed by whitespace before the bracketed
593/// email, matching the email group of [`AUTHOR`] for a value that otherwise
594/// fails the full pattern (e.g. a name with four or more parts).
595static NAMES_ONLY_EMAIL: LazyLock<Regex> = LazyLock::new(|| {
596 #[allow(clippy::unwrap_used)]
597 Regex::new(r"^(.*\S)\s+<([^>]+)>$").unwrap()
598});
599
600/// Returns whether `source` matches the author pattern – at most three
601/// space-separated names with an optional trailing `<email>`.
602///
603/// The `:author:` attribute-entry path uses this to tell whether a plain-name
604/// value was partitioned by the fallback whitespace split (a name with four or
605/// more parts, or one containing punctuation such as a comma) rather than by
606/// the pattern. Only in the fallback case is the stored `author` value replaced
607/// with the reconstructed, whitespace-condensed name (issue #758).
608pub(crate) fn matches_author_pattern(source: &str) -> bool {
609 AUTHOR.is_match(source.trim())
610}
611
612/// Apply the header substitution group to an author-line fragment: special
613/// characters first, then attribute references – matching Asciidoctor's
614/// `apply_header_subs` (`HEADER_SUBS = [:specialcharacters, :attributes]`).
615///
616/// Running special characters *before* attribute references escapes any literal
617/// `<`, `>`, or `&` in the fragment while leaving the expanded value of an
618/// attribute reference untouched, so an attribute whose value already contains
619/// markup (or a character reference) is inserted verbatim rather than escaped a
620/// second time – exactly as Asciidoctor's header subs behave. Numeric character
621/// references in the literal text are preserved (see
622/// [`apply_author_special_characters`]).
623fn apply_author_subs(source: &str, parser: &Parser) -> String {
624 use crate::content::SubstitutionStep;
625
626 let with_special_characters = apply_author_special_characters(source, parser);
627
628 let span = Span::new(&with_special_characters);
629 let mut content = Content::from(span);
630
631 SubstitutionStep::AttributeReferences.apply(&mut content, parser, None);
632
633 content.rendered().to_string()
634}
635
636/// Apply the special-characters substitution to `source`, escaping every
637/// literal `<`, `>`, and `&` – except the leading `&` of a numeric HTML
638/// character reference, which is left intact so a name such as `AsciiDoc®`
639/// keeps its ® entity rather than degrading to `AsciiDoc&#174;` (issue
640/// #757).
641fn apply_author_special_characters(source: &str, parser: &Parser) -> String {
642 let mut result = String::with_capacity(source.len());
643 let mut last = 0;
644
645 // Escape the text between the character references, emitting each reference
646 // verbatim so its `&` is never doubled.
647 for m in NUMERIC_CHARACTER_REFERENCE.find_iter(source) {
648 result.push_str(&escape_special_characters(&source[last..m.start()], parser));
649 result.push_str(m.as_str());
650 last = m.end();
651 }
652
653 result.push_str(&escape_special_characters(&source[last..], parser));
654 result
655}
656
657/// Run the special-characters substitution step over `source`, escaping `<`,
658/// `>`, and `&`.
659fn escape_special_characters(source: &str, parser: &Parser) -> String {
660 if source.is_empty() {
661 return String::new();
662 }
663
664 let span = Span::new(source);
665 let mut content = Content::from(span);
666
667 crate::content::SubstitutionStep::SpecialCharacters.apply(&mut content, parser, None);
668
669 content.rendered().to_string()
670}
671
672/// Matches a numeric HTML character reference – decimal (`®`) or
673/// hexadecimal (`®`). Mirrors the guard the author line uses when deciding
674/// whether a semicolon separates two authors (see
675/// [`AuthorLine`](crate::document::AuthorLine)); the leading `&` of such a
676/// reference is preserved rather than escaped when header subs are applied.
677static NUMERIC_CHARACTER_REFERENCE: LazyLock<Regex> = LazyLock::new(|| {
678 #[allow(clippy::unwrap_used)]
679 Regex::new(r"&#(?:[0-9]+|[xX][0-9a-fA-F]+);").unwrap()
680});
681
682#[cfg(test)]
683mod tests {
684 #![allow(clippy::unwrap_used)]
685
686 use super::Author;
687
688 // The `<`-branch of Asciidoctor's `process_authors` (`names_only`), reached
689 // when an `:author:` attribute value's substitution produced inline HTML.
690 // The rendered markup – with name-joiner underscores turned to spaces –
691 // becomes `name`, while the name parts are partitioned from the tag-stripped
692 // text so the formatting does not leak into them. No email is split off.
693 mod parse_substituted_names_only {
694 use super::Author;
695
696 #[test]
697 fn empty_input_is_none() {
698 assert!(Author::parse_substituted_names_only("").is_none());
699 assert!(Author::parse_substituted_names_only(" ").is_none());
700 }
701
702 #[test]
703 fn markup_with_no_text_keeps_rendered_value_as_single_name() {
704 // Stripping the tags leaves nothing to partition, so the rendered
705 // markup stands as the whole name and `firstname`.
706 let author = Author::parse_substituted_names_only("<a href=\"x\"></a>").unwrap();
707 assert_eq!(author.name(), "<a href=\"x\"></a>");
708 assert_eq!(author.firstname(), "<a href=\"x\"></a>");
709 assert_eq!(author.middlename(), None);
710 assert_eq!(author.lastname(), None);
711 assert_eq!(author.email(), None);
712 }
713
714 #[test]
715 fn single_name_part() {
716 let author = Author::parse_substituted_names_only("<strong>Solo</strong>").unwrap();
717 assert_eq!(author.name(), "<strong>Solo</strong>");
718 assert_eq!(author.firstname(), "Solo");
719 assert_eq!(author.middlename(), None);
720 assert_eq!(author.lastname(), None);
721 }
722
723 #[test]
724 fn first_and_last_name() {
725 let author = Author::parse_substituted_names_only("<em>Kismet</em> Chameleon").unwrap();
726 assert_eq!(author.firstname(), "Kismet");
727 assert_eq!(author.middlename(), None);
728 assert_eq!(author.lastname(), Some("Chameleon"));
729 }
730
731 #[test]
732 fn first_middle_and_last_name() {
733 let author =
734 Author::parse_substituted_names_only("<em>Kismet</em> R. Chameleon").unwrap();
735 assert_eq!(author.firstname(), "Kismet");
736 assert_eq!(author.middlename(), Some("R."));
737 assert_eq!(author.lastname(), Some("Chameleon"));
738 }
739
740 #[test]
741 fn underscores_join_name_parts_and_the_rendered_name() {
742 // Underscores act as name joiners: within a part they become a
743 // space, and the full `name` has them replaced too.
744 let author = Author::parse_substituted_names_only("<b>Ze_Project</b> team").unwrap();
745 assert_eq!(author.name(), "<b>Ze Project</b> team");
746 assert_eq!(author.firstname(), "Ze Project");
747 assert_eq!(author.lastname(), Some("team"));
748 assert_eq!(author.email(), None);
749 }
750 }
751
752 // The entry dispatcher routes a *whole-value* `pass:[…]` macro to its
753 // substituted value (the resolved content) and every other value to the
754 // raw-value partitioning, so the literal macro syntax never leaks into the
755 // name parts.
756 mod parse_from_entry {
757 use super::Author;
758 use crate::Parser;
759
760 #[test]
761 fn pass_macro_with_markup_is_partitioned_from_the_substituted_value() {
762 // The raw is the macro syntax; the substituted value is its rendered
763 // output, which is what gets partitioned (tags stripped).
764 let parser = Parser::default();
765 let author = Author::parse_from_entry(
766 "pass:n[https://example.org/x[Ze *team*]]",
767 Some("<a href=\"https://example.org/x\">Ze <strong>team</strong></a>"),
768 &parser,
769 )
770 .unwrap();
771
772 assert_eq!(author.firstname(), "Ze");
773 assert_eq!(author.lastname(), Some("team"));
774 }
775
776 #[test]
777 fn pass_macro_resolving_to_plain_text_uses_the_substituted_value() {
778 // A pass macro whose result has no markup must still be partitioned
779 // from the substituted value, never the raw `pass:…[…]` syntax.
780 let parser = Parser::default();
781 let author =
782 Author::parse_from_entry("pass:n[Doc Writer]", Some("Doc Writer"), &parser)
783 .unwrap();
784
785 assert_eq!(author.name(), "Doc Writer");
786 assert_eq!(author.firstname(), "Doc");
787 assert_eq!(author.lastname(), Some("Writer"));
788 }
789
790 #[test]
791 fn non_pass_value_uses_raw_partitioning() {
792 // A value that is not a whole-value pass macro is partitioned from
793 // the raw value as before (the substituted value is not consulted).
794 let parser = Parser::default();
795 let author =
796 Author::parse_from_entry("Doc Writer", Some("Doc Writer"), &parser).unwrap();
797
798 assert_eq!(author.firstname(), "Doc");
799 assert_eq!(author.lastname(), Some("Writer"));
800 }
801
802 #[test]
803 fn empty_pass_macro_yields_no_author() {
804 // `pass:[]` resolves to an empty value, which describes no author.
805 let parser = Parser::default();
806 assert!(Author::parse_from_entry("pass:[]", Some(""), &parser).is_none());
807 }
808 }
809}