diff --git a/AGENTS.md b/AGENTS.md
index 75b64a0..b815c70 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -218,7 +218,7 @@ labhelper/
│ │ ├── search.html # Search page with AJAX
│ │ └── thing_detail.html # Thing details view with tags
│ ├── templatetags/
-│ │ └── dict_extras.py # Custom template filter: get_item
+│ │ └── dict_extras.py # Custom template filters: get_item, render_markdown, truncate_markdown
│ ├── admin.py # Admin configuration
│ ├── apps.py # App configuration
│ ├── forms.py # All forms and formsets
@@ -260,7 +260,7 @@ labhelper/
| **Box** | A storage box in the lab | `id` (CharField PK, max 10), `box_type` (FK) |
| **Facet** | A category of tags (e.g., Priority, Category) | `name`, `slug`, `color`, `cardinality` (single/multiple) |
| **Tag** | A tag value for a specific facet | `facet` (FK), `name` |
-| **Thing** | An item stored in a box | `name`, `box` (FK), `description`, `picture`, `tags` (M2M) |
+| **Thing** | An item stored in a box | `name`, `box` (FK), `description` (Markdown), `picture`, `tags` (M2M) |
| **ThingFile** | File attachment for a Thing | `thing` (FK), `file`, `title`, `uploaded_at` |
| **ThingLink** | Hyperlink for a Thing | `thing` (FK), `url`, `title`, `uploaded_at` |
@@ -397,41 +397,86 @@ The project uses a base template system at `labhelper/templates/base.html`. All
### Template Best Practices
1. **Always extend base template**
- ```django
- {% extends "base.html" %}
- ```
+ ```django
+ {% extends "base.html" %}
+ ```
2. **Use block system for content injection**
- - `title`: Page title tag
- - `page_header`: Page header with breadcrumbs
- - `content`: Main page content
- - `extra_css`: Additional styles
- - `extra_js`: Additional JavaScript
+ - `title`: Page title tag
+ - `page_header`: Page header with breadcrumbs
+ - `content`: Main page content
+ - `extra_css`: Additional styles
+ - `extra_js`: Additional JavaScript
3. **Load required template tags**
- ```django
- {% load static %}
- {% load mptt_tags %}
- {% load thumbnail %}
- ```
+ ```django
+ {% load static %}
+ {% load mptt_tags %}
+ {% load thumbnail %}
+ {% load dict_extras %}
+ ```
4. **Use URL names for links**
- ```django
-
- ```
+ ```django
+
+ ```
5. **Use icons with Font Awesome**
- ```django
-
- ```
+ ```django
+
+ ```
6. **Add breadcrumbs for navigation**
- ```django
-
- ```
+ ```django
+
+ ```
+
+### Markdown Support
+
+The `Thing.description` field supports Markdown formatting with HTML sanitization for security.
+
+**Available Template Filters:**
+
+- `render_markdown`: Converts Markdown text to sanitized HTML with automatic link handling
+ - Converts Markdown syntax (headers, lists, bold, italic, links, code, tables, etc.)
+ - Sanitizes HTML using `bleach` to prevent XSS attacks
+ - Automatically adds `target="_blank"` and `rel="noopener noreferrer"` to external links
+ - Use in `thing_detail.html` for full rendered Markdown
+
+- `truncate_markdown`: Converts Markdown to plain text and truncates
+ - Strips HTML tags after Markdown conversion
+ - Adds ellipsis (`...`) if text exceeds specified length (default: 100)
+ - Use in `box_detail.html` or search API previews where space is limited
+
+**Usage Examples:**
+```django
+
+
| ', result) + self.assertIn(' | ', result) + + def test_truncate_markdown_basic(self): + """truncate_markdown filter should truncate and strip Markdown.""" + from boxes.templatetags.dict_extras import truncate_markdown + result = truncate_markdown('**bold** text here', 10) + self.assertNotIn('**', result) + self.assertNotIn('', result) + + def test_truncate_markdown_long_text(self): + """truncate_markdown filter should add ellipsis for long text.""" + from boxes.templatetags.dict_extras import truncate_markdown + long_text = 'This is a very long text that should be truncated' + result = truncate_markdown(long_text, 20) + self.assertTrue(result.endswith('...')) + self.assertLessEqual(len(result), 25) + + def test_truncate_markdown_empty(self): + """truncate_markdown filter should handle empty strings.""" + from boxes.templatetags.dict_extras import truncate_markdown + result = truncate_markdown('') + self.assertEqual(result, '') + + def test_truncate_markdown_short_text(self): + """truncate_markdown filter should not truncate short text.""" + from boxes.templatetags.dict_extras import truncate_markdown + result = truncate_markdown('Short', 100) + self.assertEqual(result, 'Short') + self.assertNotIn('...', result) + + +class MarkdownInViewsTests(AuthTestCase): + """Tests for Markdown rendering in views.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.box_type = BoxType.objects.create( + name='Standard Box', + width=200, + height=100, + length=300 + ) + self.box = Box.objects.create( + id='BOX001', + box_type=self.box_type + ) + + def test_thing_detail_renders_markdown(self): + """Thing detail page should render Markdown in description.""" + thing = Thing.objects.create( + name='Test Item', + box=self.box, + description='**Bold text** and *italic*' + ) + response = self.client.get(f'/thing/{thing.id}/') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Bold text') + self.assertContains(response, 'italic') + + def test_thing_detail_renders_markdown_links(self): + """Thing detail page should render Markdown links with target blank.""" + thing = Thing.objects.create( + name='Test Item', + box=self.box, + description='Check [this link](https://example.com)' + ) + response = self.client.get(f'/thing/{thing.id}/') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'href="https://example.com"') + self.assertContains(response, 'target="_blank"') + + def test_thing_detail_sanitizes_html(self): + """Thing detail page should sanitize dangerous HTML in description.""" + thing = Thing.objects.create( + name='Test Item', + box=self.box, + description='Normal text' + ) + response = self.client.get(f'/thing/{thing.id}/') + self.assertEqual(response.status_code, 200) + # The description should not contain script tags (they are stripped) + # Note: The base template has a |
|---|