decktile 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- decktile/__init__.py +5 -0
- decktile/__main__.py +3 -0
- decktile/assets/lucide/VENDORED.md +21 -0
- decktile/assets/lucide/lucide.css +2193 -0
- decktile/assets/lucide/lucide.ttf +3 -0
- decktile/cli.py +269 -0
- decktile/parser/__init__.py +0 -0
- decktile/parser/ast.py +191 -0
- decktile/parser/markdown.py +289 -0
- decktile/plugins/cards.py +25 -0
- decktile/plugins/directives.py +56 -0
- decktile/plugins/fenced.py +228 -0
- decktile/plugins/layouts.py +69 -0
- decktile/plugins/lucide.py +65 -0
- decktile/plugins/lucide_font.py +26 -0
- decktile/render/__init__.py +0 -0
- decktile/render/html.py +287 -0
- decktile/render/ir.py +536 -0
- decktile/render/odf/__init__.py +4 -0
- decktile/render/odf/geometry.py +510 -0
- decktile/render/odf/shapes.py +719 -0
- decktile/render/odf/styles.py +828 -0
- decktile/render/odp.py +380 -0
- decktile/render/pdf.py +17 -0
- decktile/render/pdg.py +72 -0
- decktile/render/seaborn.py +155 -0
- decktile/render/templates/base.css +741 -0
- decktile/render/templates/decktile.js +371 -0
- decktile/render/templates/shell.html +106 -0
- decktile/seaborn_styles/__init__.py +5 -0
- decktile/seaborn_styles/ossummit_europe.py +30 -0
- decktile/theme/__init__.py +0 -0
- decktile/theme/colors.py +53 -0
- decktile/theme/loader.py +22 -0
- decktile/theme/parser.py +264 -0
- decktile/themes/default.css +146 -0
- decktile/themes/kcd_vietnam.css +159 -0
- decktile/themes/kubecon_china.css +213 -0
- decktile/themes/kubecon_japan.css +156 -0
- decktile/themes/ossummit_europe.css +196 -0
- decktile/themes/ossummit_korea.css +196 -0
- decktile-0.2.0.dist-info/METADATA +625 -0
- decktile-0.2.0.dist-info/RECORD +46 -0
- decktile-0.2.0.dist-info/WHEEL +4 -0
- decktile-0.2.0.dist-info/entry_points.txt +6 -0
- decktile-0.2.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Markdown parser using markdown-it-py with plugin extensions."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import frontmatter
|
|
5
|
+
from markdown_it import MarkdownIt
|
|
6
|
+
from markdown_it.token import Token
|
|
7
|
+
|
|
8
|
+
from decktile.parser.ast import (
|
|
9
|
+
Document, Meta, Slide,
|
|
10
|
+
Heading, Paragraph, CodeBlock, Card, Grid, Table, Quote, ListNode, AttrNode,
|
|
11
|
+
Text, Strong, Emphasis, Strikethrough, CodeSpan, Image, SoftBreak, LucideIcon,
|
|
12
|
+
)
|
|
13
|
+
from decktile.plugins.fenced import extract_fenced, interleave_fences
|
|
14
|
+
from decktile.plugins.directives import preprocess_directives, extract_attrs, parse_attr_token
|
|
15
|
+
from decktile.plugins.lucide import lucide_plugin
|
|
16
|
+
from decktile.plugins.cards import group_cards
|
|
17
|
+
|
|
18
|
+
# Renders a table cell's inline tokens to HTML (bold, code, links, icons).
|
|
19
|
+
# Concatenating token .content dropped every inline markup.
|
|
20
|
+
_CELL_MD = MarkdownIt("gfm-like", {"html": True}).use(lucide_plugin)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse(input_text: str) -> Document:
|
|
24
|
+
post = frontmatter.loads(input_text)
|
|
25
|
+
raw_meta = dict(post.metadata)
|
|
26
|
+
style_raw = raw_meta.pop("style", "") or ""
|
|
27
|
+
logo_raw = raw_meta.pop("logo", "") or ""
|
|
28
|
+
logo_dark_raw = raw_meta.pop("logo_dark", "") or ""
|
|
29
|
+
watermark_raw = raw_meta.pop("watermark", "") or ""
|
|
30
|
+
background_raw = raw_meta.pop("background", "") or ""
|
|
31
|
+
|
|
32
|
+
meta = Meta(
|
|
33
|
+
theme=raw_meta.pop("theme", ""),
|
|
34
|
+
title=raw_meta.pop("title", ""),
|
|
35
|
+
footer=raw_meta.pop("footer", ""),
|
|
36
|
+
paginate=raw_meta.pop("paginate", False),
|
|
37
|
+
size=raw_meta.pop("size", "16:9"),
|
|
38
|
+
style=style_raw,
|
|
39
|
+
logo=logo_raw,
|
|
40
|
+
logo_dark=logo_dark_raw,
|
|
41
|
+
watermark=watermark_raw,
|
|
42
|
+
background=background_raw,
|
|
43
|
+
pygments_style=raw_meta.pop("pygments_style", "default"),
|
|
44
|
+
seaborn_theme=raw_meta.pop("seaborn_theme", None) or Meta.seaborn_theme,
|
|
45
|
+
theme_variant=raw_meta.pop("variant", Meta.theme_variant),
|
|
46
|
+
transition=raw_meta.pop("transition", Meta.transition),
|
|
47
|
+
autoplay=raw_meta.pop("autoplay", Meta.autoplay),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
body = post.content
|
|
51
|
+
slides = []
|
|
52
|
+
for part in body.split("\n---\n"):
|
|
53
|
+
part = part.strip()
|
|
54
|
+
if not part:
|
|
55
|
+
continue
|
|
56
|
+
slide = _parse_slide(part)
|
|
57
|
+
if slide is not None:
|
|
58
|
+
slides.append(slide)
|
|
59
|
+
|
|
60
|
+
return Document(meta=meta, slides=slides)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_slide(content: str) -> Slide:
|
|
64
|
+
notes = ""
|
|
65
|
+
# Collect all HTML comments as speaker notes
|
|
66
|
+
note_parts = re.findall(r"<!--(?!attr:)(.*?)-->", content, flags=re.DOTALL)
|
|
67
|
+
if note_parts:
|
|
68
|
+
blocks = []
|
|
69
|
+
for p in note_parts:
|
|
70
|
+
raw_lines = p.splitlines()
|
|
71
|
+
is_single_line = len(raw_lines) <= 1
|
|
72
|
+
lines = [line.rstrip() for line in raw_lines]
|
|
73
|
+
while lines and not lines[0].strip():
|
|
74
|
+
lines.pop(0)
|
|
75
|
+
while lines and not lines[-1].strip():
|
|
76
|
+
lines.pop()
|
|
77
|
+
if not lines:
|
|
78
|
+
continue
|
|
79
|
+
if is_single_line:
|
|
80
|
+
lines[0] = lines[0].strip()
|
|
81
|
+
blocks.append("\n".join(lines))
|
|
82
|
+
notes = "\n\n".join(blocks)
|
|
83
|
+
# Strip comments from content
|
|
84
|
+
content = re.sub(r"<!--(?!attr:).*?-->", "", content, flags=re.DOTALL).strip()
|
|
85
|
+
content = preprocess_directives(content)
|
|
86
|
+
|
|
87
|
+
# Extract fenced blocks
|
|
88
|
+
content, fence_nodes = extract_fenced(content)
|
|
89
|
+
|
|
90
|
+
md = MarkdownIt("gfm-like", {"breaks": True, "html": True})
|
|
91
|
+
md.use(lucide_plugin)
|
|
92
|
+
tokens = md.parse(content)
|
|
93
|
+
nodes = _tokens_to_nodes(tokens)
|
|
94
|
+
|
|
95
|
+
nodes = interleave_fences(nodes, fence_nodes)
|
|
96
|
+
nodes = extract_attrs(nodes)
|
|
97
|
+
nodes = group_cards(nodes)
|
|
98
|
+
|
|
99
|
+
layout = _detect_layout(nodes)
|
|
100
|
+
variant = ""
|
|
101
|
+
transition = ""
|
|
102
|
+
hidden = False
|
|
103
|
+
for n in list(nodes):
|
|
104
|
+
if isinstance(n, AttrNode) and n.type == "layout":
|
|
105
|
+
layout = n.value.strip()
|
|
106
|
+
nodes.remove(n)
|
|
107
|
+
elif isinstance(n, AttrNode) and n.type == "variant":
|
|
108
|
+
variant = n.value.strip()
|
|
109
|
+
nodes.remove(n)
|
|
110
|
+
elif isinstance(n, AttrNode) and n.type == "transition":
|
|
111
|
+
transition = n.value.strip()
|
|
112
|
+
nodes.remove(n)
|
|
113
|
+
elif isinstance(n, AttrNode) and n.type in ("hidden", "hide"):
|
|
114
|
+
hidden = True
|
|
115
|
+
nodes.remove(n)
|
|
116
|
+
|
|
117
|
+
if hidden:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
return Slide(layout=layout, children=nodes, notes=notes, variant=variant, transition=transition)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _tokens_to_nodes(tokens: list[Token]) -> list:
|
|
124
|
+
nodes = []
|
|
125
|
+
i = 0
|
|
126
|
+
while i < len(tokens):
|
|
127
|
+
token = tokens[i]
|
|
128
|
+
if token.type == "heading_open":
|
|
129
|
+
level = int(token.tag[1])
|
|
130
|
+
i += 1
|
|
131
|
+
content = []
|
|
132
|
+
while i < len(tokens) and tokens[i].type != "heading_close":
|
|
133
|
+
content.extend(_token_inline(tokens[i]))
|
|
134
|
+
i += 1
|
|
135
|
+
nodes.append(Heading(level=level, content=content))
|
|
136
|
+
i += 1
|
|
137
|
+
elif token.type == "paragraph_open":
|
|
138
|
+
i += 1
|
|
139
|
+
content = []
|
|
140
|
+
while i < len(tokens) and tokens[i].type != "paragraph_close":
|
|
141
|
+
content.extend(_token_inline(tokens[i]))
|
|
142
|
+
i += 1
|
|
143
|
+
if content:
|
|
144
|
+
nodes.append(Paragraph(content=content))
|
|
145
|
+
i += 1
|
|
146
|
+
elif token.type == "blockquote_open":
|
|
147
|
+
i += 1
|
|
148
|
+
content = []
|
|
149
|
+
while i < len(tokens) and tokens[i].type != "blockquote_close":
|
|
150
|
+
if tokens[i].type == "inline":
|
|
151
|
+
content.extend(_token_inline(tokens[i]))
|
|
152
|
+
i += 1
|
|
153
|
+
if content:
|
|
154
|
+
nodes.append(Quote(content=content))
|
|
155
|
+
i += 1
|
|
156
|
+
elif token.type == "table_open":
|
|
157
|
+
rows = []
|
|
158
|
+
cur = []
|
|
159
|
+
i += 1
|
|
160
|
+
while i < len(tokens) and tokens[i].type != "table_close":
|
|
161
|
+
t = tokens[i]
|
|
162
|
+
if t.type in ("th_open", "td_open"):
|
|
163
|
+
i += 1
|
|
164
|
+
cell = ""
|
|
165
|
+
while i < len(tokens) and tokens[i].type not in ("th_close", "td_close"):
|
|
166
|
+
if tokens[i].type == "inline":
|
|
167
|
+
cell += _CELL_MD.renderer.renderInline(
|
|
168
|
+
tokens[i].children or [], _CELL_MD.options, {})
|
|
169
|
+
else:
|
|
170
|
+
cell += tokens[i].content
|
|
171
|
+
i += 1
|
|
172
|
+
cur.append(cell.strip())
|
|
173
|
+
elif t.type == "tr_close":
|
|
174
|
+
if cur:
|
|
175
|
+
rows.append(cur)
|
|
176
|
+
cur = []
|
|
177
|
+
i += 1
|
|
178
|
+
if cur:
|
|
179
|
+
rows.append(cur)
|
|
180
|
+
if rows:
|
|
181
|
+
nodes.append(Table(headers=rows[0], rows=rows[1:]))
|
|
182
|
+
i += 1
|
|
183
|
+
elif token.type in ("bullet_list_open", "ordered_list_open"):
|
|
184
|
+
close_type = token.type.replace("_open", "_close")
|
|
185
|
+
items = []
|
|
186
|
+
i += 1
|
|
187
|
+
while i < len(tokens) and tokens[i].type != close_type:
|
|
188
|
+
if tokens[i].type == "list_item_open":
|
|
189
|
+
i += 1
|
|
190
|
+
inlines = []
|
|
191
|
+
while i < len(tokens) and tokens[i].type != "list_item_close":
|
|
192
|
+
if tokens[i].type == "inline":
|
|
193
|
+
inlines.extend(_token_inline(tokens[i]))
|
|
194
|
+
i += 1
|
|
195
|
+
items.append(inlines)
|
|
196
|
+
i += 1
|
|
197
|
+
else:
|
|
198
|
+
i += 1
|
|
199
|
+
if items:
|
|
200
|
+
nodes.append(ListNode(items=items))
|
|
201
|
+
i += 1
|
|
202
|
+
elif token.type == "inline":
|
|
203
|
+
content = _token_inline(token)
|
|
204
|
+
if content:
|
|
205
|
+
nodes.append(Paragraph(content=content))
|
|
206
|
+
i += 1
|
|
207
|
+
elif token.type == "html_block":
|
|
208
|
+
html = token.content.strip()
|
|
209
|
+
node = parse_attr_token(html)
|
|
210
|
+
if node:
|
|
211
|
+
nodes.append(node)
|
|
212
|
+
i += 1
|
|
213
|
+
elif token.type in ("fence", "code_block"):
|
|
214
|
+
lang = token.info.strip() if token.info else ""
|
|
215
|
+
nodes.append(CodeBlock(content=token.content, language=lang))
|
|
216
|
+
i += 1
|
|
217
|
+
else:
|
|
218
|
+
i += 1
|
|
219
|
+
return nodes
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _token_inline(token: Token) -> list:
|
|
223
|
+
if token.type == "inline":
|
|
224
|
+
return _walk_inline_children(token.children or [])
|
|
225
|
+
elif token.type == "text":
|
|
226
|
+
return [Text(content=token.content)]
|
|
227
|
+
elif token.type == "code_inline":
|
|
228
|
+
return [CodeSpan(content=token.content)]
|
|
229
|
+
elif token.type == "softbreak":
|
|
230
|
+
return [SoftBreak()]
|
|
231
|
+
elif token.type == "hardbreak":
|
|
232
|
+
return [SoftBreak()]
|
|
233
|
+
elif token.type == "image":
|
|
234
|
+
attrs = dict(token.attrs or {})
|
|
235
|
+
return [Image(src=attrs.get("src", ""), alt=token.content or "", title=attrs.get("title", ""))]
|
|
236
|
+
elif token.type == "lucide_icon" or token.type == "svg":
|
|
237
|
+
attrs = dict(token.attrs or {})
|
|
238
|
+
name = attrs.pop("name", "")
|
|
239
|
+
return [LucideIcon(name=name, attrs=attrs)]
|
|
240
|
+
return []
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _walk_inline_children(tokens: list) -> list:
|
|
244
|
+
"""Walk inline token children, handling strong/em open/close pairs."""
|
|
245
|
+
result = []
|
|
246
|
+
i = 0
|
|
247
|
+
while i < len(tokens):
|
|
248
|
+
t = tokens[i]
|
|
249
|
+
if t.type == "strong_open":
|
|
250
|
+
i += 1
|
|
251
|
+
inner = []
|
|
252
|
+
while i < len(tokens) and tokens[i].type != "strong_close":
|
|
253
|
+
inner.extend(_token_inline(tokens[i]))
|
|
254
|
+
i += 1
|
|
255
|
+
result.append(Strong(children=inner))
|
|
256
|
+
elif t.type == "em_open":
|
|
257
|
+
i += 1
|
|
258
|
+
inner = []
|
|
259
|
+
while i < len(tokens) and tokens[i].type != "em_close":
|
|
260
|
+
inner.extend(_token_inline(tokens[i]))
|
|
261
|
+
i += 1
|
|
262
|
+
result.append(Emphasis(children=inner))
|
|
263
|
+
elif t.type == "s_open":
|
|
264
|
+
i += 1
|
|
265
|
+
inner = []
|
|
266
|
+
while i < len(tokens) and tokens[i].type != "s_close":
|
|
267
|
+
inner.extend(_token_inline(tokens[i]))
|
|
268
|
+
i += 1
|
|
269
|
+
result.append(Strikethrough(children=inner))
|
|
270
|
+
else:
|
|
271
|
+
result.extend(_token_inline(t))
|
|
272
|
+
i += 1
|
|
273
|
+
return result
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _detect_layout(nodes: list) -> str:
|
|
277
|
+
has_h1 = any(isinstance(n, Heading) and n.level == 1 for n in nodes)
|
|
278
|
+
has_kicker = any(isinstance(n, AttrNode) and n.type == "kicker" for n in nodes)
|
|
279
|
+
has_speaker = any(isinstance(n, AttrNode) and n.type == "speaker" for n in nodes)
|
|
280
|
+
if has_h1 or has_kicker or has_speaker:
|
|
281
|
+
return "title"
|
|
282
|
+
for n in nodes:
|
|
283
|
+
if isinstance(n, Grid):
|
|
284
|
+
if n.children and all(
|
|
285
|
+
isinstance(c, Card) and "metric" in (c.class_ or "") for c in n.children
|
|
286
|
+
):
|
|
287
|
+
return f"metrics-{n.cols}" if n.cols else "metrics-2"
|
|
288
|
+
return f"grid-{n.cols}" if n.cols else "grid-2"
|
|
289
|
+
return "content"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Card auto-grouping: consecutive Card nodes → Grid."""
|
|
2
|
+
|
|
3
|
+
from decktile.parser.ast import Card, Grid, Node
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def group_cards(nodes: list[Node]) -> list[Node]:
|
|
7
|
+
"""Auto-group consecutive Card nodes into a Grid."""
|
|
8
|
+
result = []
|
|
9
|
+
i = 0
|
|
10
|
+
while i < len(nodes):
|
|
11
|
+
if isinstance(nodes[i], Card):
|
|
12
|
+
cards = [nodes[i]]
|
|
13
|
+
j = i + 1
|
|
14
|
+
while j < len(nodes) and isinstance(nodes[j], Card):
|
|
15
|
+
cards.append(nodes[j])
|
|
16
|
+
j += 1
|
|
17
|
+
if len(cards) >= 2:
|
|
18
|
+
result.append(Grid(cols=len(cards), children=list(cards)))
|
|
19
|
+
else:
|
|
20
|
+
result.append(cards[0])
|
|
21
|
+
i = j
|
|
22
|
+
else:
|
|
23
|
+
result.append(nodes[i])
|
|
24
|
+
i += 1
|
|
25
|
+
return result
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Directive parsing: @kicker, @speaker, @subtitle, etc. → AttrNode."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from decktile.parser.ast import AttrNode, Paragraph, Text, Node
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def preprocess_directives(content: str) -> str:
|
|
8
|
+
"""Convert @directive lines to HTML comment markers."""
|
|
9
|
+
lines = content.split("\n")
|
|
10
|
+
result = []
|
|
11
|
+
for line in lines:
|
|
12
|
+
stripped = line.strip()
|
|
13
|
+
if stripped.startswith("@") and not stripped.startswith("@@"):
|
|
14
|
+
parts = stripped[1:].split(" ", 1)
|
|
15
|
+
typ = parts[0]
|
|
16
|
+
value = parts[1] if len(parts) > 1 else ""
|
|
17
|
+
result.append(f"<!--attr:{typ}:{value}-->")
|
|
18
|
+
else:
|
|
19
|
+
result.append(line)
|
|
20
|
+
return "\n".join(result)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extract_attrs(nodes: list[Node]) -> list[Node]:
|
|
24
|
+
"""Post-process: convert attr comment markers to AttrNodes."""
|
|
25
|
+
result = []
|
|
26
|
+
for node in nodes:
|
|
27
|
+
if isinstance(node, Paragraph) and len(node.content) == 1:
|
|
28
|
+
text = node.content[0].content if isinstance(node.content[0], Text) else ""
|
|
29
|
+
m = re.match(r"<!--attr:([\w-]+):(.*)-->", text)
|
|
30
|
+
if m:
|
|
31
|
+
typ, value = m.group(1), m.group(2)
|
|
32
|
+
attrs = _parse_attrs(value)
|
|
33
|
+
result.append(AttrNode(type=typ, value=value, attrs=attrs))
|
|
34
|
+
continue
|
|
35
|
+
result.append(node)
|
|
36
|
+
return result
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_attr_token(html: str) -> AttrNode | None:
|
|
40
|
+
"""Parse a single <!--attr:...--> comment into an AttrNode."""
|
|
41
|
+
m = re.match(r"<!--attr:([\w-]+):(.*)-->", html)
|
|
42
|
+
if m:
|
|
43
|
+
typ, value = m.group(1), m.group(2)
|
|
44
|
+
return AttrNode(type=typ, value=value, attrs=_parse_attrs(value))
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parse_attrs(value: str) -> dict[str, str]:
|
|
49
|
+
"""Parse key="val" and key=val patterns from a value string."""
|
|
50
|
+
attrs = {}
|
|
51
|
+
for ma in re.finditer(r'(\w+)="([^"]*)"', value):
|
|
52
|
+
attrs[ma.group(1)] = ma.group(2)
|
|
53
|
+
for ma in re.finditer(r'(\w+)=(\S+)', value):
|
|
54
|
+
if ma.group(1) not in attrs:
|
|
55
|
+
attrs[ma.group(1)] = ma.group(2)
|
|
56
|
+
return attrs
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Fenced block extraction: ::: card, ::: grid → AST nodes.
|
|
2
|
+
|
|
3
|
+
Card/grid ``{...}`` attributes: bare words → CSS classes, ``k=v`` → ``k-v`` classes.
|
|
4
|
+
Grid: ``cols=N`` and ``class=name`` used literally.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from markdown_it import MarkdownIt
|
|
9
|
+
from decktile.parser.ast import Arrow, Card, CodeBlock, Grid, Node, Notes
|
|
10
|
+
from decktile.plugins.lucide import lucide_plugin
|
|
11
|
+
|
|
12
|
+
_CARD_MD = MarkdownIt("gfm-like", {"breaks": False, "html": True})
|
|
13
|
+
_CARD_MD.use(lucide_plugin)
|
|
14
|
+
|
|
15
|
+
_ICON_RE = re.compile(r'\{icon:(\S+)\s*([^}]*)\}')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _expand_icons(text: str) -> str:
|
|
19
|
+
"""Expand {icon:...} markers using the shared lucide plugin renderer."""
|
|
20
|
+
if not text or "{" not in text:
|
|
21
|
+
return text
|
|
22
|
+
from decktile.plugins.lucide import render_icon, _parse_opts
|
|
23
|
+
def _render(m):
|
|
24
|
+
return render_icon(m.group(1), _parse_opts(m.group(2)))
|
|
25
|
+
return _ICON_RE.sub(_render, text)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def extract_fenced(content: str) -> tuple[str, list[Node]]:
|
|
29
|
+
"""Extract ::: fence blocks from content, returning cleaned text and nodes."""
|
|
30
|
+
lines = content.split("\n")
|
|
31
|
+
result = []
|
|
32
|
+
nodes = []
|
|
33
|
+
count = 0
|
|
34
|
+
i = 0
|
|
35
|
+
while i < len(lines):
|
|
36
|
+
t = lines[i].strip()
|
|
37
|
+
if t == ":::":
|
|
38
|
+
i += 1
|
|
39
|
+
continue
|
|
40
|
+
if t.startswith(":::") and not t.startswith("::::"):
|
|
41
|
+
rest = t[3:].strip()
|
|
42
|
+
typ = rest.split()[0].split("{")[0]
|
|
43
|
+
depth = 1
|
|
44
|
+
inner = []
|
|
45
|
+
j = i + 1
|
|
46
|
+
while j < len(lines) and depth > 0:
|
|
47
|
+
lt = lines[j].strip()
|
|
48
|
+
if lt.startswith(":::") and not lt.startswith("::::") and lt != ":::":
|
|
49
|
+
depth += 1
|
|
50
|
+
elif lt == ":::":
|
|
51
|
+
depth -= 1
|
|
52
|
+
if depth > 0:
|
|
53
|
+
inner.append(lines[j])
|
|
54
|
+
j += 1
|
|
55
|
+
inner_text = "\n".join(inner)
|
|
56
|
+
|
|
57
|
+
if typ == "card":
|
|
58
|
+
nodes.append(_parse_card(inner_text, rest))
|
|
59
|
+
elif typ == "grid":
|
|
60
|
+
nodes.append(_parse_grid(inner_text, rest))
|
|
61
|
+
elif typ == "arrow":
|
|
62
|
+
nodes.append(_parse_arrow(inner_text))
|
|
63
|
+
elif typ == "notes":
|
|
64
|
+
nodes.append(_parse_notes(inner_text, rest))
|
|
65
|
+
|
|
66
|
+
# blank lines around the marker: adjacent fences (cards with no
|
|
67
|
+
# blank line between them) would otherwise merge into one
|
|
68
|
+
# markdown paragraph, and interleave_fences restores one fence
|
|
69
|
+
# per marker paragraph -- the rest got appended after every
|
|
70
|
+
# other block, splitting a card group
|
|
71
|
+
result.extend(["", f"\u25caFENCE_{count}", ""])
|
|
72
|
+
count += 1
|
|
73
|
+
i = j
|
|
74
|
+
continue
|
|
75
|
+
result.append(lines[i])
|
|
76
|
+
i += 1
|
|
77
|
+
|
|
78
|
+
return "\n".join(result), nodes
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _parse_card(text: str, rest: str = "") -> Card:
|
|
82
|
+
header = ""
|
|
83
|
+
body = []
|
|
84
|
+
children = []
|
|
85
|
+
|
|
86
|
+
lines = text.strip().split("\n")
|
|
87
|
+
i = 0
|
|
88
|
+
while i < len(lines):
|
|
89
|
+
line = lines[i].strip()
|
|
90
|
+
|
|
91
|
+
if line.startswith("```"):
|
|
92
|
+
lang = line[3:].strip()
|
|
93
|
+
inner = []
|
|
94
|
+
i += 1
|
|
95
|
+
while i < len(lines) and not lines[i].strip().startswith("```"):
|
|
96
|
+
inner.append(lines[i])
|
|
97
|
+
i += 1
|
|
98
|
+
children.append(CodeBlock(content="\n".join(inner), language=lang))
|
|
99
|
+
i += 1 # skip closing ```
|
|
100
|
+
continue
|
|
101
|
+
elif line.startswith(":::") and not line.startswith("::::"):
|
|
102
|
+
depth = 1
|
|
103
|
+
inner = []
|
|
104
|
+
j = i + 1
|
|
105
|
+
while j < len(lines) and depth > 0:
|
|
106
|
+
lt = lines[j].strip()
|
|
107
|
+
if lt.startswith(":::") and not lt.startswith("::::") and lt != ":::":
|
|
108
|
+
depth += 1
|
|
109
|
+
elif lt == ":::":
|
|
110
|
+
depth -= 1
|
|
111
|
+
if depth > 0:
|
|
112
|
+
inner.append(lines[j])
|
|
113
|
+
j += 1
|
|
114
|
+
_, nested = extract_fenced("\n".join(inner))
|
|
115
|
+
children.extend(nested)
|
|
116
|
+
i = j
|
|
117
|
+
continue
|
|
118
|
+
elif line.startswith("### "):
|
|
119
|
+
header = _expand_markdown(line[4:])
|
|
120
|
+
elif line.startswith("- "):
|
|
121
|
+
items = []
|
|
122
|
+
while i < len(lines) and lines[i].strip().startswith("- "):
|
|
123
|
+
li = lines[i].strip()[2:]
|
|
124
|
+
items.append(f"<li>{_expand_markdown(li)}</li>")
|
|
125
|
+
i += 1
|
|
126
|
+
body.append(f"<ul>{''.join(items)}</ul>")
|
|
127
|
+
continue
|
|
128
|
+
elif line:
|
|
129
|
+
rendered = _expand_markdown(line)
|
|
130
|
+
if rendered.strip():
|
|
131
|
+
body.append(rendered)
|
|
132
|
+
i += 1
|
|
133
|
+
|
|
134
|
+
class_ = ""
|
|
135
|
+
tag = None
|
|
136
|
+
label = ""
|
|
137
|
+
raw = rest.split("{", 1)[1].rstrip("}") if "{" in rest else ""
|
|
138
|
+
for attr in raw.split(","):
|
|
139
|
+
attr = attr.strip()
|
|
140
|
+
if not attr:
|
|
141
|
+
continue
|
|
142
|
+
if "=" in attr:
|
|
143
|
+
k, v = attr.split("=", 1)
|
|
144
|
+
k, v = k.strip(), v.strip().strip('"')
|
|
145
|
+
if k == "tag":
|
|
146
|
+
tag = v
|
|
147
|
+
elif k == "label":
|
|
148
|
+
label = v
|
|
149
|
+
else:
|
|
150
|
+
class_ = (class_ + f" {k}-{v}").strip()
|
|
151
|
+
else:
|
|
152
|
+
class_ = (class_ + " " + attr).strip() if class_ else attr
|
|
153
|
+
|
|
154
|
+
# ponytail: metric cards: first non-empty line is the value, rest is the label
|
|
155
|
+
if "metric" in class_ and not header and body:
|
|
156
|
+
header = body.pop(0)
|
|
157
|
+
|
|
158
|
+
return Card(header=header, body=body, children=children, tag=tag, class_=class_, label=label)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _expand_markdown(text: str) -> str:
|
|
162
|
+
"""Render inline markdown (bold, italic, code, images, lucide icons, links)."""
|
|
163
|
+
if not text:
|
|
164
|
+
return text
|
|
165
|
+
text = _expand_icons(text)
|
|
166
|
+
return _CARD_MD.renderInline(text)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _parse_grid(inner_text: str, rest: str) -> Grid:
|
|
170
|
+
_, children = extract_fenced(inner_text)
|
|
171
|
+
raw = " ".join(rest.split()[1:]).strip("{}")
|
|
172
|
+
cols = 0
|
|
173
|
+
class_ = ""
|
|
174
|
+
for attr in raw.split(","):
|
|
175
|
+
attr = attr.strip()
|
|
176
|
+
if not attr:
|
|
177
|
+
continue
|
|
178
|
+
if "=" in attr:
|
|
179
|
+
k, v = attr.split("=", 1)
|
|
180
|
+
k, v = k.strip(), v.strip().strip('"')
|
|
181
|
+
if k == "cols":
|
|
182
|
+
cols = int(v)
|
|
183
|
+
elif k == "class":
|
|
184
|
+
class_ = v
|
|
185
|
+
else:
|
|
186
|
+
class_ = (class_ + " " + attr).strip() if class_ else attr
|
|
187
|
+
if cols == 0:
|
|
188
|
+
cols = len(children) or 2
|
|
189
|
+
return Grid(cols=cols, class_=class_, children=children)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _parse_arrow(text: str) -> Arrow:
|
|
193
|
+
return Arrow(content=_expand_icons(text.strip()) or "\u2192")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _parse_notes(text: str, rest: str) -> Notes:
|
|
197
|
+
tag = ""
|
|
198
|
+
raw = rest.split("{", 1)[1].rstrip("}") if "{" in rest else ""
|
|
199
|
+
for attr in raw.split(","):
|
|
200
|
+
attr = attr.strip()
|
|
201
|
+
if "=" in attr:
|
|
202
|
+
k, v = attr.split("=", 1)
|
|
203
|
+
if k.strip() == "tag":
|
|
204
|
+
tag = v.strip().strip('"')
|
|
205
|
+
# README documents `> quote` lines inside notes; the callout is rendered
|
|
206
|
+
# inline, so the blockquote marker otherwise showed as a literal '>'
|
|
207
|
+
text = "\n".join(re.sub(r"^\s*>\s?", "", line) for line in text.strip().splitlines())
|
|
208
|
+
return Notes(content=_expand_markdown(text), tag=tag or None)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def interleave_fences(nodes: list[Node], fence_nodes: list[Node]) -> list[Node]:
|
|
212
|
+
"""Replace FENCE marker paragraphs with actual fence nodes."""
|
|
213
|
+
from decktile.parser.ast import Paragraph, Text
|
|
214
|
+
result = []
|
|
215
|
+
fi = 0
|
|
216
|
+
for node in nodes:
|
|
217
|
+
if isinstance(node, Paragraph) and node.content:
|
|
218
|
+
text = node.content[0].content if isinstance(node.content[0], Text) else ""
|
|
219
|
+
if text.startswith("\u25caFENCE_"):
|
|
220
|
+
if fi < len(fence_nodes):
|
|
221
|
+
result.append(fence_nodes[fi])
|
|
222
|
+
fi += 1
|
|
223
|
+
continue
|
|
224
|
+
result.append(node)
|
|
225
|
+
while fi < len(fence_nodes):
|
|
226
|
+
result.append(fence_nodes[fi])
|
|
227
|
+
fi += 1
|
|
228
|
+
return result
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Layout processing: applies predefined layouts to slide content."""
|
|
2
|
+
|
|
3
|
+
from decktile.parser.ast import Paragraph, AttrNode, Image
|
|
4
|
+
|
|
5
|
+
KNOWN_LAYOUTS = {"image-right", "image-left", "two-col", "compare", "ecosystem"}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def apply_layout(nodes: list, layout: str, render_fn) -> str:
|
|
9
|
+
"""Wrap body nodes into columns per the layout definition."""
|
|
10
|
+
if layout not in KNOWN_LAYOUTS:
|
|
11
|
+
return "\n".join(filter(None, (render_fn(n) for n in nodes)))
|
|
12
|
+
|
|
13
|
+
if layout == "image-right":
|
|
14
|
+
left, right = _split_col_or_image(nodes, flip=False)
|
|
15
|
+
elif layout == "image-left":
|
|
16
|
+
left, right = _split_col_or_image(nodes, flip=True)
|
|
17
|
+
elif layout == "two-col":
|
|
18
|
+
left, right = _split_two_col_nodes(nodes)
|
|
19
|
+
elif layout == "compare":
|
|
20
|
+
left, right = _split_two_col_nodes(nodes)
|
|
21
|
+
else:
|
|
22
|
+
return "\n".join(filter(None, (render_fn(n) for n in nodes)))
|
|
23
|
+
|
|
24
|
+
left_html = "\n".join(filter(None, (render_fn(n) for n in left)))
|
|
25
|
+
right_html = "\n".join(filter(None, (render_fn(n) for n in right)))
|
|
26
|
+
parts = []
|
|
27
|
+
if left_html or right_html:
|
|
28
|
+
if layout == "compare":
|
|
29
|
+
parts.append('<div class="layout-cols compare">')
|
|
30
|
+
parts.append('<div class="col-left">\n' + left_html + '\n</div>')
|
|
31
|
+
parts.append('<div class="card-arrow">\u2192</div>')
|
|
32
|
+
parts.append('<div class="col-right">\n' + right_html + '\n</div>')
|
|
33
|
+
else:
|
|
34
|
+
parts.append('<div class="layout-cols">')
|
|
35
|
+
parts.append('<div class="col-left">\n' + left_html + '\n</div>')
|
|
36
|
+
if right_html:
|
|
37
|
+
parts.append('<div class="col-right">\n' + right_html + '\n</div>')
|
|
38
|
+
parts.append('</div>')
|
|
39
|
+
return "\n".join(parts)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _split_col_or_image(nodes: list, flip: bool = False) -> tuple[list, list]:
|
|
43
|
+
col_idx = _find_col(nodes)
|
|
44
|
+
if col_idx >= 0:
|
|
45
|
+
left, right = nodes[:col_idx], nodes[col_idx + 1:]
|
|
46
|
+
return (right, left) if flip else (left, right)
|
|
47
|
+
return _split_image_nodes(nodes, flip)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _split_image_nodes(nodes: list, flip: bool = False) -> tuple[list, list]:
|
|
51
|
+
for i, n in enumerate(nodes):
|
|
52
|
+
if isinstance(n, Paragraph) and any(isinstance(t, Image) for t in n.content):
|
|
53
|
+
left, right = nodes[:i] + nodes[i + 1:], [n]
|
|
54
|
+
return (right, left) if flip else (left, right)
|
|
55
|
+
return (nodes, []) if not flip else ([], nodes)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _split_two_col_nodes(nodes: list) -> tuple[list, list]:
|
|
59
|
+
col_idx = _find_col(nodes)
|
|
60
|
+
if col_idx >= 0:
|
|
61
|
+
return nodes[:col_idx], nodes[col_idx + 1:]
|
|
62
|
+
return nodes, []
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _find_col(nodes: list) -> int:
|
|
66
|
+
for i, n in enumerate(nodes):
|
|
67
|
+
if isinstance(n, AttrNode) and n.type == "col":
|
|
68
|
+
return i
|
|
69
|
+
return -1
|