Repository: Textualize/textual

Issue:
[0] is being considered a markup tag
Have you checked closed issues? (https://github.com/Textualize/textual/issues?q=is%3Aissue+is%3Aclosed) *Yes*

Have you checked against the most recent version of Textual? (https://pypi.org/search/?q=textual) *Yes*

## The bug

* Trying to create a Static with markup=True with predefined text "[0] Hello World".
=> Crash

* Trying to escape the text results in a non-escaped string. 
=> Crash

According to McGugan:
> `[0]` shouldn't be considered a tag and shouldn't need escaping. If it is throwing a markup error, that is a bug. Please file an issue.

Error:
```
MarkupError: Expected markup style value (found '0] Hello World!').
```

MRE:
```python
from textual.app import App
from textual.widgets import Footer, Static
from textual.markup import escape
class TextualApp(App):

    DEFAULT_CSS = """
    /* any needed CSS  here */
    """

    def compose(self):
        yield Static(escape("[0] Hello World!"))
        yield Footer()

    # code that causes a bug here
```

textual diagnose:
```
<!-- This is valid Markdown, please paste the following directly in to a GitHub issue -->
# Textual Diagnostics

## Versions

| Name    | Value  |
|---------|--------|
| Textual | 3.1.1  |
| Rich    | 14.0.0 |

## Python

| Name           | Value                                |
|----------------|--------------------------------------|
| Version        | 3.12.9                               |
| Implementation | CPython                              |
| Compiler       | GCC 13.2.1 20240309                  |
| Executable     | /home/kuhnchris/test/venv/bin/python |

## Operating System

| Name    | Value                                             |
|---------|---------------------------------------------------|
| System  | Linux                                             |
| Release | 6.12.21-0-lts                                     |
| Version | #1-Alpine SMP PREEMPT_DYNAMIC 2025-04-01 11:09:17 |

## Terminal

| Name                 | Value          |
|----------------------|----------------|
| Terminal Application | *Unknown*      |
| TERM                 | xterm-256color |
| COLORTERM            | truecolor      |
| FORCE_COLOR          | *Not set*      |
| NO_COLOR             | *Not set*      |

## Rich Console options

| Name           | Value                |
|----------------|----------------------|
| size           | width=289, height=98 |
| legacy_windows | False                |
| min_width      | 1                    |
| max_width      | 289                  |
| is_terminal    | True                 |
| encoding       | utf-8                |
| max_height     | 98                   |
| justify        | None                 |
| overflow       | None                 |
| no_wrap        | False                |
| highlight      | None                 |
| markup         | None                 |
| height         | None                 |

```

Technical workaround:
(by David Fokkema)
```
import re

escape("[1]", _escape=re.compile(r"(\\*)(\[[a-z0-9#/@][^[]*?])").sub)
```

(by kuhnchris)
```
outputText.replace("[", "\\[")
```

Full Stack log (textual run --dev):

[fullStackTrace.txt](https://github.com/user-attachments/files/19912727/fullStackTrace.txt)

Discussion:
@github-actions: We found the following entries in the [FAQ](https://textual.textualize.io/FAQ/) which you may find helpful:


- [Does Textual support images?](https://textual.textualize.io/FAQ/#does-textual-support-images)
- [Why doesn't Textual look good on macOS?](https://textual.textualize.io/FAQ/#why-doesnt-textual-look-good-on-macos)

Feel free to close this issue if you found an answer in the FAQ. Otherwise, please give us a little time to review.

This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)@python-and-novella: The uppercase also should be considered:
```python3
from textual.app import App
from textual.widgets import Static
from textual.markup import escape
import re

class MyApp(App):
    def on_mount(self):
        self.widgets = [ 
                Static(escape('[0 W] Hello World!',re.compile(r'(\\*)(\[[0-9a-zA-Z#/@][^[]*?])').sub)),
            ]
        self.mount_all(self.widgets)

if __name__ == '__main__':
    app = MyApp()
    app.run()
```@nightlymizu: Not sure if I misunderstood how to use `escape()`, but it seems to not work properly on many strings.
Below are some strings I tested that cause issues:

```python
from __future__ import annotations

from typing import Final

from textual.app import App, ComposeResult
from textual.markup import escape
from textual.widgets import Static

TEST_STRINGS: Final = [
    'foo [123] bar',  # MarkupError
    'foo [12,345 items] bar',  # MarkupError
    'foo [ word ] bar',  # incorrectly remove "[ word ]" from the text
    'foo [ Checked , Alpha 20 ] bar',  # MarkupError
    'foo [Super] bar',  # incorrectly remove "[Super]" from the text
    'foo [中文] bar',  # MarkupError
    'path\\[123]\\to\\file',  # incorrectly remove the first backslash from the text
    'path\\[xyz]\\to\\file',  # incorrectly add an extra backslash to the text
    '[123][456]',  # MarkupError
    '[123][456][/123]',  # MarkupError
    '\\[yes]/',  # incorrectly add an extra backslash to the text
    '\\\\\\[yes] \\\\ \\[no]',  # incorrectly add extra backslashes to the text
    ]


class TestEscapeApp(App):
    def compose(self) -> ComposeResult:
        for text in TEST_STRINGS:
            yield Static(escape(text))


if __name__ == '__main__':
    app = TestEscapeApp()
    app.run()

```

Tested on the following version:

```
Textual 3.3.0
Rich    14.0.0
```

The workaround provided by kuhnchris appears to work (though not thoroughly tested):

```python
text.replace('[', '\\[')
```
@github-actions: Don't forget to [star](https://github.com/Textualize/textual) the repository!

Follow [@textualizeio](https://twitter.com/textualizeio) for Textual updates.

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 190c9923f..5c7debf71 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 
 - Added experimental opt-in support for https://github.com/willmcgugan/textual-speedups
 
+### Changed
+
+- Content markup is now more lenient; if a 'tag' doesn't contain a valid style it will be included verbatim. https://github.com/Textualize/textual/pull/5851
+
 ## [3.3.0] - 2025-06-01
 
 ### Fixed
diff --git a/src/textual/markup.py b/src/textual/markup.py
index c2a4571bd..60abb9f5c 100644
--- a/src/textual/markup.py
+++ b/src/textual/markup.py
@@ -48,8 +48,9 @@ expect_markup_tag = (
         variable_ref=VARIABLE_REF,
         whitespace=r"\s+",
     )
-    .expect_eof(False)
+    .expect_eof(True)
     .expect_semicolon(False)
+    .extract_text(True)
 )
 
 expect_markup = Expect(
@@ -374,14 +375,36 @@ def _to_content(
         elif token_name == "open_tag":
             tag_text = []
 
+            eof = False
+            contains_text = False
             for token in iter_tokens:
                 if token.name == "end_tag":
                     break
+                elif token.name == "text":
+                    contains_text = True
+                elif token.name == "eof":
+                    eof = True
                 tag_text.append(token.value)
-            opening_tag = "".join(tag_text).strip()
-            style_stack.append(
-                (position, opening_tag, normalize_markup_tag(opening_tag))
-            )
+            if contains_text or eof:
+                # "tag" was unparsable
+                text_content = f"[{''.join(tag_text)}" + ("" if eof else "]")
+                text_append(text_content)
+                position += len(text_content)
+            else:
+                opening_tag = "".join(tag_text)
+
+                if not opening_tag.strip():
+                    blank_tag = f"[{opening_tag}]"
+                    text_append(blank_tag)
+                    position += len(blank_tag)
+                else:
+                    style_stack.append(
+                        (
+                            position,
+                            opening_tag,
+                            normalize_markup_tag(opening_tag.strip()),
+                        )
+                    )
 
         elif token_name == "open_closing_tag":
             tag_text = []


Test patch:
diff --git a/tests/test_content.py b/tests/test_content.py
index 9de62d21e..a5c459f3f 100644
--- a/tests/test_content.py
+++ b/tests/test_content.py
@@ -229,6 +229,7 @@ def test_assemble():
         ("\\[/foo", "[/foo"),
         ("\\[/foo]", "[/foo]"),
         ("\\[]", "[]"),
+        ("\\[0]", "[0]"),
     ],
 )
 def test_escape(markup: str, plain: str) -> None:
@@ -273,14 +274,3 @@ def test_first_line():
     first_line = content.first_line
     assert first_line.plain == "foo"
     assert first_line.spans == [Span(0, 3, "red")]
-
-
-def test_errors():
-    with pytest.raises(Exception):
-        Content.from_markup("[")
-
-    with pytest.raises(Exception):
-        Content.from_markup("[:")
-
-    with pytest.raises(Exception):
-        Content.from_markup("[foo")
diff --git a/tests/test_markup.py b/tests/test_markup.py
index 93e2a37b3..346b0a903 100644
--- a/tests/test_markup.py
+++ b/tests/test_markup.py
@@ -10,6 +10,15 @@ from textual.markup import MarkupError, to_content
     ["markup", "content"],
     [
         ("", Content("")),
+        ("[", Content("[")),
+        ("[]", Content("[]")),
+        ("[ ", Content("[ ")),
+        ("[  ", Content("[  ")),
+        ("[  ]", Content("[  ]")),
+        ("[0", Content("[0")),
+        ("[0]", Content("[0]")),
+        ("[red", Content("[red")),
+        ("[red]", Content("")),
         ("foo", Content("foo")),
         ("foo\n", Content("foo\n")),
         ("foo\nbar", Content("foo\nbar")),
@@ -152,8 +161,6 @@ def test_to_content(markup: str, content: Content):
 
 
 def test_content_parse_fail() -> None:
-    with pytest.raises(MarkupError):
-        to_content("[rgb(1,2,3,4)]foo")
     with pytest.raises(MarkupError):
         to_content("[foo]foo[/bar]")
     with pytest.raises(MarkupError):