Add collapsible distinct org headers and top-of-file Inbox

This commit is contained in:
2026-08-07 21:11:35 -04:00
parent 7817336754
commit fa57387008
6 changed files with 694 additions and 55 deletions

View File

@ -127,6 +127,7 @@ python3 main.py --no-browser
- Desktop: independent scrollable sidebars.
- Mobile: notes list capped with its own scroll area.
- Sidebar text truncation with ellipsis and tooltip for full text.
- Distinct, level-styled org headers that collapse/expand their section (and all nested subsections) on click.
## Project layout

361
main.py
View File

@ -331,6 +331,31 @@ def _replace_timestamps(escaped_html: str) -> str:
return result.replace("\x00", "<").replace("\x01", ">")
_CODE_RE = re.compile(r'(?:^|(?<=[\s\(\[\{"\'\-\u2014/]))=(?!\s)([\s\S]+?)(?<!\s)=(?=[\s\)\}\]"\'\-\.,:;!\?\u2014/]|$)')
_VERBATIM_RE = re.compile(r'(?:^|(?<=[\s\(\[\{"\'\-\u2014/]))~(?!\s)([\s\S]+?)(?<!\s)~(?=[\s\)\}\]"\'\-\.,:;!\?\u2014/]|$)')
_BOLD_RE = re.compile(r'(?:^|(?<=[\s\(\[\{"\'\-\u2014/]))\*(?!\s)([\s\S]+?)(?<!\s)\*(?=[\s\)\}\]"\'\-\.,:;!\?\u2014/]|$)')
_ITALIC_RE = re.compile(r'(?:^|(?<=[\s\(\[\{"\'\-\u2014/]))/(?!\s)([\s\S]+?)(?<!\s)/(?=[\s\)\}\]"\'\-\.,:;!\?\u2014/]|$)')
_UNDERLINE_RE = re.compile(r'(?:^|(?<=[\s\(\[\{"\'\-\u2014/]))_(?!\s)([\s\S]+?)(?<!\s)_(?=[\s\)\}\]"\'\-\.,:;!\?\u2014/]|$)')
_STRIKE_RE = re.compile(r'(?:^|(?<=[\s\(\[\{"\'\-\u2014/]))\+(?!\s)([\s\S]+?)(?<!\s)\+(?=[\s\)\}\]"\'\-\.,:;!\?\u2014/]|$)')
def apply_inline_formatting(html_str: str) -> str:
"""Apply org inline text formatting (=code=, ~verbatim~, *bold*, /italic/, _underline_, +strike+) to text segments outside HTML tags."""
parts = re.split(r"(<[^>]+>)", html_str)
for i in range(0, len(parts), 2):
text = parts[i]
if not text:
continue
text = _CODE_RE.sub(r"<code class='org-code'>\1</code>", text)
text = _VERBATIM_RE.sub(r"<code class='org-code'>\1</code>", text)
text = _BOLD_RE.sub(r"<strong>\1</strong>", text)
text = _ITALIC_RE.sub(r"<em>\1</em>", text)
text = _UNDERLINE_RE.sub(r"<u>\1</u>", text)
text = _STRIKE_RE.sub(r"<del>\1</del>", text)
parts[i] = text
return "".join(parts)
def render_plain_text_with_links(text: str) -> str:
rendered_parts: list[str] = []
cursor = 0
@ -347,11 +372,16 @@ def render_plain_text_with_links(text: str) -> str:
return _replace_timestamps("".join(rendered_parts))
def _render_heading_with_todo(heading_text: str) -> tuple[str, str | None, str, list[str]]:
def _render_heading_with_todo(
heading_text: str, render_line_fn: Callable[[str], str] | None = None
) -> tuple[str, str | None, str, list[str]]:
"""Parse a heading for TODO keyword and tags.
Returns (rendered_html, keyword_or_None, bare_heading_text, tags_list).
"""
if render_line_fn is None:
render_line_fn = html.escape
# Strip tags from the end first
tag_match = _ORG_TAG_RE.search(heading_text)
tags: list[str] = []
@ -365,9 +395,9 @@ def _render_heading_with_todo(heading_text: str) -> tuple[str, str | None, str,
keyword = m.group(1)
rest = m.group(2).strip()
css_class = f"org-todo org-todo-{keyword.lower()}"
rendered = f"<span class='{css_class}'>{html.escape(keyword)}</span> {html.escape(rest)}"
rendered = f"<span class='{css_class}'>{html.escape(keyword)}</span> {render_line_fn(rest)}"
return rendered, keyword, rest, tags
return html.escape(text_without_tags), None, text_without_tags.strip(), tags
return render_line_fn(text_without_tags), None, text_without_tags.strip(), tags
def render_line_with_links(
@ -389,15 +419,30 @@ def render_line_with_links(
safe_href = html.escape(note_href(resolved, False, path_to_slug), quote=True)
link_text = label if label else raw_target
rendered_parts.append(f"<a class='org-link' href='{safe_href}'>{html.escape(link_text)}</a>")
elif raw_target.startswith(("http://", "https://", "mailto:")) or "://" in raw_target:
safe_href = html.escape(raw_target, quote=True)
link_text = label if label else raw_target
rendered_parts.append(f"<a class='org-link' href='{safe_href}' target='_blank' rel='noopener noreferrer'>{html.escape(link_text)}</a>")
else:
rendered_parts.append(render_plain_text_with_links(match.group(0)))
link_text = label if label else raw_target
rendered_parts.append(html.escape(link_text))
cursor = end
if cursor < len(line):
rendered_parts.append(render_plain_text_with_links(line[cursor:]))
return "".join(rendered_parts)
full_html = "".join(rendered_parts)
return apply_inline_formatting(full_html)
_BLOCK_START_RE = re.compile(r"^\s*#\+(BEGIN_SRC|BEGIN_EXAMPLE|BEGIN_QUOTE|BEGIN_EXPORT)\b\s*(.*)$", re.IGNORECASE)
_BLOCK_END_RE = re.compile(r"^\s*#\+(END_SRC|END_EXAMPLE|END_QUOTE|END_EXPORT)\b", re.IGNORECASE)
_TABLE_ROW_RE = re.compile(r"^\s*\|.*\|\s*$")
_TABLE_SEP_RE = re.compile(r"^\s*\|[-+=|]+\|\s*$")
_LIST_ITEM_RE = re.compile(r"^\s*(?:([-+])|(\d+)[\.\)])\s+(.*)$")
_HR_RE = re.compile(r"^\s*-{5,}\s*$")
_COMMENT_OR_KEYWORD_RE = re.compile(r"^\s*#(?:[+A-Za-z_]|\s)")
def render_org_to_html(
@ -405,65 +450,179 @@ def render_org_to_html(
path_to_slug: dict[str, str],
show_webhook: bool = False,
) -> str:
"""Very small org-ish renderer: headings become section titles, body keeps line breaks."""
"""Render org mode content to HTML, supporting headings, lists, tables, blocks, links, and inline markup."""
lines = content.splitlines()
html_lines: list[str] = []
section_stack: list[tuple[int, list[str], list[str]]] = []
i = 0
n = len(lines)
in_properties = False
property_items: list[tuple[str, str]] = []
lines = content.splitlines()
for raw_line in lines:
line = raw_line.rstrip("\n")
stripped = line.lstrip()
def render_line(l: str) -> str:
return render_line_with_links(l, source_relative_path, known_paths, id_to_path, path_to_slug)
def current_container() -> list[str]:
return html_lines if not section_stack else section_stack[-1][2]
def emit(*parts: str) -> None:
current_container().extend(parts)
def close_sections(min_level: int) -> None:
"""Close open sections whose heading level is >= min_level."""
while section_stack and section_stack[-1][0] >= min_level:
level, heading_lines, body_lines = section_stack.pop()
section_html = (
"\n".join(heading_lines)
+ "\n"
+ "\n".join(body_lines)
+ "\n</div>\n</section>"
)
current_container().append(section_html)
while i < n:
line = lines[i]
stripped = line.strip()
# Check PROPERTIES drawer
if re.match(r"^\s*:PROPERTIES:\s*$", stripped, re.IGNORECASE):
in_properties = True
property_items = []
i += 1
while i < n:
s = lines[i].strip()
if re.match(r"^\s*:END:\s*$", s, re.IGNORECASE):
in_properties = False
if property_items:
dl_items = [
f"<dt>{html.escape(k)}</dt><dd>{html.escape(v)}</dd>"
for k, v in property_items
]
emit(f"<dl class='org-properties'>{''.join(dl_items)}</dl>")
i += 1
break
prop_match = PROPERTY_LINE_RE.match(s)
if prop_match:
property_items.append((prop_match.group(1), prop_match.group(2)))
i += 1
continue
if in_properties:
if re.match(r"^\s*:END:\s*$", stripped, re.IGNORECASE):
in_properties = False
if property_items:
dl_items = []
for key, value in property_items:
dl_items.append(
f"<dt>{html.escape(key)}</dt>"
f"<dd>{html.escape(value)}</dd>"
)
html_lines.append(
"<dl class='org-properties'>" + "".join(dl_items) + "</dl>"
)
continue
prop_match = PROPERTY_LINE_RE.match(stripped)
if prop_match:
property_items.append((prop_match.group(1), prop_match.group(2)))
continue
if re.match(r"^\s*#\+filetags:\s", stripped, re.IGNORECASE):
i += 1
continue
# Check TITLE
title_match = TITLE_RE.match(stripped)
if title_match:
html_lines.append(f"<h1>{html.escape(title_match.group(1))}</h1>")
emit(f"<h1>{html.escape(title_match.group(1))}</h1>")
i += 1
continue
# Check Code/Example/Quote/Export blocks
block_match = _BLOCK_START_RE.match(stripped)
if block_match:
block_kind = block_match.group(1).upper()
block_arg = block_match.group(2).strip()
block_lines: list[str] = []
i += 1
while i < n:
if _BLOCK_END_RE.match(lines[i].strip()):
i += 1
break
block_lines.append(lines[i])
i += 1
block_content = "\n".join(block_lines)
if block_kind == "BEGIN_SRC":
lang_cls = f" class='language-{html.escape(block_arg.lower())}'" if block_arg else ""
emit(f"<pre class='org-src-block'><code{lang_cls}>{html.escape(block_content)}</code></pre>")
elif block_kind == "BEGIN_EXAMPLE":
emit(f"<pre class='org-example'><code>{html.escape(block_content)}</code></pre>")
elif block_kind == "BEGIN_QUOTE":
quote_rendered = "\n".join(f"<p>{render_line(l)}</p>" for l in block_lines if l.strip())
emit(f"<blockquote class='org-quote'>{quote_rendered}</blockquote>")
# EXPORT is ignored
continue
# Check Table
if _TABLE_ROW_RE.match(line):
table_lines: list[str] = []
while i < n and _TABLE_ROW_RE.match(lines[i]):
table_lines.append(lines[i])
i += 1
rows: list[tuple[bool, list[str]]] = []
for tl in table_lines:
if _TABLE_SEP_RE.match(tl):
rows.append((True, []))
else:
parts = [c.strip() for c in tl.strip().split("|")]
if parts and parts[0] == "":
parts.pop(0)
if parts and parts[-1] == "":
parts.pop()
rows.append((False, [render_line(c) for c in parts]))
has_header = len(rows) >= 2 and not rows[0][0] and rows[1][0]
table_html: list[str] = ["<table class='org-table'>"]
if has_header:
table_html.append("<thead><tr>")
for cell in rows[0][1]:
table_html.append(f"<th>{cell}</th>")
table_html.append("</tr></thead>")
body_rows = rows[2:]
else:
body_rows = rows
table_html.append("<tbody>")
for is_sep, cells in body_rows:
if is_sep:
continue
table_html.append("<tr>")
for cell in cells:
table_html.append(f"<td>{cell}</td>")
table_html.append("</tr>")
table_html.append("</tbody></table>")
emit("".join(table_html))
continue
# Check Headings (* at column 0 or star + space)
if stripped.startswith("*"):
stars = len(stripped) - len(stripped.lstrip("*"))
if stars > 0 and len(stripped) > stars and stripped[stars] == " ":
level = min(stars + 1, 6)
close_sections(level)
heading_text = stripped[stars + 1 :]
rendered_title, keyword, bare_text, heading_tags = _render_heading_with_todo(heading_text)
html_lines.append(f"<h{level}>{rendered_title}</h{level}>")
rendered_title, keyword, bare_text, heading_tags = _render_heading_with_todo(
heading_text, render_line_fn=render_line
)
starts_collapsed = level >= 3
section_class = "org-section" + (" collapsed" if starts_collapsed else "")
toggle_glyph = "&#x25B8;" if starts_collapsed else "&#x25BE;"
toggle_expanded = "false" if starts_collapsed else "true"
toggle_title = "Expand section" if starts_collapsed else "Collapse section"
heading_lines: list[str] = [
f"<section class='{section_class}' data-level='{level}'>",
(
f"<h{level} class='org-heading' data-level='{level}'>"
f"<button class='org-collapse-toggle' type='button' aria-expanded='{toggle_expanded}' "
f"title='{toggle_title}'>{toggle_glyph}</button>"
f"<span class='org-heading-text'>{rendered_title}</span>"
f"</h{level}>"
),
]
if heading_tags:
tag_spans = "".join(
f"<span class='org-tag'>{html.escape(t)}</span>"
for t in heading_tags
)
html_lines.append(f"<div class='org-tags'>{tag_spans}</div>")
heading_lines.append(f"<div class='org-tags'>{tag_spans}</div>")
if keyword and show_webhook:
safe_file = html.escape(source_relative_path, quote=True)
safe_heading = html.escape(bare_text, quote=True)
html_lines.append(
heading_lines.append(
f"<button class='webhook-send-btn' type='button' "
f"data-file='{safe_file}' data-heading='{safe_heading}' "
f"data-state='STRT'>Start work</button> "
@ -471,14 +630,95 @@ def render_org_to_html(
f"data-file='{safe_file}' data-heading='{safe_heading}' "
f"data-state='DONE'>End work</button>"
)
heading_lines.append("<div class='org-section-body'>")
section_stack.append((level, heading_lines, []))
i += 1
continue
if not stripped:
html_lines.append("<div class='spacer'></div>")
else:
rendered_line = render_line_with_links(line, source_relative_path, known_paths, id_to_path, path_to_slug)
html_lines.append(f"<p>{rendered_line}</p>")
# Check Horizontal Rule
if _HR_RE.match(stripped):
emit("<hr class='org-hr'>")
i += 1
continue
# Check Lists (Unordered & Ordered)
list_match = _LIST_ITEM_RE.match(line)
if list_match:
is_ordered = list_match.group(2) is not None
list_items: list[str] = []
tag_name = "ol" if is_ordered else "ul"
while i < n:
lm = _LIST_ITEM_RE.match(lines[i])
if lm:
item_ordered = lm.group(2) is not None
if item_ordered != is_ordered:
break
item_text = lm.group(3)
i += 1
while (
i < n
and lines[i].strip()
and not _LIST_ITEM_RE.match(lines[i])
and not lines[i].startswith("*")
and not _BLOCK_START_RE.match(lines[i].strip())
and not _TABLE_ROW_RE.match(lines[i])
):
item_text += " " + lines[i].strip()
i += 1
list_items.append(render_line(item_text))
else:
break
items_html = "".join(f"<li>{item}</li>" for item in list_items)
emit(f"<{tag_name} class='org-list'>{items_html}</{tag_name}>")
continue
# Skip comment or unrecognized directive lines (#+CREATED:, #+filetags:, # ...)
if _COMMENT_OR_KEYWORD_RE.match(stripped):
i += 1
continue
# Empty lines
if not stripped:
emit("<div class='spacer'></div>")
i += 1
continue
# Regular Paragraph (group consecutive soft-wrapped lines so
# multi-line org emphasis like *bold spanning lines* can match)
paragraph_lines: list[str] = [line]
i += 1
while i < n:
nxt = lines[i]
nxt_stripped = nxt.strip()
if not nxt_stripped:
break
if _BLOCK_START_RE.match(nxt_stripped):
break
if _TABLE_ROW_RE.match(nxt):
break
if _LIST_ITEM_RE.match(nxt):
break
if _HR_RE.match(nxt_stripped):
break
if _COMMENT_OR_KEYWORD_RE.match(nxt_stripped):
break
if TITLE_RE.match(nxt_stripped):
break
if re.match(r"^\s*:PROPERTIES:\s*$", nxt_stripped, re.IGNORECASE):
break
if nxt_stripped.startswith("*"):
stars = len(nxt_stripped) - len(nxt_stripped.lstrip("*"))
if stars > 0 and len(nxt_stripped) > stars and nxt_stripped[stars] == " ":
break
paragraph_lines.append(nxt)
i += 1
paragraph_text = "\n".join(p.rstrip() for p in paragraph_lines)
emit(f"<p>{render_line(paragraph_text)}</p>")
close_sections(1)
return "\n".join(html_lines)
@ -638,9 +878,12 @@ def extract_heading_data(content: str, heading_text: str, file_id: str = "", sta
def add_to_inbox(content: str, text: str) -> str:
"""Append a '** TODO <text>' item under a top-level '* Inbox' section.
"""Add a '** TODO <text>' item under a top-level '* Inbox' section.
Creates the Inbox section at the bottom of the file if it does not exist.
The Inbox is kept as the first top-level heading in the file: if it is
missing it is created there (right after any file preamble such as
#+TITLE or #+CREATED), and if it already exists further down it is
moved to the top before the new task is added.
"""
text = text.strip()
if not text:
@ -650,22 +893,38 @@ def add_to_inbox(content: str, text: str) -> str:
top_level_re = re.compile(r"^\*\s")
lines = content.rstrip("\n").splitlines()
def first_top_level() -> int:
for idx, line in enumerate(lines):
if top_level_re.match(line):
return idx
return len(lines)
def section_end(start: int) -> int:
for idx in range(start + 1, len(lines)):
if top_level_re.match(lines[idx]):
return idx
return len(lines)
inbox_idx: int | None = None
for idx, line in enumerate(lines):
if inbox_re.match(line):
inbox_idx = idx
if inbox_idx is None:
lines.append("* Inbox")
inbox_idx = len(lines) - 1
end = len(lines)
for idx in range(inbox_idx + 1, len(lines)):
if top_level_re.match(lines[idx]):
end = idx
break
lines.insert(end, new_task)
insert_at = first_top_level()
if inbox_idx is None:
lines.insert(insert_at, "* Inbox")
inbox_idx = insert_at
elif inbox_idx != insert_at:
end = section_end(inbox_idx)
inbox_block = lines[inbox_idx:end]
del lines[inbox_idx:end]
lines[insert_at:insert_at] = inbox_block
inbox_idx = insert_at
lines.insert(section_end(inbox_idx), new_task)
return "\n".join(lines) + "\n"

View File

@ -443,8 +443,80 @@ main h2 {
}
.org-content h1 {
margin-top: 0;
font-size: 1.4rem;
margin: 0 0 0.9rem;
padding: 0.45rem 0.75rem;
border-radius: 0.45rem;
border-left: 3px solid var(--accent);
background: color-mix(in srgb, var(--accent-soft) 25%, var(--panel));
color: var(--ink);
font-size: 1.35rem;
}
.org-heading {
display: flex;
align-items: center;
gap: 0.45rem;
margin: 1.25rem 0 0.35rem;
padding: 0.4rem 0.7rem;
border-radius: 0.45rem;
border-left: 3px solid var(--accent);
background: color-mix(in srgb, var(--accent-soft) 30%, var(--panel));
color: var(--ink);
cursor: pointer;
user-select: none;
}
.org-content h2.org-heading { font-size: 1.15rem; }
.org-content h3.org-heading { font-size: 0.95rem; }
.org-content h4.org-heading { font-size: 0.9rem; }
.org-content h5.org-heading,
.org-content h6.org-heading { font-size: 0.85rem; }
.org-heading:hover {
background: color-mix(in srgb, var(--accent-soft) 55%, var(--panel));
}
.org-heading-text {
flex: 1;
min-width: 0;
}
.org-collapse-toggle {
flex: 0 0 auto;
border: none;
background: transparent;
color: var(--ink-soft);
font-size: 0.8rem;
line-height: 1;
padding: 0.15rem 0.2rem;
cursor: pointer;
border-radius: 0.25rem;
}
.org-collapse-toggle:hover {
color: var(--accent);
}
.org-section {
margin: 0.2rem 0;
}
.org-section-body {
margin-top: 0.15rem;
}
.org-section-body .org-section {
margin-left: 0.75rem;
}
.org-section.collapsed > .org-section-body {
display: none;
}
.org-section.collapsed > .org-heading {
border-left-style: dashed;
background: color-mix(in srgb, var(--accent-soft) 12%, var(--panel));
color: var(--ink-soft);
}
.org-content p {
@ -643,6 +715,90 @@ body[data-theme="dark"] .webhook-send-btn.webhook-sent {
border-color: #c84a52;
}
.org-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.88em;
background: color-mix(in srgb, var(--accent-soft) 45%, var(--panel));
color: var(--ink);
border: 1px solid var(--line);
border-radius: 0.3rem;
padding: 0.1em 0.35em;
}
.org-src-block,
.org-example {
background: var(--nav-bg);
border: 1px solid var(--line);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
overflow-x: auto;
margin: 0.75rem 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.88rem;
line-height: 1.45;
color: var(--ink);
}
.org-src-block code,
.org-example code {
font-family: inherit;
background: transparent;
border: none;
padding: 0;
}
.org-quote {
margin: 0.75rem 0;
padding: 0.5rem 1rem;
border-left: 4px solid var(--accent);
background: color-mix(in srgb, var(--accent-soft) 20%, var(--panel));
color: var(--ink-soft);
font-style: italic;
}
.org-quote p {
margin: 0.25rem 0;
}
.org-table {
width: 100%;
border-collapse: collapse;
margin: 0.85rem 0;
font-size: 0.9rem;
}
.org-table th,
.org-table td {
border: 1px solid var(--line);
padding: 0.45rem 0.65rem;
text-align: left;
}
.org-table th {
background: var(--nav-bg);
color: var(--accent);
font-weight: 600;
}
.org-table tr:nth-child(even) {
background: color-mix(in srgb, var(--nav-bg) 30%, var(--panel));
}
.org-list {
margin: 0.5rem 0 0.5rem 1.5rem;
padding: 0;
}
.org-list li {
margin: 0.25rem 0;
}
.org-hr {
border: none;
border-top: 1px solid var(--line);
margin: 1.25rem 0;
}
.spacer {
height: 0.5rem;
}

View File

@ -1,4 +1,4 @@
const CACHE = "orgweb-v1";
const CACHE = "orgweb-v2";
const PRECACHE = [
"/",
"/static/style.css",
@ -39,6 +39,19 @@ self.addEventListener("fetch", (event) => {
return;
}
if (url.pathname === "/static/style.css") {
event.respondWith(
fetch(event.request)
.then((resp) => {
const copy = resp.clone();
caches.open(CACHE).then((cache) => cache.put(event.request, copy));
return resp;
})
.catch(() => caches.match(event.request))
);
return;
}
event.respondWith(
caches.match(event.request).then(
(cached) =>

View File

@ -11,7 +11,7 @@
<link rel="manifest" href="/static/manifest.webmanifest">
<link rel="apple-touch-icon" href="/static/icon-192.png">
<link rel="icon" type="image/png" href="/static/icon-192.png">
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/style.css?v=2">
</head>
<body>
<header>
@ -356,6 +356,24 @@
});
})();
</script>
<script>
(function () {
document.addEventListener("click", function (e) {
if (e.target.closest("a")) return;
var heading = e.target.closest(".org-heading");
if (!heading) return;
var section = heading.parentElement;
if (!section || !section.classList.contains("org-section")) return;
var collapsed = section.classList.toggle("collapsed");
var btn = heading.querySelector(".org-collapse-toggle");
if (btn) {
btn.setAttribute("aria-expanded", collapsed ? "false" : "true");
btn.title = collapsed ? "Expand section" : "Collapse section";
btn.innerHTML = collapsed ? "&#x25B8;" : "&#x25BE;";
}
});
})();
</script>
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", function () {

192
test_main.py Normal file
View File

@ -0,0 +1,192 @@
import unittest
import main
class TestOrgParser(unittest.TestCase):
def setUp(self):
self.known_paths = {"note1.org", "note2.org"}
self.id_to_path = {"id-123": "note1.org"}
self.path_to_slug = {"note1.org": "id-123", "note2.org": "note2.org"}
def render(self, content, show_webhook=False):
return main.render_org_to_html(
content=content,
source_relative_path="source.org",
known_paths=self.known_paths,
id_to_path=self.id_to_path,
path_to_slug=self.path_to_slug,
show_webhook=show_webhook,
)
def test_external_links(self):
content = "See [[https://hungryroot.atlassian.net/browse/BE-7924][BE-7924]] for details."
rendered = self.render(content)
self.assertIn("<a class='org-link' href='https://hungryroot.atlassian.net/browse/BE-7924' target='_blank' rel='noopener noreferrer'>BE-7924</a>", rendered)
def test_internal_links(self):
content = "Check [[note1.org][Note 1]] or [[id:id-123][Note 1 ID]]."
rendered = self.render(content)
self.assertIn("<a class='org-link' href='/n/id-123'>Note 1</a>", rendered)
self.assertIn("<a class='org-link' href='/n/id-123'>Note 1 ID</a>", rendered)
def test_inline_formatting(self):
content = "Added flat replacements (=author_id=, ~python~, *always present*, /italic text/, _underlined_, +deleted+)."
rendered = self.render(content)
self.assertIn("<code class='org-code'>author_id</code>", rendered)
self.assertIn("<code class='org-code'>python</code>", rendered)
self.assertIn("<strong>always present</strong>", rendered)
self.assertIn("<em>italic text</em>", rendered)
self.assertIn("<u>underlined</u>", rendered)
self.assertIn("<del>deleted</del>", rendered)
def test_code_blocks(self):
content = "#+begin_src python\ndef hello():\n return 'world'\n#+end_src"
rendered = self.render(content)
self.assertIn("<pre class='org-src-block'><code class='language-python'>def hello():\n return &#x27;world&#x27;</code></pre>", rendered)
def test_tables(self):
content = (
"| Field | List default |\n"
"|-------+--------------|\n"
"| =author= | present |\n"
)
rendered = self.render(content)
self.assertIn("<table class='org-table'>", rendered)
self.assertIn("<thead><tr><th>Field</th><th>List default</th></tr></thead>", rendered)
self.assertIn("<tbody><tr><td><code class='org-code'>author</code></td><td>present</td></tr></tbody>", rendered)
def test_lists(self):
content = "- =GET /api/v3/cookbooks/=\n- =GET /api/v3/cookbooks/<slug>/="
rendered = self.render(content)
self.assertIn("<ul class='org-list'>", rendered)
self.assertIn("<li><code class='org-code'>GET /api/v3/cookbooks/</code></li>", rendered)
ordered_content = "1. Item one\n2. Item two"
ordered_rendered = self.render(ordered_content)
self.assertIn("<ol class='org-list'>", ordered_rendered)
self.assertIn("<li>Item one</li>", ordered_rendered)
def test_comments_ignored(self):
content = "#+CREATED: [2026-08-04]\n# This is a comment\nReal text"
rendered = self.render(content)
self.assertNotIn("#+CREATED", rendered)
self.assertNotIn("This is a comment", rendered)
self.assertIn("<p>Real text</p>", rendered)
def test_headings_with_todo_and_tags(self):
content = "* TODO Fix auth issue :bug:backend:"
rendered = self.render(content)
self.assertIn("<h2", rendered)
self.assertIn("<span class='org-todo org-todo-todo'>TODO</span>", rendered)
self.assertIn("Fix auth issue", rendered)
self.assertIn("<span class='org-tag'>bug</span>", rendered)
self.assertIn("<span class='org-tag'>backend</span>", rendered)
def test_headings_build_collapsible_sections(self):
content = (
"#+TITLE: Doc title\n"
"* Top\n"
"Some content.\n"
"** Sub\n"
"Deeper content.\n"
"* Sibling\n"
"Sibling content.\n"
)
rendered = self.render(content)
self.assertIn("<h1>Doc title</h1>", rendered)
self.assertIn("<section class='org-section' data-level='2'>", rendered)
self.assertIn("<section class='org-section collapsed' data-level='3'>", rendered)
self.assertIn("class='org-heading'", rendered)
self.assertIn("class='org-collapse-toggle'", rendered)
self.assertIn("class='org-section-body'", rendered)
self.assertIn("<p>Some content.</p>", rendered)
self.assertIn("<p>Deeper content.</p>", rendered)
self.assertIn("<p>Sibling content.</p>", rendered)
self.assertNotIn("<section class='org-section' data-level='2'></section>", rendered)
def test_level3_and_deeper_start_collapsed(self):
content = (
"* One\n"
"** Sub\n"
"Body A\n"
"*** Deep\n"
"Body B\n"
"* Two\n"
"Body C\n"
)
rendered = self.render(content)
self.assertIn("<section class='org-section' data-level='2'>", rendered)
self.assertIn("<section class='org-section collapsed' data-level='3'>", rendered)
self.assertIn("<section class='org-section collapsed' data-level='4'>", rendered)
self.assertIn("aria-expanded='false'", rendered)
self.assertIn(">&#x25B8;</button>", rendered)
def test_nested_section_contains_deeper_heading(self):
content = "* Top\n** Sub\nDeep text\n* End\nTail text"
rendered = self.render(content)
top = rendered.index("<section class='org-section' data-level='2'>")
sub = rendered.index("<section class='org-section collapsed' data-level='3'>")
end = rendered.index("<section class='org-section' data-level='2'>", top + 1)
self.assertTrue(top < sub < end)
def test_heading_stars_capped_at_level_6(self):
content = "****** Deep heading\nBody text"
rendered = self.render(content)
self.assertIn("<h6 class='org-heading' data-level='6'>", rendered)
self.assertIn("<p>Body text</p>", rendered)
def test_multiline_emphasis(self):
content = '*The safe ordering is always "clients first, flag flips\nsecond." Until then.*'
rendered = self.render(content)
self.assertIn(
"<strong>The safe ordering is always &quot;clients first, flag flips\nsecond.&quot; Until then.</strong>",
rendered,
)
def test_code_with_internal_marker(self):
content = "Request =?expand=pairing_ids,product_ids= on the list endpoint."
rendered = self.render(content)
self.assertIn("<code class='org-code'>?expand=pairing_ids,product_ids</code>", rendered)
def test_soft_wrapped_paragraph_is_single_p(self):
content = "First line of prose\nsecond line of prose\n\nNext paragraph"
rendered = self.render(content)
self.assertIn("<p>First line of prose\nsecond line of prose</p>", rendered)
self.assertIn("<p>Next paragraph</p>", rendered)
def test_add_to_inbox_creates_inbox_at_top_after_preamble(self):
content = "#+TITLE: Notes\n* Work\nSome text.\n"
updated = main.add_to_inbox(content, "Call dentist")
lines = updated.splitlines()
self.assertEqual(lines[0], "#+TITLE: Notes")
self.assertEqual(lines[1], "* Inbox")
self.assertEqual(lines[2], "** TODO Call dentist")
self.assertEqual(lines[3], "* Work")
self.assertEqual(lines[4], "Some text.")
def test_add_to_inbox_appends_to_existing_inbox(self):
content = "* Inbox\n** TODO Existing\n* Work\n"
updated = main.add_to_inbox(content, "New task")
lines = updated.splitlines()
self.assertEqual(lines[0], "* Inbox")
self.assertEqual(lines[1], "** TODO Existing")
self.assertEqual(lines[2], "** TODO New task")
self.assertEqual(lines[3], "* Work")
def test_add_to_inbox_moves_existing_inbox_to_top(self):
content = "* Work\nSome text.\n* Inbox\n** TODO Old task\n"
updated = main.add_to_inbox(content, "New task")
lines = updated.splitlines()
self.assertEqual(lines[0], "* Inbox")
self.assertEqual(lines[1], "** TODO Old task")
self.assertEqual(lines[2], "** TODO New task")
self.assertEqual(lines[3], "* Work")
self.assertEqual(lines[4], "Some text.")
def test_add_to_inbox_blank_text_is_noop(self):
content = "* Inbox\n** TODO Keep\n"
self.assertEqual(main.add_to_inbox(content, " "), content)
if __name__ == "__main__":
unittest.main()