"""
Assembles complete documentation for any Duck component.
"""
import re
from duck.html.components.container import Container, FlexContainer
from duck.html.components.heading import Heading
from duck.html.components.paragraph import Paragraph
from quirl.components.animation.demo import Demo
from quirl.components.badge import Badge
from quirl.components.code import CodeBlock
from quirl.components.divider import Divider
from quirl.theme import Theme
TYPE_ANNOTATION_PATTERN = re.compile(r"^(?P<name>[^\s(]+)\s*(?:\((?P<type>[^)]+)\))?$")
[docs]
class Documentor(Container):
"""
Stitches together full documentation for any Duck component.
Combines the docstring description, a props reference, a usage code
block, and an animated live preview into a single styled
documentation card.
Required Props:
- component_cls: The component class to document. Required.
Optional Props:
- title: Optional heading text. Defaults to the class name.
- id: Stable DOM id for the documentation card.
"""
docs_preview_kwargs = None
docs_no_preview_reason = (
"Documentor requires a component_cls to document and isn't "
"meant to preview itself."
)
[docs]
def on_create(self):
"""
Build the documentation card from the component's docstring.
"""
super().on_create()
# Read configuration at construction time
self.component_class = self.get_kwarg_or_raise("component_cls")
self.doc_title = self.kwargs.get("title", self.component_class.__name__)
self.doc_id = self.kwargs.get("id", f"{self.component_class.__name__.lower()}-docs")
self.id = self.doc_id
# Apply container base styles as an elevated, responsive card
self.style.update({
"padding": "clamp(20px, 5vw, 32px)",
"gap": "20px",
"display": "flex",
"flex-direction": "column",
"background": Theme.current.surface_color,
"border": f"1px solid {Theme.current.border_color}",
"border-radius": Theme.current.border_radius,
"max-width": "720px",
"width": "100%",
"box-sizing": "border-box",
"font-family": Theme.current.font_family,
"animation": "quirl-fade-in 0.3s ease-out",
})
# Assemble documentation sections, separated by dividers
self.add_children([
self.build_header(),
self.build_description(),
Divider(),
self.build_props_section(),
Divider(),
self.build_usage_section(),
Divider(),
self.build_preview_section(),
])
[docs]
def build_description(self) -> Paragraph:
"""
Return the first paragraph of the docstring as a description.
Returns:
A Paragraph with the description text, or an empty Paragraph
if no docstring is present.
"""
doc = self.component_class.__doc__ or ""
first_line = doc.strip().split("\n")[0] if doc else ""
return Paragraph(
text=first_line,
style={"margin": "0", "color": Theme.current.muted_text_color, "line-height": "1.5"},
)
[docs]
def build_props_section(self) -> Container:
"""
Parse Required Props and Optional Props from the docstring.
Supports typed prop names (e.g. ``key (str): ...``) and
multi-line descriptions, where continuation lines are indented
under the prop and a blank line separates one prop from the next.
Returns:
A Container with headings and prop rows for each section
found in the docstring.
"""
doc = self.component_class.__doc__ or ""
lines = doc.split("\n")
required = []
optional = []
section = None
current_prop = None
def flush_current_prop():
"""
Join and store the in-progress prop buffer, if any.
"""
if current_prop is None:
return
prop_text = " ".join(current_prop)
if section == "required":
required.append(prop_text)
elif section == "optional":
optional.append(prop_text)
# Parse prop lines from the docstring
for line in lines:
stripped = line.strip()
if stripped == "Required Props:":
flush_current_prop()
current_prop = None
section = "required"
continue
elif stripped == "Optional Props:":
flush_current_prop()
current_prop = None
section = "optional"
continue
elif stripped.startswith("```") or stripped.startswith("Usage:"):
flush_current_prop()
current_prop = None
section = None
continue
if not section:
continue
if stripped.startswith("-"):
flush_current_prop()
current_prop = [stripped.lstrip("- ").strip()]
elif stripped == "":
# Blank line: separator only, prop is flushed on next "-"
continue
elif current_prop is not None:
# Continuation line for the current prop's description
current_prop.append(stripped)
flush_current_prop()
children = []
# Add required props block
if required:
children.append(self.build_prop_group("Required Props", required, "warning"))
# Add optional props block
if optional:
children.append(self.build_prop_group("Optional Props", optional, "neutral"))
return Container(
style={"display": "flex", "flex-direction": "column", "gap": "16px"},
children=children,
)
[docs]
def build_prop_group(self, label: str, props: list, badge_variant: str) -> Container:
"""
Build a single labeled group of prop rows.
Args:
label: Section heading text, e.g. "Required Props".
props: List of raw "name: description" prop lines.
badge_variant: Badge variant used for the section label.
Returns:
A Container with the section heading and prop rows.
"""
return Container(
style={"display": "flex", "flex-direction": "column", "gap": "8px"},
children=[
Container(
style={"display": "flex", "align-items": "center", "gap": "8px"},
children=[
Heading(
type="h4",
text=label,
style={"margin": "0", "font-size": "0.9rem", "color": Theme.current.text_color},
),
Badge(text=str(len(props)), variant=badge_variant, size="sm"),
],
),
Container(
style={"display": "flex", "flex-direction": "column", "gap": "6px"},
children=[self.build_prop_row(prop) for prop in props],
),
],
)
[docs]
def build_prop_row(self, prop_text: str) -> Container:
"""
Build a single prop row with the name emphasized as code.
Supports an optional type annotation in the name, formatted as
`key (type): description`.
Args:
prop_text: Raw "name: description" text from the docstring.
Returns:
A Container styled as a row inside a props list.
"""
name_part, _, description = prop_text.partition(":")
match = TYPE_ANNOTATION_PATTERN.match(name_part.strip())
name = match.group("name") if match else name_part.strip()
prop_type = match.group("type") if match else None
name_children = [
Paragraph(
text=name,
style={
"margin": "0",
"font-family": "monospace",
"font-weight": "600",
"color": Theme.current.accent_color,
},
),
]
if prop_type:
name_children.append(
Paragraph(
text=f"({prop_type})",
style={
"margin": "0",
"font-family": "monospace",
"font-size": "0.85em",
"color": Theme.current.muted_text_color,
},
),
)
return Container(
style={
"display": "flex",
"flex-wrap": "wrap",
"gap": "8px",
"padding": "8px 12px",
"border-radius": Theme.current.border_radius_sm,
"background": Theme.current.surface_elevated_color,
},
children=[
Container(
style={"display": "flex", "gap": "6px", "align-items": "baseline"},
children=name_children,
),
Paragraph(
text=description.strip(),
style={"margin": "0", "color": Theme.current.muted_text_color},
),
],
)
[docs]
def build_usage_section(self) -> Container:
"""
Build the Usage code block from the same kwargs driving the Demo.
Sourcing usage from `docs_preview_kwargs` (rather than a
hand-written docstring snippet) guarantees the code shown here
always matches what the live preview actually renders. Falls
back to parsing a ```python block from the docstring only for
components that opt out of a preview entirely.
Returns:
A Container with a heading and code block, or an empty
Container if there's nothing to show.
"""
preview_kwargs = getattr(self.component_class, "docs_preview_kwargs", {})
if preview_kwargs is not None:
usage_code = self.format_usage_code(preview_kwargs)
else:
usage_code = self.extract_docstring_usage()
if not usage_code:
return Container(
style={"display": "flex", "flex-direction": "column", "gap": "8px"},
children=[
Heading(
type="h4",
text="Usage",
style={"margin": "0", "font-size": "0.9rem", "color": Theme.current.text_color},
),
Container(
text="Usage code not available",
style={
"display": "flex",
"align-items": "center",
"justify-content": "center",
"padding": "32px 16px",
"border-radius": Theme.current.border_radius,
"border": f"1px dashed {Theme.current.border_color}",
"color": Theme.current.muted_text_color,
"font-size": "0.875rem",
"text-align": "center",
},
),
],
)
return FlexContainer(
style={"flex-direction": "column", "gap": "8px"},
children=[
Heading(
type="h4",
text="Usage",
style={"margin": "0", "font-size": "0.9rem", "color": Theme.current.text_color},
),
Container(
style={
"border-radius": Theme.current.border_radius_sm,
"overflow": "hidden",
"max-width": "100%",
},
children=[CodeBlock(code=usage_code, language="python")],
),
],
)
[docs]
def build_preview_section(self):
"""
Build the live preview with animated demo, or a placeholder.
A component opts out of a preview by setting the class attribute
``docs_preview_kwargs = None`` (as opposed to ``{}``, which means
"no kwargs needed but a preview is still wanted"). Optionally set
``docs_no_preview_reason`` for a specific explanation.
Returns:
A Demo component with the live preview and animation, or a
Container explaining why no preview is available.
"""
preview_kwargs = getattr(self.component_class, "docs_preview_kwargs", {})
if preview_kwargs is None:
return self.build_no_preview_placeholder()
animation_steps = getattr(self.component_class, "docs_animation_steps", [])
instance = self.component_class(**preview_kwargs)
return Demo(
component=instance,
steps=animation_steps,
title="Live Preview",
)
[docs]
def build_no_preview_placeholder(self) -> Container:
"""
Build the placeholder shown when a component opts out of a preview.
Returns:
A dashed-border Container explaining the lack of a preview.
"""
reason = getattr(
self.component_class,
"docs_no_preview_reason",
"No live preview available for this component.",
)
return FlexContainer(
style={"flex-direction": "column", "gap": "8px"},
children=[
Heading(
type="h4",
text="Live Preview",
style={"margin": "0", "font-size": "0.9rem", "color": Theme.current.text_color},
),
Container(
style={
"display": "flex",
"align-items": "center",
"justify-content": "center",
"padding": "32px 16px",
"border-radius": Theme.current.border_radius,
"border": f"1px dashed {Theme.current.border_color}",
"color": Theme.current.muted_text_color,
"font-size": "0.875rem",
"text-align": "center",
},
children=[Paragraph(text=reason, style={"margin": "0"})],
),
]
)