Add new templates and tests for improved functionality
- Created index.html template for the homepage with service cards and partner logos. - Added page_from_md.html template for rendering pages from markdown. - Developed services.html template detailing various services offered. - Implemented tests for link handling in markdown, ensuring external links open in new tabs and internal links function correctly. - Enhanced markdown parser tests to validate heading extraction, content rendering, and link safety. - Introduced utility tests for template rendering, HTML minification, and JavaScript minification. Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
from lib.markdown_parser import markdown_to_html_lines
|
||||
|
||||
|
||||
def test_external_link_gets_target_rel():
|
||||
md = '[external](https://example.com)'
|
||||
html = markdown_to_html_lines(md)
|
||||
assert 'href="https://example.com"' in html
|
||||
assert 'target="_blank"' in html
|
||||
assert 'rel="noopener noreferrer"' in html
|
||||
|
||||
|
||||
def test_internal_link_no_target():
|
||||
md = '[internal](/about)'
|
||||
html = markdown_to_html_lines(md)
|
||||
assert 'href="/about"' in html
|
||||
assert 'target=' not in html
|
||||
@@ -0,0 +1,133 @@
|
||||
import textwrap
|
||||
from lib import markdown_parser
|
||||
|
||||
|
||||
def make_empty_state():
|
||||
return {
|
||||
'page': {'title': None, 'sections': []},
|
||||
'current_section': None,
|
||||
'current_card': None,
|
||||
'current_detail': None,
|
||||
'content_buffer': [],
|
||||
'detail_buffer': [],
|
||||
}
|
||||
|
||||
|
||||
def test_flush_detail_buffer():
|
||||
state = make_empty_state()
|
||||
state['current_detail'] = {'title': 'D', 'content': ''}
|
||||
state['detail_buffer'] = ['Line 1', 'Line 2']
|
||||
|
||||
markdown_parser.flush_detail_buffer(state)
|
||||
|
||||
assert 'Line 1' in state['current_detail']['content']
|
||||
assert state['detail_buffer'] == []
|
||||
|
||||
|
||||
def test_flush_content_buffer_to_card():
|
||||
state = make_empty_state()
|
||||
state['current_card'] = {'title': 'C', 'content': ''}
|
||||
state['content_buffer'] = ['para 1', 'para 2']
|
||||
|
||||
markdown_parser.flush_content_buffer_to_card(state)
|
||||
|
||||
assert 'para 1' in state['current_card']['content']
|
||||
|
||||
|
||||
def test_flush_content_buffer_to_section():
|
||||
state = make_empty_state()
|
||||
state['current_section'] = {'title': 'S', 'content': '', 'cards': []}
|
||||
state['content_buffer'] = ['a', 'b']
|
||||
|
||||
markdown_parser.flush_content_buffer_to_section(state)
|
||||
|
||||
assert 'a' in state['current_section']['content']
|
||||
|
||||
|
||||
def test_start_section_and_card_and_detail_and_process_line():
|
||||
state = make_empty_state()
|
||||
|
||||
# start a section
|
||||
markdown_parser.start_section('Sec1', state)
|
||||
assert state['current_section']['title'] == 'Sec1'
|
||||
assert state['page']['sections'][0]['title'] == 'Sec1'
|
||||
|
||||
# start a card
|
||||
markdown_parser.start_card('Card1', state)
|
||||
assert state['current_card']['title'] == 'Card1'
|
||||
assert state['page']['sections'][0]['cards'][0]['title'] == 'Card1'
|
||||
|
||||
# start a detail
|
||||
markdown_parser.start_detail('Det1', state)
|
||||
assert state['current_detail']['title'] == 'Det1'
|
||||
|
||||
# process a normal content line into detail_buffer
|
||||
markdown_parser.process_line_with_state('Detail line 1', state)
|
||||
assert 'Detail line 1' in state['detail_buffer'][0]
|
||||
|
||||
# process an H3 to start a new card
|
||||
markdown_parser.process_line_with_state('### NewCard', state)
|
||||
assert state['current_card']['title'] == 'NewCard'
|
||||
|
||||
# process an H2 to start a new section
|
||||
markdown_parser.process_line_with_state('## NewSection', state)
|
||||
assert state['current_section']['title'] == 'NewSection'
|
||||
|
||||
|
||||
def test_start_card_without_section_is_noop():
|
||||
state = make_empty_state()
|
||||
|
||||
markdown_parser.start_card('Orphan', state)
|
||||
|
||||
assert state['current_card'] is None
|
||||
assert state['page']['sections'] == []
|
||||
|
||||
|
||||
def test_start_detail_without_card_is_noop():
|
||||
state = make_empty_state()
|
||||
|
||||
markdown_parser.start_detail('Detail', state)
|
||||
|
||||
assert state['current_detail'] is None
|
||||
assert state['detail_buffer'] == []
|
||||
|
||||
|
||||
def test_process_h4_without_card_is_treated_as_content():
|
||||
state = make_empty_state()
|
||||
|
||||
markdown_parser.process_line_with_state('#### Heading without card', state)
|
||||
|
||||
assert '#### Heading without card' in state['content_buffer']
|
||||
|
||||
|
||||
def test_process_new_h1_flushes_section_content():
|
||||
state = make_empty_state()
|
||||
markdown_parser.start_section('First', state)
|
||||
state['content_buffer'].append('Paragraph text')
|
||||
|
||||
markdown_parser.process_line_with_state('# New Page', state)
|
||||
|
||||
assert 'Paragraph text' in state['page']['sections'][0]['content']
|
||||
assert state['page']['title'] == 'New Page'
|
||||
|
||||
|
||||
def test_build_component_structure_matches_integration():
|
||||
md = textwrap.dedent("""
|
||||
# Title
|
||||
|
||||
## Section A
|
||||
|
||||
### Card A
|
||||
Intro A
|
||||
|
||||
#### Detail X
|
||||
Line X
|
||||
""")
|
||||
|
||||
page = markdown_parser.build_component_structure(md, 'f.md')
|
||||
assert page['title'] == 'Title'
|
||||
assert page['sections'][0]['title'] == 'Section A'
|
||||
card = page['sections'][0]['cards'][0]
|
||||
assert card['title'] == 'Card A'
|
||||
assert 'Intro A' in card['content']
|
||||
assert 'Detail X' in card['details'][0]['title']
|
||||
@@ -0,0 +1,351 @@
|
||||
import textwrap
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from markdown import Markdown
|
||||
from xml.etree.ElementTree import Element, SubElement
|
||||
from lib.markdown_parser import (
|
||||
HeadingCollector,
|
||||
HeadingExtension,
|
||||
build_component_structure,
|
||||
markdown_to_html_lines,
|
||||
parse_markdown_file,
|
||||
)
|
||||
|
||||
|
||||
def test_h4_creates_card_details():
|
||||
md = textwrap.dedent("""
|
||||
# Page Title
|
||||
|
||||
## Section One
|
||||
|
||||
### Card One
|
||||
Card intro paragraph.
|
||||
|
||||
#### Detail A
|
||||
Detail content line 1.
|
||||
- item 1
|
||||
- item 2
|
||||
|
||||
#### Detail B
|
||||
Another detail paragraph with [a link](https://example.com).
|
||||
""")
|
||||
|
||||
page = build_component_structure(md, "test.md")
|
||||
|
||||
assert page['title'] == 'Page Title'
|
||||
assert len(page['sections']) == 1
|
||||
section = page['sections'][0]
|
||||
assert section['title'] == 'Section One'
|
||||
assert len(section['cards']) == 1
|
||||
card = section['cards'][0]
|
||||
assert card['title'] == 'Card One'
|
||||
# content should include the intro paragraph converted to HTML
|
||||
assert 'Card intro paragraph' in card['content']
|
||||
# details should be present
|
||||
assert 'details' in card
|
||||
assert len(card['details']) == 2
|
||||
assert card['details'][0]['title'] == 'Detail A'
|
||||
assert 'item 1' in card['details'][0]['content']
|
||||
assert card['details'][1]['title'] == 'Detail B'
|
||||
assert 'https://example.com' in card['details'][1]['content']
|
||||
|
||||
|
||||
def test_section_content_preserves_lists_before_first_card():
|
||||
md = textwrap.dedent("""
|
||||
# Title
|
||||
|
||||
## Section One
|
||||
|
||||
- Item A
|
||||
- Item B
|
||||
|
||||
### Card Title
|
||||
Card body
|
||||
""")
|
||||
|
||||
page = build_component_structure(md, "test.md")
|
||||
|
||||
section = page['sections'][0]
|
||||
assert '<ul>' in section['content']
|
||||
assert '<li>Item A</li>' in section['content']
|
||||
assert section['cards'][0]['title'] == 'Card Title'
|
||||
|
||||
|
||||
def test_card_content_keeps_lists_when_section_changes():
|
||||
md = textwrap.dedent("""
|
||||
# Title
|
||||
|
||||
## Section One
|
||||
|
||||
### Card One
|
||||
- First
|
||||
- Second
|
||||
|
||||
## Section Two
|
||||
Details
|
||||
""")
|
||||
|
||||
page = build_component_structure(md, "test.md")
|
||||
|
||||
first_section = page['sections'][0]
|
||||
card = first_section['cards'][0]
|
||||
assert '<ul>' in card['content']
|
||||
assert '<li>First</li>' in card['content']
|
||||
|
||||
|
||||
def test_card_body_renders_list_items():
|
||||
md = textwrap.dedent("""
|
||||
# Title
|
||||
|
||||
## Section
|
||||
|
||||
### Card
|
||||
- Alpha
|
||||
- Beta
|
||||
- Gamma
|
||||
""")
|
||||
|
||||
page = build_component_structure(md, "test.md")
|
||||
|
||||
card = page['sections'][0]['cards'][0]
|
||||
assert '<ul>' in card['content']
|
||||
assert '<li>Alpha</li>' in card['content']
|
||||
assert '<li>Gamma</li>' in card['content']
|
||||
|
||||
|
||||
def test_external_links_add_target_attributes():
|
||||
md = textwrap.dedent("""
|
||||
# Title
|
||||
|
||||
## Section
|
||||
|
||||
Visit [Example](https://example.com) now.
|
||||
""")
|
||||
|
||||
page = build_component_structure(md, "test.md")
|
||||
|
||||
section_html = page['sections'][0]['content']
|
||||
soup = BeautifulSoup(section_html, 'html.parser')
|
||||
anchor = soup.find('a')
|
||||
assert anchor is not None
|
||||
assert anchor['href'] == 'https://example.com'
|
||||
assert anchor.get('target') == '_blank'
|
||||
assert anchor.get('rel') == ['noopener', 'noreferrer']
|
||||
|
||||
|
||||
def test_unsafe_links_are_neutralized():
|
||||
md = textwrap.dedent("""
|
||||
# Title
|
||||
|
||||
## Section
|
||||
|
||||
[Bad](javascript:alert('xss'))
|
||||
""")
|
||||
|
||||
page = build_component_structure(md, "test.md")
|
||||
|
||||
section_html = page['sections'][0]['content']
|
||||
soup = BeautifulSoup(section_html, 'html.parser')
|
||||
anchor = soup.find('a')
|
||||
assert anchor is not None
|
||||
assert anchor['href'] == '#unsafe'
|
||||
assert 'target' not in anchor.attrs
|
||||
assert 'rel' not in anchor.attrs
|
||||
|
||||
|
||||
def test_index_page_sections_and_headings_extracted():
|
||||
text = """
|
||||
# Title
|
||||
|
||||
Main intro paragraph.
|
||||
|
||||
## Section 1
|
||||
|
||||
Section 1 intro.
|
||||
|
||||
- List item 1 a
|
||||
- List item 1 b
|
||||
|
||||
## Section 2
|
||||
|
||||
Section 2 intro.
|
||||
|
||||
- List item 2 a
|
||||
- List item 2 b
|
||||
|
||||
"""
|
||||
md = Markdown(extensions=[HeadingExtension()])
|
||||
md.convert(text)
|
||||
collector = md.treeprocessors['heading_collector']
|
||||
headings = [h for h in getattr(collector, 'headings', []) if 'level' in h]
|
||||
|
||||
assert headings[0]['level'] == 1
|
||||
assert headings[0]['text'] == 'Title'
|
||||
assert headings[1]['level'] == 2
|
||||
assert headings[1]['text'] == 'Section 1'
|
||||
assert headings[2]['level'] == 2
|
||||
assert headings[2]['text'] == 'Section 2'
|
||||
|
||||
|
||||
def test_index_page_main_intro_extracted():
|
||||
text = """
|
||||
# Title
|
||||
|
||||
This is the main introduction paragraph for the index page.
|
||||
|
||||
## Section 1
|
||||
|
||||
Section content.
|
||||
"""
|
||||
md = Markdown(extensions=[HeadingExtension()])
|
||||
md.convert(text)
|
||||
collector = md.treeprocessors['heading_collector']
|
||||
main_intro = getattr(collector, 'main_intro', '')
|
||||
|
||||
assert 'This is the main introduction paragraph for the index page.' in main_intro
|
||||
|
||||
|
||||
def test_heading_extension_collects_headings_and_lists():
|
||||
md = Markdown(extensions=[HeadingExtension()])
|
||||
md.convert(textwrap.dedent("""
|
||||
# Title
|
||||
|
||||
## Section with **Bold** and [Link](#target)
|
||||
|
||||
- Item A
|
||||
- [Item B](#b) and more
|
||||
"""))
|
||||
|
||||
collector = md.treeprocessors['heading_collector']
|
||||
headings = getattr(collector, 'headings', [])
|
||||
|
||||
assert headings[0]['level'] == 1
|
||||
assert headings[0]['text'] == 'Title'
|
||||
assert headings[1]['level'] == 2
|
||||
assert headings[1]['text'] == 'Section with Bold and Link'
|
||||
list_entry = next(entry for entry in headings if entry.get('type') == 'ul')
|
||||
assert list_entry['items'][0] == 'Item A'
|
||||
assert 'Item B' in list_entry['items'][1]
|
||||
|
||||
|
||||
def test_heading_collector_extract_text_handles_children():
|
||||
collector = HeadingCollector(Markdown())
|
||||
parent = Element('p')
|
||||
child = SubElement(parent, 'strong')
|
||||
child.text = 'Bold'
|
||||
child.tail = ' tail'
|
||||
nested = SubElement(child, 'em')
|
||||
nested.text = 'inner'
|
||||
|
||||
text = collector._extract_text(parent)
|
||||
|
||||
assert 'Bold' in text
|
||||
assert 'tail' in text
|
||||
assert 'inner' in text
|
||||
|
||||
collector.headings = []
|
||||
collector._process_element(parent)
|
||||
assert collector.headings == []
|
||||
|
||||
|
||||
def test_parse_markdown_file_sets_title_from_filename(tmp_path):
|
||||
file_path = tmp_path / 'sample_page.md'
|
||||
file_path.write_text('Just text without heading', encoding='utf-8')
|
||||
|
||||
page = parse_markdown_file(str(file_path))
|
||||
|
||||
assert page['title'] == 'Sample Page'
|
||||
|
||||
|
||||
def test_parse_markdown_file_missing(tmp_path):
|
||||
missing = tmp_path / 'not_there.md'
|
||||
|
||||
with pytest.raises(FileNotFoundError):
|
||||
parse_markdown_file(str(missing))
|
||||
|
||||
|
||||
def test_markdown_to_html_lines_handles_empty_and_blank_links():
|
||||
assert markdown_to_html_lines('') == ''
|
||||
|
||||
html = markdown_to_html_lines(
|
||||
'Before [Empty]() and [External](https://example.com)')
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
anchors = soup.find_all('a')
|
||||
|
||||
assert anchors[0]['href'] == ''
|
||||
assert 'target' not in anchors[0].attrs
|
||||
assert anchors[1]['target'] == '_blank'
|
||||
|
||||
|
||||
def test_markdown_images_normalize_src_and_alt():
|
||||
html = markdown_to_html_lines(
|
||||
'')
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
image = soup.find('img')
|
||||
|
||||
assert image is not None
|
||||
assert image['src'] == 'img/it-consulting.svg'
|
||||
assert image['alt'] == 'IT Consulting'
|
||||
|
||||
|
||||
def test_markdown_images_add_alt_fallback():
|
||||
html = markdown_to_html_lines('')
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
image = soup.find('img')
|
||||
|
||||
assert image is not None
|
||||
assert image['src'] == 'img/logo.svg'
|
||||
assert image['alt'] == 'logo'
|
||||
|
||||
|
||||
def test_markdown_heading_with_inline_image_preserves_image():
|
||||
html = markdown_to_html_lines('## Heading ')
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
heading = soup.find('h2')
|
||||
image = heading.find('img') if heading else None
|
||||
|
||||
assert heading is not None
|
||||
assert heading.get_text(strip=True) == 'Heading'
|
||||
assert image is not None
|
||||
assert image['src'] == 'img/graphic.svg'
|
||||
assert image['alt'] == 'Graphic'
|
||||
|
||||
|
||||
def test_build_component_structure_converts_section_images():
|
||||
md = textwrap.dedent("""
|
||||
# Title
|
||||
|
||||
## Section With Visual
|
||||
|
||||
Intro text before image.
|
||||
|
||||

|
||||
""")
|
||||
|
||||
page = build_component_structure(md, "section.md")
|
||||
section_html = page['sections'][0]['content']
|
||||
soup = BeautifulSoup(section_html, 'html.parser')
|
||||
image = soup.find('img')
|
||||
|
||||
assert image is not None
|
||||
assert image['src'] == 'img/vision.png'
|
||||
assert image['alt'] == 'Vision Diagram'
|
||||
|
||||
|
||||
def test_build_component_structure_preserves_external_image_src():
|
||||
md = textwrap.dedent("""
|
||||
# Title
|
||||
|
||||
## Section
|
||||
|
||||

|
||||
""")
|
||||
|
||||
page = build_component_structure(md, "external.md")
|
||||
section_html = page['sections'][0]['content']
|
||||
soup = BeautifulSoup(section_html, 'html.parser')
|
||||
image = soup.find('img')
|
||||
|
||||
assert image is not None
|
||||
assert image['src'] == 'https://cdn.example.com/logo.svg'
|
||||
assert image['alt'] == 'Remote Logo'
|
||||
@@ -0,0 +1,15 @@
|
||||
from lib.markdown_parser import markdown_to_html_lines
|
||||
|
||||
|
||||
def test_javascript_link_neutralized():
|
||||
md = '[bad](javascript:alert(1))'
|
||||
html = markdown_to_html_lines(md)
|
||||
assert 'href="#unsafe"' in html
|
||||
assert 'javascript:' not in html
|
||||
|
||||
|
||||
def test_data_link_neutralized():
|
||||
md = '[bad](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)'
|
||||
html = markdown_to_html_lines(md)
|
||||
assert 'href="#unsafe"' in html
|
||||
assert 'data:' not in html
|
||||
@@ -0,0 +1,101 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from lib.utils import (
|
||||
get_template_files,
|
||||
render_template,
|
||||
set_active_page_by_url,
|
||||
minify_html,
|
||||
js_minifier,
|
||||
)
|
||||
|
||||
|
||||
def test_get_template_files_filters_partials_and_non_html(
|
||||
tmp_path: Path) -> None:
|
||||
template_dir = tmp_path / "templates"
|
||||
template_dir.mkdir()
|
||||
(template_dir / "page.html").write_text("<h1>Page</h1>", encoding="utf-8")
|
||||
(template_dir / "_partial.html").write_text("<p>Ignore</p>", encoding="utf-8")
|
||||
(template_dir / "notes.txt").write_text("ignore", encoding="utf-8")
|
||||
|
||||
result = get_template_files(str(template_dir))
|
||||
|
||||
assert sorted(result) == ["page.html"]
|
||||
|
||||
|
||||
def test_render_template_renders_with_context(tmp_path: Path) -> None:
|
||||
template_dir = tmp_path / "templates"
|
||||
template_dir.mkdir()
|
||||
(template_dir /
|
||||
"hello.html").write_text("Hello {{ name }}!", encoding="utf-8")
|
||||
|
||||
rendered = render_template(
|
||||
"hello.html", {
|
||||
"name": "World"}, template_dir=str(template_dir))
|
||||
|
||||
assert rendered == "Hello World!"
|
||||
|
||||
|
||||
def test_set_active_page_by_url_marks_only_requested_page_active() -> None:
|
||||
nav = {
|
||||
"index.html": {"active": False},
|
||||
"about.html": {"active": False},
|
||||
}
|
||||
|
||||
set_active_page_by_url(nav, "about.html")
|
||||
|
||||
assert nav["about.html"]["active"] is True
|
||||
assert nav["index.html"]["active"] is False
|
||||
|
||||
|
||||
def test_minify_html_removes_comments_and_extra_whitespace() -> None:
|
||||
html = "<div> Text </div> <!-- comment -->\n"
|
||||
|
||||
minified = minify_html(html)
|
||||
|
||||
assert minified == "<div> Text</div>"
|
||||
|
||||
|
||||
def test_js_minifier_strips_comments_and_rewrites_files(
|
||||
tmp_path: Path) -> None:
|
||||
output_dir = tmp_path / "site"
|
||||
js_dir = output_dir / "js"
|
||||
js_dir.mkdir(parents=True)
|
||||
js_path = js_dir / "app.js"
|
||||
js_path.write_text(
|
||||
"// comment\nvar x = 1; \n/* block */\nfunction test() { console.log(x); }\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Ensure non-JS files are ignored
|
||||
(js_dir / "readme.txt").write_text("skip", encoding="utf-8")
|
||||
|
||||
js_minifier(str(output_dir))
|
||||
|
||||
content = js_path.read_text(encoding="utf-8")
|
||||
|
||||
assert content == "var x = 1; function test() { console.log(x); }"
|
||||
assert os.path.exists(js_dir / "readme.txt")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial, expected",
|
||||
[
|
||||
({"a.js": "const a = 1;"}, "const a = 1;"),
|
||||
],
|
||||
)
|
||||
def test_js_minifier_handles_multiple_invocations(
|
||||
tmp_path: Path, initial, expected) -> None:
|
||||
output_dir = tmp_path / "dist"
|
||||
js_dir = output_dir / "js"
|
||||
js_dir.mkdir(parents=True)
|
||||
for filename, content in initial.items():
|
||||
(js_dir / filename).write_text(content, encoding="utf-8")
|
||||
|
||||
js_minifier(str(output_dir))
|
||||
js_minifier(str(output_dir))
|
||||
|
||||
for filename in initial:
|
||||
content = (js_dir / filename).read_text(encoding="utf-8")
|
||||
assert content == expected
|
||||
Reference in New Issue
Block a user