Skip to content

Instantly share code, notes, and snippets.

@jaredh159
Created January 6, 2026 13:46
Show Gist options
  • Select an option

  • Save jaredh159/3e64c04403fd08a3438be20b204f22f0 to your computer and use it in GitHub Desktop.

Select an option

Save jaredh159/3e64c04403fd08a3438be20b204f22f0 to your computer and use it in GitHub Desktop.
asciidoc footnote double-macro issue

see: https://docs.asciidoctor.org/asciidoc/latest/macros/footnote/#externalizing-a-footnote

see: https://asciidoc.zulipchat.com/#narrow/channel/335219-asciidoc-lang/topic/question.20about.20complex.20footnote.20example/with/566545458

assert_html!(
  horror,
  adoc! {r#"
    :fn-foo: pass:c,q[footnote:foo[formatted _text_]]

    Hi.{fn-foo}
  "#},
  html! {r##"
    <div class="paragraph">
    <p>Hi.<sup class="footnote" id="_footnote_foo">[<a id="_footnoteref_1" class="footnote" href="#_footnotedef_1" title="View footnote.">1</a>]</sup></p>
    </div>
    <div id="footnotes">
    <hr>
    <div class="footnote" id="_footnotedef_1">
    <a href="#_footnoteref_1">1</a>. formatted <em>text</em>
    </div>
    </div>
  "##}
);
@jaredh159
Copy link
Author

my conversation with claude, TLDR; this is special-cased in asciidoctor, any attr def WRAPPED in a pass: get's special treatment:

The Answer

  You're asking exactly the right question, and understanding this reveals an elegant aspect of Asciidoctor's design. The pass macro is NOT "honoring" the footnote macro - it's preserving it as literal text for later processing.

  Here's the two-phase process:

  Phase 1: Attribute Definition Time

  When Asciidoctor parses:
  :fn-disclaimer: pass:c,q[footnote:disclaimer[Opinions are _mine_, and mine *alone*.]]

  In lib/asciidoctor/document.rb:1102-1108:
  def apply_attribute_value_subs value
    if AttributeEntryPassMacroRx =~ value
      value = $2
      value = apply_subs value, (resolve_pass_subs $1) if $1
    else
      value = apply_header_subs value
    end
    ...
  end

  The pass:c,q[...] is matched, extracting:
  - $1 = c,q (the substitution spec)
  - $2 = footnote:disclaimer[Opinions are _mine_, and mine *alone*.] (the content)

  Only c (specialcharacters) and q (quotes) subs are applied. This transforms:
  - _mine_ → <em>mine</em>
  - *alone* → <strong>alone</strong>

  But footnote:disclaimer[...] is left as literal text because the :macros substitution was not enabled.

  The stored attribute value becomes:
  footnote:disclaimer[Opinions are <em>mine</em>, and mine <strong>alone</strong>.]

  Phase 2: Attribute Usage Time

  When you write:
  A bold statement!{fn-disclaimer}

  The {fn-disclaimer} is substituted, placing that literal text into the line. Then normal substitutions are applied to the paragraph content, which include :macros. At this point, sub_macros (in substitutors.rb:835-877) matches the footnote:disclaimer[...] pattern and processes it.

  The Design Insight

  This is intentional! The pass: macro lets you defer macro processing. By wrapping the footnote in pass:c,q[...], you:
  1. Apply quotes substitution at definition time (so _mine_ gets formatted)
  2. Preserve the footnote macro text literally
  3. Let the footnote be processed when the attribute is actually used

  This is useful because footnotes are registered at the point they appear in the document flow. Defining them in header attributes and expanding them where needed gives you control over the footnote numbering.

> so they special case an attr def that starts with pass:  is that correct?

⏺ Yes, exactly. Look at the regex in lib/asciidoctor/rx.rb:141:

  AttributeEntryPassMacroRx = /\Apass:([a-z]+(?:,[a-z-]+)*)?\[(.*)\]\Z/m

  The \A and \Z anchors mean it must match the entire attribute value from start to end. So this only triggers when the whole value is wrapped in pass:[...].

  And then in document.rb:1102-1108:

  def apply_attribute_value_subs value
    if AttributeEntryPassMacroRx =~ value
      value = $2                                        # extract content
      value = apply_subs value, (resolve_pass_subs $1) if $1  # apply specified subs
    else
      value = apply_header_subs value                   # normal header subs
    end
    ...
  end

  So it's an either/or:
  - If the value is pass:...[...] → use only the specified subs
  - Otherwise → use HEADER_SUBS (:specialcharacters, :attributes)

  This is distinct from how pass: works inline (where it's extracted during extract_passthroughs as part of the :macros substitution). For attribute values, it's a dedicated check that runs before any substitutions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment