diff --git a/mdit_py_plugins/attrs/index.py b/mdit_py_plugins/attrs/index.py index 0f608c8..124b6d0 100644 --- a/mdit_py_plugins/attrs/index.py +++ b/mdit_py_plugins/attrs/index.py @@ -41,8 +41,14 @@ def attrs_plugin( - `key="value"` or `key=value` specifies a key-value attribute. Quotes are not needed when the value consists entirely of ASCII alphanumeric characters or `_` or `:` or `-`. - Backslash escapes may be used inside quoted values. - - `%` begins a comment, which ends with the next `%` or the end of the attribute (`}`). + Backslash escapes may be used inside quoted values, + to allow a ``"`` character within the value. + Note that the backslash is retained in the value: + ``{k="a\\"b"}`` gives ``k`` the value ``a\\"b``. + - `%` begins a comment, which ends with the next `%`. + If no further `%` occurs in the rest of the line (for a block attribute) + or of the paragraph (for an inline attribute), + the comment instead ends at the next `}`, which also ends the attribute. Multiple attribute blocks are merged. @@ -175,8 +181,8 @@ def _attr_inline_rule( state.pos += new_pos + 1 if not silent: attr_token = state.tokens[token_index] - if "class" in attrs and "class" in token.attrs: - attrs["class"] = f"{token.attrs['class']} {attrs['class']}" + if "class" in attrs and "class" in attr_token.attrs: + attrs["class"] = f"{attr_token.attrs['class']} {attrs['class']}" _add_attrs(attr_token, attrs, allowed) return True @@ -238,7 +244,8 @@ def _attr_resolve_block_rule(state: StateCore, *, allowed: set[str] | None) -> N i += 1 continue - if i + 1 < len_tokens: + # skip closing tokens; attributes would be rendered into the closing tag + if i + 1 < len_tokens and state.tokens[i + 1].nesting >= 0: next_token = state.tokens[i + 1] # classes are appended diff --git a/mdit_py_plugins/attrs/parse.py b/mdit_py_plugins/attrs/parse.py index 061574f..605bbfb 100644 --- a/mdit_py_plugins/attrs/parse.py +++ b/mdit_py_plugins/attrs/parse.py @@ -100,17 +100,28 @@ def parse(string: str) -> tuple[int, dict[str, str]]: """Parse attributes from start of string. :returns: (length of parsed string, dict of attributes) + :raises ParseError: if the attributes are malformed, + or the string ends before the closing `}` """ pos = 0 state: State = State.START tokens = TokenState() while pos < len(string): - state = HANDLERS[state](string[pos], pos, tokens) + if ( + state == State.SCANNING_COMMENT + and string[pos] == "}" + and "%" not in string[pos + 1 :] + ): + # a comment ends at the next `%`, but there is none left to close it, + # so this `}` ends both the comment and the attributes + state = State.DONE + else: + state = HANDLERS[state](string[pos], pos, tokens) if state == State.DONE: return pos, tokens.compile(string) pos = pos + 1 - return pos, tokens.compile(string) + raise ParseError("Attributes not terminated", pos) def handle_start(char: str, pos: int, tokens: TokenState) -> State: diff --git a/tests/fixtures/attrs.md b/tests/fixtures/attrs.md index b6d3890..6523b94 100644 --- a/tests/fixtures/attrs.md +++ b/tests/fixtures/attrs.md @@ -61,6 +61,61 @@ a = 1 . +block: attrs last in a blockquote +. +> {.a} + +para +. +
+

para

+. + +block: attrs last in a list item +. +- {.a} +. + +. + +block: attrs last in an ordered list item +. +1. {.a} +. +
    +
  1. +
+. + +block: two attrs blocks last in a blockquote +. +> {.a} +> {.b} +. +
+. + +block: attrs before a blockquote containing only attrs +. +{.a} +{.b} +> {.c} +. +
+. + +block: attrs followed by a paragraph in a blockquote +. +> {.a} +> para +. +
+

para

+
+. + block after paragraph . a @@ -70,6 +125,64 @@ a {#a .a c=1}

. +unterminated: block group is not an attribute block +. +{.a +para +. +

{.a +para

+. + +comment: block +. +{%x} +para +. +

para

+. + +comment: block after a class +. +{.a %c} +para +. +

para

+. + +comment: block closed by a percent +. +{% just a comment %} +para +. +

para

+. + +comment: block with a brace inside the comment +. +{% x } y %} +para +. +

para

+. + +comment: block, attributes after a brace inside the comment +. +{% c } % .b} +para +. +

para

+. + +comment: block, a brace inside the comment does not terminate the group +. +{% see } below %{x} +para +. +

{% see } below %{x} +para

+. + simple reference link . @@ -101,6 +214,13 @@ simple inline code

a

. +quoted value keeps a backslash escape +. +`a`{k="a\"b"} +. +

a

+. + ignore if space . ![a](b) {#id key="*"} @@ -134,6 +254,134 @@ merging attributes

a

. +unterminated: inline code +. +`a`{ +. +

a{

+. + +unterminated: inline code with a class +. +`a`{.a +. +

a{.a

+. + +unterminated: link +. +[a](u){ +. +

a{

+. + +unterminated: image +. +![a](u){.x +. +

a{.x

+. + +unterminated: following text is not consumed +. +`a`{.a +more +. +

a{.a +more

+. + +unterminated: group after a terminated group +. +`a`{.a}{ +. +

a{

+. + +unterminated: partially scanned attributes are not applied +. +`a`{ .a b +. +

a{ .a b

+. + +unterminated: escaped brace inside a quoted value +. +`a`{k="a\} +. +

a{k="a}

+. + +unterminated: inside a link label +. +[x `a`{.a](u) +. +

x a{.a

+. + +comment: inline code +. +`a`{%c} +. +

a

+. + +comment: after a class +. +`a`{.a %c} +. +

a

+. + +comment: link +. +[a](u){%c} +. +

a

+. + +comment: closed by a brace, following text is kept +. +`a`{%a}b +. +

ab

+. + +comment: a brace inside a comment closed by a percent +. +`a`{% x } y %} +. +

a

+. + +comment: attributes after a brace inside the comment +. +`a`{.a % c } % #i} +. +

a

+. + +comment: an empty comment containing a brace +. +`a`{% } %} +. +

a

+. + +comment: text after a brace-closed comment is kept +. +`a`{.a %c} and {more} +. +

a and {more}

+. + +comment: a later percent keeps a brace-closed comment open +. +`a`{.a %c} and 100% sure +. +

a{.a %c} and 100% sure

+. + spans: simple . [a]{#id .b}c @@ -176,6 +424,34 @@ spans: escaped span attribute

[a]{.b}

. +spans: unterminated attributes are not a span +. +[a]{ +. +

[a]{

+. + +spans: unterminated attributes with a class are not a span +. +[a]{.x +. +

[a]{.x

+. + +spans: comment +. +[a]{%c} +. +

a

+. + +spans: a brace inside an unterminated comment is not a span +. +[a]{% a } %{b} +. +

[a]{% a } %{b}

+. + spans: nested text syntax . [*a*]{.b}c @@ -238,7 +514,49 @@ spans: merge attributes . [a]{#a .a}{#b .a .b other=c}{other=d} . -

a

+

a

+. + +spans: merge classes from two groups +. +[a]{.x}{.y} +. +

a

+. + +spans: merge classes from three groups +. +[a]{.x}{.y}{.z} +. +

a

+. + +spans: merge classes with an id in each group +. +[a]{.x #p}{.y #q} +. +

a

+. + +spans: merge classes on the outer of nested spans +. +[[a]{.i}]{.x}{.y} +. +

a

+. + +links: merge classes from two groups +. +[a](u){.x}{.y} +. +

a

+. + +links: merge classes from three groups +. +[a](u){.x}{.y}{.z} +. +

a

. Indented by 4 spaces