Rename all 1,169 components to path-based names with namespace support

Component names now reflect filesystem location using / as path separator
and : as namespace separator for shared components:
  ~sx-header → ~layouts/header
  ~layout-app-body → ~shared:layout/app-body
  ~blog-admin-dashboard → ~admin/dashboard

209 files, 4,941 replacements across all services.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 22:00:12 +00:00
parent de80d921e9
commit b0920a1121
209 changed files with 4620 additions and 4620 deletions

View File

@@ -256,7 +256,7 @@ def _image(node: dict) -> str:
parts.append(f':width "{_esc(width)}"')
if href:
parts.append(f':href "{_esc(href)}"')
return "(~kg-image " + " ".join(parts) + ")"
return "(~kg_cards/kg-image " + " ".join(parts) + ")"
@_converter("gallery")
@@ -282,14 +282,14 @@ def _gallery(node: dict) -> str:
images_sx = "(list " + " ".join(rows) + ")"
caption = node.get("caption", "")
caption_attr = f" :caption {html_to_sx(caption)}" if caption else ""
return f"(~kg-gallery :images {images_sx}{caption_attr})"
return f"(~kg_cards/kg-gallery :images {images_sx}{caption_attr})"
@_converter("html")
def _html_card(node: dict) -> str:
raw = node.get("html", "")
inner = html_to_sx(raw)
return f"(~kg-html {inner})"
return f"(~kg_cards/kg-html {inner})"
@_converter("embed")
@@ -299,7 +299,7 @@ def _embed(node: dict) -> str:
parts = [f':html "{_esc(embed_html)}"']
if caption:
parts.append(f":caption {html_to_sx(caption)}")
return "(~kg-embed " + " ".join(parts) + ")"
return "(~kg_cards/kg-embed " + " ".join(parts) + ")"
@_converter("bookmark")
@@ -330,7 +330,7 @@ def _bookmark(node: dict) -> str:
if caption:
parts.append(f":caption {html_to_sx(caption)}")
return "(~kg-bookmark " + " ".join(parts) + ")"
return "(~kg_cards/kg-bookmark " + " ".join(parts) + ")"
@_converter("callout")
@@ -344,7 +344,7 @@ def _callout(node: dict) -> str:
parts.append(f':emoji "{_esc(emoji)}"')
if inner:
parts.append(f':content {inner}')
return "(~kg-callout " + " ".join(parts) + ")"
return "(~kg_cards/kg-callout " + " ".join(parts) + ")"
@_converter("button")
@@ -352,7 +352,7 @@ def _button(node: dict) -> str:
text = node.get("buttonText", "")
url = node.get("buttonUrl", "")
alignment = node.get("alignment", "center")
return f'(~kg-button :url "{_esc(url)}" :text "{_esc(text)}" :alignment "{_esc(alignment)}")'
return f'(~kg_cards/kg-button :url "{_esc(url)}" :text "{_esc(text)}" :alignment "{_esc(alignment)}")'
@_converter("toggle")
@@ -360,7 +360,7 @@ def _toggle(node: dict) -> str:
heading = node.get("heading", "")
inner = _convert_children(node.get("children", []))
content_attr = f" :content {inner}" if inner else ""
return f'(~kg-toggle :heading "{_esc(heading)}"{content_attr})'
return f'(~kg_cards/kg-toggle :heading "{_esc(heading)}"{content_attr})'
@_converter("audio")
@@ -380,7 +380,7 @@ def _audio(node: dict) -> str:
parts.append(f':duration "{duration_str}"')
if thumbnail:
parts.append(f':thumbnail "{_esc(thumbnail)}"')
return "(~kg-audio " + " ".join(parts) + ")"
return "(~kg_cards/kg-audio " + " ".join(parts) + ")"
@_converter("video")
@@ -400,7 +400,7 @@ def _video(node: dict) -> str:
parts.append(f':thumbnail "{_esc(thumbnail)}"')
if loop:
parts.append(":loop true")
return "(~kg-video " + " ".join(parts) + ")"
return "(~kg_cards/kg-video " + " ".join(parts) + ")"
@_converter("file")
@@ -429,12 +429,12 @@ def _file(node: dict) -> str:
parts.append(f':filesize "{size_str}"')
if caption:
parts.append(f":caption {html_to_sx(caption)}")
return "(~kg-file " + " ".join(parts) + ")"
return "(~kg_cards/kg-file " + " ".join(parts) + ")"
@_converter("paywall")
def _paywall(_node: dict) -> str:
return "(~kg-paywall)"
return "(~kg_cards/kg-paywall)"
@_converter("markdown")
@@ -442,4 +442,4 @@ def _markdown(node: dict) -> str:
md_text = node.get("markdown", "")
rendered = mistune.html(md_text)
inner = html_to_sx(rendered)
return f"(~kg-md {inner})"
return f"(~kg_cards/kg-md {inner})"

View File

@@ -1,10 +1,10 @@
#!/usr/bin/env python3
"""
Re-convert sx_content from lexical JSON to eliminate ~kg-html wrappers and
Re-convert sx_content from lexical JSON to eliminate ~kg_cards/kg-html wrappers and
raw caption strings.
The updated lexical_to_sx converter now produces native sx expressions instead
of (1) wrapping HTML/markdown cards in (~kg-html :html "...") and (2) storing
of (1) wrapping HTML/markdown cards in (~kg_cards/kg-html :html "...") and (2) storing
captions as escaped HTML strings. This script re-runs the conversion on all
posts that already have sx_content, overwriting the old output.
@@ -50,11 +50,11 @@ async def migrate(dry_run: bool = False) -> int:
continue
if dry_run:
old_has_kg = "~kg-html" in (post.sx_content or "")
old_has_kg = "~kg_cards/kg-html" in (post.sx_content or "")
old_has_raw = "raw! caption" in (post.sx_content or "")
markers = []
if old_has_kg:
markers.append("~kg-html")
markers.append("~kg_cards/kg-html")
if old_has_raw:
markers.append("raw-caption")
tag = f" [{', '.join(markers)}]" if markers else ""
@@ -76,7 +76,7 @@ async def migrate(dry_run: bool = False) -> int:
def main():
parser = argparse.ArgumentParser(
description="Re-convert sx_content to eliminate ~kg-html and raw captions"
description="Re-convert sx_content to eliminate ~kg_cards/kg-html and raw captions"
)
parser.add_argument("--dry-run", action="store_true",
help="Preview changes without writing to database")

View File

@@ -398,7 +398,7 @@ class BlogPageService:
}
def post_detail_data(self, post, user, rights, csrf, blog_url_base):
"""Serialize post detail view data for ~blog-post-detail-content defcomp."""
"""Serialize post detail view data for ~detail/post-detail-content defcomp."""
slug = post.get("slug", "")
is_admin = rights.get("admin") if isinstance(rights, dict) else getattr(rights, "admin", False)
user_id = getattr(user, "id", None) if user else None

View File

@@ -1,6 +1,6 @@
;; Blog admin panel components
(defcomp ~blog-cache-panel (&key (clear-url :as string) (csrf :as string))
(defcomp ~admin/cache-panel (&key (clear-url :as string) (csrf :as string))
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
(div :class "flex flex-col md:flex-row gap-3 items-start"
(form :sx-post clear-url :sx-trigger "submit" :sx-target "#cache-status" :sx-swap "innerHTML"
@@ -8,21 +8,21 @@
(button :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" :type "submit" "Clear cache"))
(div :id "cache-status" :class "py-2"))))
(defcomp ~blog-snippets-panel (&key list)
(defcomp ~admin/snippets-panel (&key list)
(div :class "max-w-4xl mx-auto p-6"
(div :class "mb-6 flex justify-between items-center"
(h1 :class "text-3xl font-bold" "Snippets"))
(div :id "snippets-list" list)))
(defcomp ~blog-snippet-visibility-select (&key patch-url hx-headers options cls)
(defcomp ~admin/snippet-visibility-select (&key patch-url hx-headers options cls)
(select :name "visibility" :sx-patch patch-url :sx-target "#snippets-list" :sx-swap "innerHTML"
:sx-headers hx-headers :class "text-sm border border-stone-300 rounded px-2 py-1"
options))
(defcomp ~blog-snippet-option (&key (value :as string) (selected :as boolean) (label :as string))
(defcomp ~admin/snippet-option (&key (value :as string) (selected :as boolean) (label :as string))
(option :value value :selected selected label))
(defcomp ~blog-snippet-row (&key (name :as string) (owner :as string) (badge-cls :as string) (visibility :as string) extra)
(defcomp ~admin/snippet-row (&key (name :as string) (owner :as string) (badge-cls :as string) (visibility :as string) extra)
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
(div :class "flex-1 min-w-0"
(div :class "font-medium truncate" name)
@@ -30,10 +30,10 @@
(span :class (str "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium " badge-cls) visibility)
extra))
(defcomp ~blog-snippets-list (&key rows)
(defcomp ~admin/snippets-list (&key rows)
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" rows)))
(defcomp ~blog-menu-items-panel (&key new-url list)
(defcomp ~admin/menu-items-panel (&key new-url list)
(div :class "max-w-4xl mx-auto p-6"
(div :class "mb-6 flex justify-end items-center"
(button :type "button" :sx-get new-url :sx-target "#menu-item-form" :sx-swap "innerHTML"
@@ -42,7 +42,7 @@
(div :id "menu-item-form" :class "mb-6")
(div :id "menu-items-list" list)))
(defcomp ~blog-menu-item-row (&key img (label :as string) (slug :as string) (sort-order :as string) (edit-url :as string) (delete-url :as string) (confirm-text :as string) hx-headers)
(defcomp ~admin/menu-item-row (&key img (label :as string) (slug :as string) (sort-order :as string) (edit-url :as string) (delete-url :as string) (confirm-text :as string) hx-headers)
(div :class "flex items-center gap-4 p-4 hover:bg-stone-50 transition"
(div :class "text-stone-400 cursor-move" (i :class "fa fa-grip-vertical"))
img
@@ -54,16 +54,16 @@
(button :type "button" :sx-get edit-url :sx-target "#menu-item-form" :sx-swap "innerHTML"
:class "px-3 py-1 text-sm bg-stone-200 hover:bg-stone-300 rounded"
(i :class "fa fa-edit") " Edit")
(~delete-btn :url delete-url :trigger-target "#menu-items-list"
(~shared:misc/delete-btn :url delete-url :trigger-target "#menu-items-list"
:title "Delete menu item?" :text confirm-text
:sx-headers hx-headers))))
(defcomp ~blog-menu-items-list (&key rows)
(defcomp ~admin/menu-items-list (&key rows)
(div :class "bg-white rounded-lg shadow" (div :class "divide-y" rows)))
;; Tag groups admin
(defcomp ~blog-tag-groups-create-form (&key create-url csrf)
(defcomp ~admin/tag-groups-create-form (&key create-url csrf)
(form :method "post" :action create-url :class "border rounded p-4 bg-white space-y-3"
(input :type "hidden" :name "csrf_token" :value csrf)
(h3 :class "text-sm font-semibold text-stone-700" "New Group")
@@ -74,14 +74,14 @@
(input :type "text" :name "feature_image" :placeholder "Image URL (optional)" :class "w-full border rounded px-3 py-2 text-sm")
(button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Create")))
(defcomp ~blog-tag-group-icon-image (&key src name)
(defcomp ~admin/tag-group-icon-image (&key src name)
(img :src src :alt name :class "h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"))
(defcomp ~blog-tag-group-icon-color (&key style initial)
(defcomp ~admin/tag-group-icon-color (&key style initial)
(div :class "h-8 w-8 rounded-full text-xs font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0"
:style style initial))
(defcomp ~blog-tag-group-li (&key icon (edit-href :as string) (name :as string) (slug :as string) (sort-order :as number))
(defcomp ~admin/tag-group-li (&key icon (edit-href :as string) (name :as string) (slug :as string) (sort-order :as number))
(li :class "border rounded p-3 bg-white flex items-center gap-3"
icon
(div :class "flex-1"
@@ -89,32 +89,32 @@
(span :class "text-xs text-stone-500 ml-2" slug))
(span :class "text-xs text-stone-500" (str "order: " sort-order))))
(defcomp ~blog-tag-groups-list (&key items)
(defcomp ~admin/tag-groups-list (&key items)
(ul :class "space-y-2" items))
(defcomp ~blog-unassigned-tag (&key name)
(defcomp ~admin/unassigned-tag (&key name)
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200 rounded" name))
(defcomp ~blog-unassigned-tags (&key heading spans)
(defcomp ~admin/unassigned-tags (&key heading spans)
(div :class "border-t pt-4"
(h3 :class "text-sm font-semibold text-stone-700 mb-2" heading)
(div :class "flex flex-wrap gap-2" spans)))
(defcomp ~blog-tag-groups-main (&key form groups unassigned)
(defcomp ~admin/tag-groups-main (&key form groups unassigned)
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-8"
form groups unassigned))
;; Tag group edit
(defcomp ~blog-tag-checkbox (&key (tag-id :as string) (checked :as boolean) img (name :as string))
(defcomp ~admin/tag-checkbox (&key (tag-id :as string) (checked :as boolean) img (name :as string))
(label :class "flex items-center gap-2 px-2 py-1 hover:bg-stone-50 rounded text-sm cursor-pointer"
(input :type "checkbox" :name "tag_ids" :value tag-id :checked checked :class "rounded border-stone-300")
img (span name)))
(defcomp ~blog-tag-checkbox-image (&key src)
(defcomp ~admin/tag-checkbox-image (&key src)
(img :src src :alt "" :class "h-4 w-4 rounded-full object-cover"))
(defcomp ~blog-tag-group-edit-form (&key (save-url :as string) (csrf :as string) (name :as string) (colour :as string?) (sort-order :as number) (feature-image :as string?) tags)
(defcomp ~admin/tag-group-edit-form (&key (save-url :as string) (csrf :as string) (name :as string) (colour :as string?) (sort-order :as number) (feature-image :as string?) tags)
(form :method "post" :action save-url :class "border rounded p-4 bg-white space-y-4"
(input :type "hidden" :name "csrf_token" :value csrf)
(div :class "space-y-3"
@@ -133,19 +133,19 @@
(div :class "flex gap-3"
(button :type "submit" :class "border rounded px-4 py-2 bg-stone-800 text-white text-sm" "Save"))))
(defcomp ~blog-tag-group-delete-form (&key (delete-url :as string) (csrf :as string))
(defcomp ~admin/tag-group-delete-form (&key (delete-url :as string) (csrf :as string))
(form :method "post" :action delete-url :class "border-t pt-4"
:onsubmit "return confirm('Delete this tag group? Tags will not be deleted.')"
(input :type "hidden" :name "csrf_token" :value csrf)
(button :type "submit" :class "border rounded px-4 py-2 bg-red-600 text-white text-sm" "Delete Group")))
(defcomp ~blog-tag-group-edit-main (&key edit-form delete-form)
(defcomp ~admin/tag-group-edit-main (&key edit-form delete-form)
(div :class "max-w-2xl mx-auto px-4 py-6 space-y-6"
edit-form delete-form))
;; Data-driven snippets list (replaces Python _snippets_sx loop)
(defcomp ~blog-snippets-from-data (&key snippets user-id is-admin csrf badge-colours)
(~blog-snippets-list
(defcomp ~admin/snippets-from-data (&key snippets user-id is-admin csrf badge-colours)
(~admin/snippets-list
:rows (<> (map (lambda (s)
(let* ((s-id (get s "id"))
(s-name (get s "name"))
@@ -155,31 +155,31 @@
(badge-cls (or (get badge-colours s-vis) "bg-stone-200 text-stone-700"))
(extra (<>
(when is-admin
(~blog-snippet-visibility-select
(~admin/snippet-visibility-select
:patch-url (get s "patch_url")
:hx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:options (<>
(~blog-snippet-option :value "private" :selected (= s-vis "private") :label "private")
(~blog-snippet-option :value "shared" :selected (= s-vis "shared") :label "shared")
(~blog-snippet-option :value "admin" :selected (= s-vis "admin") :label "admin"))
(~admin/snippet-option :value "private" :selected (= s-vis "private") :label "private")
(~admin/snippet-option :value "shared" :selected (= s-vis "shared") :label "shared")
(~admin/snippet-option :value "admin" :selected (= s-vis "admin") :label "admin"))
:cls "text-sm border border-stone-300 rounded px-2 py-1"))
(when (or (= s-uid user-id) is-admin)
(~delete-btn :url (get s "delete_url") :trigger-target "#snippets-list"
(~shared:misc/delete-btn :url (get s "delete_url") :trigger-target "#snippets-list"
:title "Delete snippet?"
:text (str "Delete \u201c" s-name "\u201d?")
:sx-headers (str "{\"X-CSRFToken\": \"" csrf "\"}")
:cls "px-3 py-1 text-sm bg-red-200 hover:bg-red-300 rounded text-red-800 flex-shrink-0")))))
(~blog-snippet-row :name s-name :owner owner :badge-cls badge-cls
(~admin/snippet-row :name s-name :owner owner :badge-cls badge-cls
:visibility s-vis :extra extra)))
(or snippets (list))))))
;; Data-driven menu items list (replaces Python _menu_items_list_sx loop)
(defcomp ~blog-menu-items-from-data (&key items csrf)
(~blog-menu-items-list
(defcomp ~admin/menu-items-from-data (&key items csrf)
(~admin/menu-items-list
:rows (<> (map (lambda (item)
(let* ((img (~img-or-placeholder :src (get item "feature_image") :alt (get item "label")
(let* ((img (~shared:misc/img-or-placeholder :src (get item "feature_image") :alt (get item "label")
:size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0")))
(~blog-menu-item-row
(~admin/menu-item-row
:img img :label (get item "label") :slug (get item "slug")
:sort-order (get item "sort_order") :edit-url (get item "edit_url")
:delete-url (get item "delete_url")
@@ -188,38 +188,38 @@
(or items (list))))))
;; Data-driven tag groups main (replaces Python _tag_groups_main_panel_sx loops)
(defcomp ~blog-tag-groups-from-data (&key groups unassigned-tags csrf create-url)
(~blog-tag-groups-main
:form (~blog-tag-groups-create-form :create-url create-url :csrf csrf)
(defcomp ~admin/tag-groups-from-data (&key groups unassigned-tags csrf create-url)
(~admin/tag-groups-main
:form (~admin/tag-groups-create-form :create-url create-url :csrf csrf)
:groups (if (empty? (or groups (list)))
(~empty-state :message "No tag groups yet." :cls "text-stone-500 text-sm")
(~blog-tag-groups-list
(~shared:misc/empty-state :message "No tag groups yet." :cls "text-stone-500 text-sm")
(~admin/tag-groups-list
:items (<> (map (lambda (g)
(let* ((icon (if (get g "feature_image")
(~blog-tag-group-icon-image :src (get g "feature_image") :name (get g "name"))
(~blog-tag-group-icon-color :style (get g "style") :initial (get g "initial")))))
(~blog-tag-group-li :icon icon :edit-href (get g "edit_href")
(~admin/tag-group-icon-image :src (get g "feature_image") :name (get g "name"))
(~admin/tag-group-icon-color :style (get g "style") :initial (get g "initial")))))
(~admin/tag-group-li :icon icon :edit-href (get g "edit_href")
:name (get g "name") :slug (get g "slug") :sort-order (get g "sort_order"))))
groups))))
:unassigned (when (not (empty? (or unassigned-tags (list))))
(~blog-unassigned-tags
(~admin/unassigned-tags
:heading (str "Unassigned Tags (" (len unassigned-tags) ")")
:spans (<> (map (lambda (t)
(~blog-unassigned-tag :name (get t "name")))
(~admin/unassigned-tag :name (get t "name")))
unassigned-tags))))))
;; Data-driven tag group edit (replaces Python _tag_groups_edit_main_panel_sx loop)
(defcomp ~blog-tag-checkboxes-from-data (&key tags)
(defcomp ~admin/tag-checkboxes-from-data (&key tags)
(<> (map (lambda (t)
(~blog-tag-checkbox
(~admin/tag-checkbox
:tag-id (get t "tag_id") :checked (get t "checked")
:img (when (get t "feature_image") (~blog-tag-checkbox-image :src (get t "feature_image")))
:img (when (get t "feature_image") (~admin/tag-checkbox-image :src (get t "feature_image")))
:name (get t "name")))
(or tags (list)))))
;; Preview panel components
(defcomp ~blog-preview-panel (&key sections)
(defcomp ~admin/preview-panel (&key sections)
(div :class "max-w-4xl mx-auto px-4 py-6 space-y-4"
(style "
.sx-pretty, .json-pretty { font-family: monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; }
@@ -239,18 +239,18 @@
")
sections))
(defcomp ~blog-preview-section (&key title content)
(defcomp ~admin/preview-section (&key title content)
(details :class "border rounded bg-white"
(summary :class "cursor-pointer px-4 py-3 font-medium text-sm bg-stone-100 hover:bg-stone-200 select-none" title)
(div :class "p-4 overflow-x-auto text-xs" content)))
(defcomp ~blog-preview-rendered (&key html)
(defcomp ~admin/preview-rendered (&key html)
(div :class "blog-content prose max-w-none" (raw! html)))
(defcomp ~blog-preview-empty ()
(defcomp ~admin/preview-empty ()
(div :class "p-8 text-stone-500" "No content to preview."))
(defcomp ~blog-admin-placeholder ()
(defcomp ~admin/placeholder ()
(div :class "pb-8"))
;; ---------------------------------------------------------------------------
@@ -258,12 +258,12 @@
;; ---------------------------------------------------------------------------
;; Snippets — receives serialized snippet dicts from service
(defcomp ~blog-snippets-content (&key snippets is-admin csrf)
(~blog-snippets-panel
(defcomp ~admin/snippets-content (&key snippets is-admin csrf)
(~admin/snippets-panel
:list (if (empty? (or snippets (list)))
(~empty-state :icon "fa fa-puzzle-piece"
(~shared:misc/empty-state :icon "fa fa-puzzle-piece"
:message "No snippets yet. Create one from the blog editor.")
(~blog-snippets-list
(~admin/snippets-list
:rows (map (lambda (s)
(let* ((badge-colours (dict
"private" "bg-stone-200 text-stone-700"
@@ -274,19 +274,19 @@
(name (get s "name"))
(owner (get s "owner"))
(can-delete (get s "can_delete")))
(~blog-snippet-row
(~admin/snippet-row
:name name :owner owner :badge-cls badge-cls :visibility vis
:extra (<>
(when is-admin
(~blog-snippet-visibility-select
(~admin/snippet-visibility-select
:patch-url (get s "patch_url")
:hx-headers {:X-CSRFToken csrf}
:options (<>
(~blog-snippet-option :value "private" :selected (= vis "private") :label "private")
(~blog-snippet-option :value "shared" :selected (= vis "shared") :label "shared")
(~blog-snippet-option :value "admin" :selected (= vis "admin") :label "admin"))))
(~admin/snippet-option :value "private" :selected (= vis "private") :label "private")
(~admin/snippet-option :value "shared" :selected (= vis "shared") :label "shared")
(~admin/snippet-option :value "admin" :selected (= vis "admin") :label "admin"))))
(when can-delete
(~delete-btn
(~shared:misc/delete-btn
:url (get s "delete_url")
:trigger-target "#snippets-list"
:title "Delete snippet?"
@@ -296,16 +296,16 @@
(or snippets (list)))))))
;; Menu Items — receives serialized menu item dicts from service
(defcomp ~blog-menu-items-content (&key menu-items new-url csrf)
(~blog-menu-items-panel
(defcomp ~admin/menu-items-content (&key menu-items new-url csrf)
(~admin/menu-items-panel
:new-url new-url
:list (if (empty? (or menu-items (list)))
(~empty-state :icon "fa fa-inbox"
(~shared:misc/empty-state :icon "fa fa-inbox"
:message "No menu items yet. Add one to get started!")
(~blog-menu-items-list
(~admin/menu-items-list
:rows (map (lambda (mi)
(~blog-menu-item-row
:img (~img-or-placeholder
(~admin/menu-item-row
:img (~shared:misc/img-or-placeholder
:src (get mi "feature_image") :alt (get mi "label")
:size-cls "w-12 h-12 rounded-full object-cover flex-shrink-0")
:label (get mi "label")
@@ -318,23 +318,23 @@
(or menu-items (list)))))))
;; Tag Groups — receives serialized tag group data from service
(defcomp ~blog-tag-groups-content (&key groups unassigned-tags create-url csrf)
(~blog-tag-groups-main
:form (~blog-tag-groups-create-form :create-url create-url :csrf csrf)
(defcomp ~admin/tag-groups-content (&key groups unassigned-tags create-url csrf)
(~admin/tag-groups-main
:form (~admin/tag-groups-create-form :create-url create-url :csrf csrf)
:groups (if (empty? (or groups (list)))
(~empty-state :icon "fa fa-tags" :message "No tag groups yet.")
(~blog-tag-groups-list
(~shared:misc/empty-state :icon "fa fa-tags" :message "No tag groups yet.")
(~admin/tag-groups-list
:items (map (lambda (g)
(let* ((fi (get g "feature_image"))
(colour (get g "colour"))
(name (get g "name"))
(initial (slice (or name "?") 0 1))
(icon (if fi
(~blog-tag-group-icon-image :src fi :name name)
(~blog-tag-group-icon-color
(~admin/tag-group-icon-image :src fi :name name)
(~admin/tag-group-icon-color
:style (if colour (str "background:" colour) "background:#e7e5e4")
:initial initial))))
(~blog-tag-group-li
(~admin/tag-group-li
:icon icon
:edit-href (get g "edit_href")
:name name
@@ -342,57 +342,57 @@
:sort-order (or (get g "sort_order") 0))))
(or groups (list)))))
:unassigned (when (not (empty? (or unassigned-tags (list))))
(~blog-unassigned-tags
(~admin/unassigned-tags
:heading (str (len (or unassigned-tags (list))) " Unassigned Tags")
:spans (map (lambda (t)
(~blog-unassigned-tag :name (get t "name")))
(~admin/unassigned-tag :name (get t "name")))
(or unassigned-tags (list)))))))
;; Tag Group Edit — receives serialized tag group + tags from service
(defcomp ~blog-tag-group-edit-content (&key group all-tags save-url delete-url csrf)
(~blog-tag-group-edit-main
:edit-form (~blog-tag-group-edit-form
(defcomp ~admin/tag-group-edit-content (&key group all-tags save-url delete-url csrf)
(~admin/tag-group-edit-main
:edit-form (~admin/tag-group-edit-form
:save-url save-url :csrf csrf
:name (get group "name")
:colour (get group "colour")
:sort-order (get group "sort_order")
:feature-image (get group "feature_image")
:tags (map (lambda (t)
(~blog-tag-checkbox
(~admin/tag-checkbox
:tag-id (get t "id")
:checked (get t "checked")
:img (when (get t "feature_image")
(~blog-tag-checkbox-image :src (get t "feature_image")))
(~admin/tag-checkbox-image :src (get t "feature_image")))
:name (get t "name")))
(or all-tags (list))))
:delete-form (~blog-tag-group-delete-form :delete-url delete-url :csrf csrf)))
:delete-form (~admin/tag-group-delete-form :delete-url delete-url :csrf csrf)))
;; ---------------------------------------------------------------------------
;; Preview content composition — replaces _h_post_preview_content
;; ---------------------------------------------------------------------------
(defcomp ~blog-preview-content (&key sx-pretty json-pretty sx-rendered lex-rendered)
(defcomp ~admin/preview-content (&key sx-pretty json-pretty sx-rendered lex-rendered)
(let* ((sections (list)))
(if (and (not sx-pretty) (not json-pretty) (not sx-rendered) (not lex-rendered))
(~blog-preview-empty)
(~blog-preview-panel :sections
(~admin/preview-empty)
(~admin/preview-panel :sections
(<>
(when sx-pretty
(~blog-preview-section :title "S-Expression Source" :content sx-pretty))
(~admin/preview-section :title "S-Expression Source" :content sx-pretty))
(when json-pretty
(~blog-preview-section :title "Lexical JSON" :content json-pretty))
(~admin/preview-section :title "Lexical JSON" :content json-pretty))
(when sx-rendered
(~blog-preview-section :title "SX Rendered"
:content (~blog-preview-rendered :html sx-rendered)))
(~admin/preview-section :title "SX Rendered"
:content (~admin/preview-rendered :html sx-rendered)))
(when lex-rendered
(~blog-preview-section :title "Lexical Rendered"
:content (~blog-preview-rendered :html lex-rendered))))))))
(~admin/preview-section :title "Lexical Rendered"
:content (~admin/preview-rendered :html lex-rendered))))))))
;; ---------------------------------------------------------------------------
;; Data introspection composition — replaces _h_post_data_content
;; ---------------------------------------------------------------------------
(defcomp ~blog-data-value-cell (&key value value-type)
(defcomp ~admin/data-value-cell (&key value value-type)
(if (= value-type "nil")
(span :class "text-neutral-400" "\u2014")
(pre :class "whitespace-pre-wrap break-words break-all text-xs"
@@ -400,7 +400,7 @@
(code value)
value))))
(defcomp ~blog-data-scalar-table (&key columns)
(defcomp ~admin/data-scalar-table (&key columns)
(div :class "w-full overflow-x-auto sm:overflow-visible"
(table :class "w-full table-fixed text-sm border border-neutral-200 rounded-xl overflow-hidden"
(thead :class "bg-neutral-50/70"
@@ -411,10 +411,10 @@
(tr :class "border-t border-neutral-200 align-top"
(td :class "px-3 py-2 whitespace-nowrap text-neutral-600 align-top" (get col "key"))
(td :class "px-3 py-2 align-top"
(~blog-data-value-cell :value (get col "value") :value-type (get col "type")))))
(~admin/data-value-cell :value (get col "value") :value-type (get col "type")))))
(or columns (list)))))))
(defcomp ~blog-data-relationship-item (&key index summary children)
(defcomp ~admin/data-relationship-item (&key index summary children)
(tr :class "border-t border-neutral-200 align-top"
(td :class "px-2 py-1 whitespace-nowrap align-top" (str index))
(td :class "px-2 py-1 align-top"
@@ -422,11 +422,11 @@
(code summary))
(when children
(div :class "mt-2 pl-3 border-l border-neutral-200"
(~blog-data-model-content
(~admin/data-model-content
:columns (get children "columns")
:relationships (get children "relationships")))))))
(defcomp ~blog-data-relationship (&key name cardinality class-name loaded value)
(defcomp ~admin/data-relationship (&key name cardinality class-name loaded value)
(div :class "rounded-xl border border-neutral-200"
(div :class "px-3 py-2 bg-neutral-50/70 text-sm font-medium"
"Relationship: " (span :class "font-semibold" name)
@@ -448,7 +448,7 @@
(th :class "px-2 py-1 text-left" "Summary")))
(tbody
(map (lambda (item)
(~blog-data-relationship-item
(~admin/data-relationship-item
:index (get item "index")
:summary (get item "summary")
:children (get item "children")))
@@ -459,17 +459,17 @@
(code (get value "summary")))
(when (get value "children")
(div :class "pl-3 border-l border-neutral-200"
(~blog-data-model-content
(~admin/data-model-content
:columns (get (get value "children") "columns")
:relationships (get (get value "children") "relationships"))))))))))
(defcomp ~blog-data-model-content (&key columns relationships)
(defcomp ~admin/data-model-content (&key columns relationships)
(div :class "space-y-4"
(~blog-data-scalar-table :columns columns)
(~admin/data-scalar-table :columns columns)
(when (not (empty? (or relationships (list))))
(div :class "space-y-3"
(map (lambda (rel)
(~blog-data-relationship
(~admin/data-relationship
:name (get rel "name")
:cardinality (get rel "cardinality")
:class-name (get rel "class_name")
@@ -477,13 +477,13 @@
:value (get rel "value")))
relationships)))))
(defcomp ~blog-data-table-content (&key tablename model-data)
(defcomp ~admin/data-table-content (&key tablename model-data)
(if (not model-data)
(div :class "px-4 py-8 text-stone-400" "No post data available.")
(div :class "px-4 py-8"
(div :class "mb-6 text-sm text-neutral-500"
"Model: " (code "Post") " \u2022 Table: " (code tablename))
(~blog-data-model-content
(~admin/data-model-content
:columns (get model-data "columns")
:relationships (get model-data "relationships")))))
@@ -491,7 +491,7 @@
;; Calendar month view for browsing/toggling entries (B1)
;; ---------------------------------------------------------------------------
(defcomp ~blog-cal-entry-associated (&key name toggle-url csrf)
(defcomp ~admin/cal-entry-associated (&key name toggle-url csrf)
(div :class "flex items-center gap-1 text-[10px] rounded px-1 py-0.5 bg-green-200 text-green-900"
(span :class "truncate flex-1" name)
(button :type "button" :class "flex-shrink-0 hover:text-red-600"
@@ -505,7 +505,7 @@
:sx-on:afterSwap "document.body.dispatchEvent(new CustomEvent('entryToggled'))"
(i :class "fa fa-times"))))
(defcomp ~blog-cal-entry-unassociated (&key name toggle-url csrf)
(defcomp ~admin/cal-entry-unassociated (&key name toggle-url csrf)
(button :type "button"
:class "w-full text-left text-[10px] rounded px-1 py-0.5 bg-stone-100 text-stone-700 hover:bg-stone-200"
:data-confirm "" :data-confirm-title "Add entry?"
@@ -518,7 +518,7 @@
:sx-on:afterSwap "document.body.dispatchEvent(new CustomEvent('entryToggled'))"
(span :class "truncate block" name)))
(defcomp ~blog-calendar-view (&key cal-id year month-name
(defcomp ~admin/calendar-view (&key cal-id year month-name
current-url prev-month-url prev-year-url
next-month-url next-year-url
weekday-names days csrf)
@@ -553,9 +553,9 @@
(div :class "space-y-0.5"
(map (lambda (e)
(if (get e "is_associated")
(~blog-cal-entry-associated
(~admin/cal-entry-associated
:name (get e "name") :toggle-url (get e "toggle_url") :csrf csrf)
(~blog-cal-entry-unassociated
(~admin/cal-entry-unassociated
:name (get e "name") :toggle-url (get e "toggle_url") :csrf csrf)))
entries))))))
(or days (list))))))))
@@ -564,15 +564,15 @@
;; Nav entries OOB — renders associated entry/calendar items in scroll wrapper (B2)
;; ---------------------------------------------------------------------------
(defcomp ~blog-nav-entries-oob (&key entries calendars)
(defcomp ~admin/nav-entries-oob (&key entries calendars)
(let* ((entry-list (or entries (list)))
(cal-list (or calendars (list)))
(has-items (or (not (empty? entry-list)) (not (empty? cal-list))))
(nav-cls "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black [.hover-capable_&]:hover:bg-yellow-300 aria-selected:bg-stone-500 aria-selected:text-white [.hover-capable_&[aria-selected=true]:hover]:bg-orange-500 p-2")
(scroll-hs "on load or scroll if window.innerWidth >= 640 and my.scrollWidth > my.clientWidth remove .hidden from .entries-nav-arrow add .flex to .entries-nav-arrow else add .hidden to .entries-nav-arrow remove .flex from .entries-nav-arrow end"))
(if (not has-items)
(~blog-nav-entries-empty)
(~scroll-nav-wrapper
(~shared:nav/blog-nav-entries-empty)
(~shared:misc/scroll-nav-wrapper
:wrapper-id "entries-calendars-nav-wrapper"
:container-id "associated-items-container"
:arrow-cls "entries-nav-arrow"
@@ -581,12 +581,12 @@
:right-hs "on click set #associated-items-container.scrollLeft to #associated-items-container.scrollLeft + 200"
:items (<>
(map (lambda (e)
(~calendar-entry-nav
(~shared:navigation/calendar-entry-nav
:href (get e "href") :nav-class nav-cls
:name (get e "name") :date-str (get e "date_str")))
entry-list)
(map (lambda (c)
(~blog-nav-calendar-item
(~shared:nav/blog-nav-calendar-item
:href (get c "href") :nav-cls nav-cls
:name (get c "name")))
cal-list))

View File

@@ -1,51 +1,51 @@
;; Blog card components — pure data, no HTML injection
(defcomp ~blog-like-button (&key like-url hx-headers heart)
(defcomp ~cards/like-button (&key like-url hx-headers heart)
(div :class "absolute top-20 right-2 z-10 text-6xl md:text-4xl"
(~blog-like-toggle :like-url like-url :hx-headers hx-headers :heart heart)))
(~detail/like-toggle :like-url like-url :hx-headers hx-headers :heart heart)))
(defcomp ~blog-draft-status (&key (publish-requested :as boolean) (timestamp :as string?))
(defcomp ~cards/draft-status (&key (publish-requested :as boolean) (timestamp :as string?))
(<> (div :class "flex justify-center gap-2 mt-1"
(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800" "Draft")
(when publish-requested (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")))
(when timestamp (p :class "text-sm text-stone-500" (str "Updated: " timestamp)))))
(defcomp ~blog-published-status (&key (timestamp :as string))
(defcomp ~cards/published-status (&key (timestamp :as string))
(p :class "text-sm text-stone-500" (str "Published: " timestamp)))
;; Tag components — accept data, not HTML
(defcomp ~blog-tag-icon (&key (src :as string?) (name :as string) (initial :as string))
(defcomp ~cards/tag-icon (&key (src :as string?) (name :as string) (initial :as string))
(if src
(img :src src :alt name :class "h-4 w-4 rounded-full object-cover border border-stone-300 flex-shrink-0")
(div :class "h-4 w-4 rounded-full text-[8px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0 bg-stone-200 text-stone-600" initial)))
(defcomp ~blog-tag-item (&key src name initial)
(defcomp ~cards/tag-item (&key src name initial)
(li (a :class "flex items-center gap-1"
(~blog-tag-icon :src src :name name :initial initial)
(~cards/tag-icon :src src :name name :initial initial)
(span :class "inline-block rounded-full bg-stone-100 text-stone-600 px-2 py-1 text-sm font-medium border border-stone-200" name))))
;; At-bar — tags + authors row for detail pages
(defcomp ~blog-at-bar (&key tags authors)
(defcomp ~cards/at-bar (&key tags authors)
(when (or tags authors)
(div :class "flex flex-row justify-center gap-3"
(when tags
(div :class "mt-4 flex items-center gap-2" (div "in")
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (t) (~blog-tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
(map (lambda (t) (~cards/tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
(div)
(when authors
(div :class "mt-4 flex items-center gap-2" (div "by")
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors)))))))
(map (lambda (a) (~cards/author-item :image (get a "image") :name (get a "name"))) authors)))))))
;; Author components
(defcomp ~blog-author-item (&key image name)
(defcomp ~cards/author-item (&key image name)
(li :class "flex items-center gap-1"
(when image (img :src image :alt name :class "h-5 w-5 rounded-full object-cover"))
(span :class "text-stone-700" name)))
;; Card — accepts pure data
(defcomp ~blog-card (&key (slug :as string) (href :as string) (hx-select :as string?) (title :as string)
(defcomp ~cards/index (&key (slug :as string) (href :as string) (hx-select :as string?) (title :as string)
(feature-image :as string?) (excerpt :as string?)
status (is-draft :as boolean) (publish-requested :as boolean) (status-timestamp :as string?)
(liked :as boolean) (like-url :as string?) (csrf-token :as string?)
@@ -53,7 +53,7 @@
(tags :as list?) (authors :as list?) widget)
(article :class "border-b pb-6 last:border-b-0 relative"
(when has-like
(~blog-like-button
(~cards/like-button
:like-url like-url
:hx-headers {:X-CSRFToken csrf-token}
:heart (if liked "❤️" "🤍")))
@@ -63,8 +63,8 @@
(header :class "mb-2 text-center"
(h2 :class "text-4xl font-bold text-stone-900" title)
(if is-draft
(~blog-draft-status :publish-requested publish-requested :timestamp status-timestamp)
(when status-timestamp (~blog-published-status :timestamp status-timestamp))))
(~cards/draft-status :publish-requested publish-requested :timestamp status-timestamp)
(when status-timestamp (~cards/published-status :timestamp status-timestamp))))
(when feature-image (div :class "mb-4" (img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
(when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))
widget
@@ -73,14 +73,14 @@
(when tags
(div :class "mt-4 flex items-center gap-2" (div "in")
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (t) (~blog-tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
(map (lambda (t) (~cards/tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
(div)
(when authors
(div :class "mt-4 flex items-center gap-2" (div "by")
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors))))))))
(map (lambda (a) (~cards/author-item :image (get a "image") :name (get a "name"))) authors))))))))
(defcomp ~blog-card-tile (&key (href :as string) (hx-select :as string?) (feature-image :as string?) (title :as string)
(defcomp ~cards/tile (&key (href :as string) (hx-select :as string?) (feature-image :as string?) (title :as string)
(is-draft :as boolean) (publish-requested :as boolean) (status-timestamp :as string?)
(excerpt :as string?) (tags :as list?) (authors :as list?))
(article :class "relative"
@@ -91,33 +91,33 @@
(div :class "p-3 text-center"
(h2 :class "text-lg font-bold text-stone-900" title)
(if is-draft
(~blog-draft-status :publish-requested publish-requested :timestamp status-timestamp)
(when status-timestamp (~blog-published-status :timestamp status-timestamp)))
(~cards/draft-status :publish-requested publish-requested :timestamp status-timestamp)
(when status-timestamp (~cards/published-status :timestamp status-timestamp)))
(when excerpt (p :class "text-stone-700 text-sm leading-relaxed line-clamp-3 mt-1" excerpt))))
(when (or tags authors)
(div :class "flex flex-row justify-center gap-3"
(when tags
(div :class "mt-4 flex items-center gap-2" (div "in")
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (t) (~blog-tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
(map (lambda (t) (~cards/tag-item :src (get t "src") :name (get t "name") :initial (get t "initial"))) tags))))
(div)
(when authors
(div :class "mt-4 flex items-center gap-2" (div "by")
(ul :class "flex flex-wrap gap-2 text-sm"
(map (lambda (a) (~blog-author-item :image (get a "image") :name (get a "name"))) authors))))))))
(map (lambda (a) (~cards/author-item :image (get a "image") :name (get a "name"))) authors))))))))
;; Data-driven blog cards list (replaces Python _blog_cards_sx loop)
(defcomp ~blog-cards-from-data (&key (posts :as list?) (view :as string?) sentinel)
(defcomp ~cards/from-data (&key (posts :as list?) (view :as string?) sentinel)
(<>
(map (lambda (p)
(if (= view "tile")
(~blog-card-tile
(~cards/tile
:href (get p "href") :hx-select (get p "hx_select")
:feature-image (get p "feature_image") :title (get p "title")
:is-draft (get p "is_draft") :publish-requested (get p "publish_requested")
:status-timestamp (get p "status_timestamp")
:excerpt (get p "excerpt") :tags (get p "tags") :authors (get p "authors"))
(~blog-card
(~cards/index
:slug (get p "slug") :href (get p "href") :hx-select (get p "hx_select")
:title (get p "title") :feature-image (get p "feature_image")
:excerpt (get p "excerpt") :is-draft (get p "is_draft")
@@ -131,10 +131,10 @@
sentinel))
;; Data-driven page cards list (replaces Python _page_cards_sx loop)
(defcomp ~page-cards-from-data (&key (pages :as list?) sentinel)
(defcomp ~cards/page-cards-from-data (&key (pages :as list?) sentinel)
(<>
(map (lambda (pg)
(~blog-page-card
(~cards/page-card
:href (get pg "href") :hx-select (get pg "hx_select")
:title (get pg "title")
:has-calendar (get pg "has_calendar") :has-market (get pg "has_market")
@@ -143,21 +143,21 @@
(or pages (list)))
sentinel))
(defcomp ~blog-page-badges (&key has-calendar has-market)
(defcomp ~cards/page-badges (&key has-calendar has-market)
(div :class "flex justify-center gap-2 mt-2"
(when has-calendar (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800"
(i :class "fa fa-calendar mr-1") "Calendar"))
(when has-market (span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800"
(i :class "fa fa-shopping-bag mr-1") "Market"))))
(defcomp ~blog-page-card (&key (href :as string) (hx-select :as string?) (title :as string) (has-calendar :as boolean) (has-market :as boolean) (pub-timestamp :as string?) (feature-image :as string?) (excerpt :as string?))
(defcomp ~cards/page-card (&key (href :as string) (hx-select :as string?) (title :as string) (has-calendar :as boolean) (has-market :as boolean) (pub-timestamp :as string?) (feature-image :as string?) (excerpt :as string?))
(article :class "border-b pb-6 last:border-b-0 relative"
(a :href href :sx-get href :sx-target "#main-panel"
:sx-select (or hx-select "#main-panel") :sx-swap "outerHTML" :sx-push-url "true"
:class "block rounded-xl bg-white shadow hover:shadow-md transition overflow-hidden"
(header :class "mb-2 text-center"
(h2 :class "text-4xl font-bold text-stone-900" title)
(~blog-page-badges :has-calendar has-calendar :has-market has-market)
(when pub-timestamp (~blog-published-status :timestamp pub-timestamp)))
(~cards/page-badges :has-calendar has-calendar :has-market has-market)
(when pub-timestamp (~cards/published-status :timestamp pub-timestamp)))
(when feature-image (div :class "mb-4" (img :src feature-image :alt "" :class "rounded-lg w-full object-cover")))
(when excerpt (p :class "text-stone-700 text-lg leading-relaxed text-center" excerpt)))))

View File

@@ -1,34 +1,34 @@
;; Blog post detail components
(defcomp ~blog-detail-edit-link (&key (href :as string) (hx-select :as string))
(defcomp ~detail/edit-link (&key (href :as string) (hx-select :as string))
(a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-stone-700 text-white hover:bg-stone-800 transition-colors"
(i :class "fa fa-pencil mr-1") " Edit"))
(defcomp ~blog-detail-draft (&key publish-requested edit)
(defcomp ~detail/draft (&key publish-requested edit)
(div :class "flex items-center justify-center gap-2 mb-3"
(span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-amber-100 text-amber-800" "Draft")
(when publish-requested (span :class "inline-block px-3 py-1 rounded-full text-sm font-semibold bg-blue-100 text-blue-800" "Publish requested"))
edit))
(defcomp ~blog-like-toggle (&key like-url hx-headers heart)
(defcomp ~detail/like-toggle (&key like-url hx-headers heart)
(button :sx-post like-url :sx-swap "outerHTML"
:sx-headers hx-headers :class "cursor-pointer" heart))
(defcomp ~blog-detail-like (&key like-url hx-headers heart)
(defcomp ~detail/like (&key like-url hx-headers heart)
(div :class "absolute top-2 right-2 z-10 text-8xl md:text-6xl"
(~blog-like-toggle :like-url like-url :hx-headers hx-headers :heart heart)))
(~detail/like-toggle :like-url like-url :hx-headers hx-headers :heart heart)))
(defcomp ~blog-detail-excerpt (&key (excerpt :as string))
(defcomp ~detail/excerpt (&key (excerpt :as string))
(div :class "w-full text-center italic text-3xl p-2" excerpt))
(defcomp ~blog-detail-chrome (&key like excerpt at-bar)
(defcomp ~detail/chrome (&key like excerpt at-bar)
(<> like
excerpt
(div :class "hidden md:block" at-bar)))
(defcomp ~blog-detail-main (&key draft chrome feature-image html-content sx-content)
(defcomp ~detail/main (&key draft chrome feature-image html-content sx-content)
(<> (article :class "relative"
draft
chrome
@@ -43,34 +43,34 @@
;; Data-driven composition — replaces _post_main_panel_sx
;; ---------------------------------------------------------------------------
(defcomp ~blog-post-detail-content (&key (slug :as string) (is-draft :as boolean) (publish-requested :as boolean) (can-edit :as boolean) (edit-href :as string?)
(defcomp ~detail/post-detail-content (&key (slug :as string) (is-draft :as boolean) (publish-requested :as boolean) (can-edit :as boolean) (edit-href :as string?)
(is-page :as boolean) (has-user :as boolean) (liked :as boolean) (like-url :as string?) (csrf :as string?)
(custom-excerpt :as string?) (tags :as list?) (authors :as list?)
(feature-image :as string?) (html-content :as string?) (sx-content :as string?))
(let* ((hx-select "#main-panel")
(draft-sx (when is-draft
(~blog-detail-draft
(~detail/draft
:publish-requested publish-requested
:edit (when can-edit
(~blog-detail-edit-link :href edit-href :hx-select hx-select)))))
(~detail/edit-link :href edit-href :hx-select hx-select)))))
(chrome-sx (when (not is-page)
(~blog-detail-chrome
(~detail/chrome
:like (when has-user
(~blog-detail-like
(~detail/like
:like-url like-url
:hx-headers {:X-CSRFToken csrf}
:heart (if liked "❤️" "🤍")))
:excerpt (when (not (= custom-excerpt ""))
(~blog-detail-excerpt :excerpt custom-excerpt))
:at-bar (~blog-at-bar :tags tags :authors authors)))))
(~blog-detail-main
(~detail/excerpt :excerpt custom-excerpt))
:at-bar (~cards/at-bar :tags tags :authors authors)))))
(~detail/main
:draft draft-sx
:chrome chrome-sx
:feature-image feature-image
:html-content html-content
:sx-content sx-content)))
(defcomp ~blog-meta (&key (robots :as string) (page-title :as string) (desc :as string) (canonical :as string?) (og-type :as string) (og-title :as string) (image :as string?) (twitter-card :as string) (twitter-title :as string))
(defcomp ~detail/meta (&key (robots :as string) (page-title :as string) (desc :as string) (canonical :as string?) (og-type :as string) (og-title :as string) (image :as string?) (twitter-card :as string) (twitter-title :as string))
(<>
(meta :name "robots" :content robots)
(title page-title)
@@ -86,7 +86,7 @@
(meta :name "twitter:description" :content desc)
(when image (meta :name "twitter:image" :content image))))
(defcomp ~blog-home-main (&key html-content sx-content)
(defcomp ~detail/home-main (&key html-content sx-content)
(article :class "relative"
(if sx-content
(div :class "blog-content p-2" sx-content)

View File

@@ -1,10 +1,10 @@
;; Blog editor components
(defcomp ~blog-editor-error (&key error)
(defcomp ~editor/error (&key error)
(div :class "max-w-[768px] mx-auto mt-[16px] rounded-[8px] border border-red-300 bg-red-50 px-[16px] py-[12px] text-[14px] text-red-700"
(strong "Save failed:") " " error))
(defcomp ~blog-editor-form (&key (csrf :as string) (title-placeholder :as string) (create-label :as string))
(defcomp ~editor/form (&key (csrf :as string) (title-placeholder :as string) (create-label :as string))
(form :id "post-new-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]"
(input :type "hidden" :name "csrf_token" :value csrf)
(input :type "hidden" :id "lexical-json-input" :name "lexical" :value "")
@@ -56,7 +56,7 @@
:class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer" create-label))))
;; Edit form — pre-populated version for /<slug>/admin/edit/
(defcomp ~blog-editor-edit-form (&key (csrf :as string) (updated-at :as string) (title-val :as string?) (excerpt-val :as string?)
(defcomp ~editor/edit-form (&key (csrf :as string) (updated-at :as string) (title-val :as string?) (excerpt-val :as string?)
(feature-image :as string?) (feature-image-caption :as string?)
(sx-content-val :as string?) (lexical-json :as string?)
(has-sx :as boolean) (title-placeholder :as string)
@@ -135,7 +135,7 @@
(when footer-extra footer-extra)))))
;; Publish-mode show/hide script for edit form
(defcomp ~blog-editor-publish-js (&key already-emailed)
(defcomp ~editor/publish-js (&key already-emailed)
(script
"(function() {"
" var statusSel = document.getElementById('status-select');"
@@ -153,20 +153,20 @@
" sync();"
"})();"))
(defcomp ~blog-editor-styles (&key (css-href :as string))
(defcomp ~editor/styles (&key (css-href :as string))
(<> (link :rel "stylesheet" :href css-href)
(style
"#lexical-editor { display: flow-root; }"
"#lexical-editor [data-kg-card=\"html\"] * { float: none !important; }"
"#lexical-editor [data-kg-card=\"html\"] table { width: 100% !important; }")))
(defcomp ~blog-editor-scripts (&key (js-src :as string) (sx-editor-js-src :as string?) (init-js :as string))
(defcomp ~editor/scripts (&key (js-src :as string) (sx-editor-js-src :as string?) (init-js :as string))
(<> (script :src js-src)
(when sx-editor-js-src (script :src sx-editor-js-src))
(script init-js)))
;; SX editor styles — comprehensive CSS for the Koenig-style block editor
(defcomp ~sx-editor-styles ()
(defcomp ~editor/sx-editor-styles ()
(style
;; Editor container
".sx-editor { position: relative; font-size: 18px; line-height: 1.6; font-family: Georgia, 'Times New Roman', serif; color: #1c1917; }"
@@ -308,34 +308,34 @@
;; Editor panel composition — replaces render_editor_panel (new post/page)
;; ---------------------------------------------------------------------------
(defcomp ~blog-editor-content (&key csrf title-placeholder create-label
(defcomp ~editor/content (&key csrf title-placeholder create-label
css-href js-src sx-editor-js-src init-js
save-error)
(~blog-editor-panel :parts
(~layouts/editor-panel :parts
(<>
(when save-error (~blog-editor-error :error save-error))
(~blog-editor-form :csrf csrf :title-placeholder title-placeholder
(when save-error (~editor/error :error save-error))
(~editor/form :csrf csrf :title-placeholder title-placeholder
:create-label create-label)
(~blog-editor-styles :css-href css-href)
(~sx-editor-styles)
(~blog-editor-scripts :js-src js-src :sx-editor-js-src sx-editor-js-src
(~editor/styles :css-href css-href)
(~editor/sx-editor-styles)
(~editor/scripts :js-src js-src :sx-editor-js-src sx-editor-js-src
:init-js init-js))))
;; ---------------------------------------------------------------------------
;; Edit content composition — replaces _h_post_edit_content (existing post)
;; ---------------------------------------------------------------------------
(defcomp ~blog-edit-content (&key csrf updated-at title-val excerpt-val
(defcomp ~editor/edit-content (&key csrf updated-at title-val excerpt-val
feature-image feature-image-caption
sx-content-val lexical-json has-sx
title-placeholder status already-emailed
newsletter-options footer-extra
css-href js-src sx-editor-js-src init-js
save-error)
(~blog-editor-panel :parts
(~layouts/editor-panel :parts
(<>
(when save-error (~blog-editor-error :error save-error))
(~blog-editor-edit-form
(when save-error (~editor/error :error save-error))
(~editor/edit-form
:csrf csrf :updated-at updated-at
:title-val title-val :excerpt-val excerpt-val
:feature-image feature-image :feature-image-caption feature-image-caption
@@ -343,8 +343,8 @@
:has-sx has-sx :title-placeholder title-placeholder
:status status :already-emailed already-emailed
:newsletter-options newsletter-options :footer-extra footer-extra)
(~blog-editor-publish-js :already-emailed already-emailed)
(~blog-editor-styles :css-href css-href)
(~sx-editor-styles)
(~blog-editor-scripts :js-src js-src :sx-editor-js-src sx-editor-js-src
(~editor/publish-js :already-emailed already-emailed)
(~editor/styles :css-href css-href)
(~editor/sx-editor-styles)
(~editor/scripts :js-src js-src :sx-editor-js-src sx-editor-js-src
:init-js init-js))))

View File

@@ -1,37 +1,37 @@
;; Blog filter components
(defcomp ~blog-action-button (&key (href :as string) (hx-select :as string) (btn-class :as string) (title :as string) (icon-class :as string) (label :as string))
(defcomp ~filters/action-button (&key (href :as string) (hx-select :as string) (btn-class :as string) (title :as string) (icon-class :as string) (label :as string))
(a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class btn-class :title title (i :class icon-class) label))
(defcomp ~blog-drafts-button (&key (href :as string) (hx-select :as string) (btn-class :as string) (title :as string) (label :as string) (draft-count :as number))
(defcomp ~filters/drafts-button (&key (href :as string) (hx-select :as string) (btn-class :as string) (title :as string) (label :as string) (draft-count :as number))
(a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class btn-class :title title (i :class "fa fa-file-text-o mr-1") " Drafts "
(span :class "inline-block bg-stone-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" draft-count)))
(defcomp ~blog-drafts-button-amber (&key href hx-select btn-class title label draft-count)
(defcomp ~filters/drafts-button-amber (&key href hx-select btn-class title label draft-count)
(a :href href :sx-get href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class btn-class :title title (i :class "fa fa-file-text-o mr-1") " Drafts "
(span :class "inline-block bg-amber-500 text-white px-1.5 py-0.5 text-xs font-medium rounded ml-1" draft-count)))
(defcomp ~blog-action-buttons-wrapper (&key inner)
(defcomp ~filters/action-buttons-wrapper (&key inner)
(div :class "flex flex-wrap gap-2 px-4 py-3" inner))
(defcomp ~blog-filter-any-topic (&key cls hx-select)
(defcomp ~filters/any-topic (&key cls hx-select)
(li (a :class (str "px-3 py-1 rounded border " cls)
:sx-get "?page=1" :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true" "Any Topic")))
(defcomp ~blog-filter-group-icon-image (&key src name)
(defcomp ~filters/group-icon-image (&key src name)
(img :src src :alt name :class "h-6 w-6 rounded-full object-cover border border-stone-300 flex-shrink-0"))
(defcomp ~blog-filter-group-icon-color (&key style initial)
(defcomp ~filters/group-icon-color (&key style initial)
(div :class "h-6 w-6 rounded-full text-[10px] font-semibold flex items-center justify-center border border-stone-300 flex-shrink-0" :style style initial))
(defcomp ~blog-filter-group-li (&key cls hx-get hx-select icon name count)
(defcomp ~filters/group-li (&key cls hx-get hx-select icon name count)
(li (a :class (str "flex items-center gap-2 px-3 py-1 rounded border " cls)
:sx-get hx-get :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true"
@@ -40,19 +40,19 @@
(span :class "flex-1")
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" count))))
(defcomp ~blog-filter-nav (&key items)
(defcomp ~filters/nav (&key items)
(nav :class "max-w-3xl mx-auto px-4 pb-4 flex flex-wrap gap-2 text-sm"
(ul :class "divide-y flex flex-col gap-3" items)))
(defcomp ~blog-filter-any-author (&key cls hx-select)
(defcomp ~filters/any-author (&key cls hx-select)
(li (a :class (str "px-3 py-1 rounded " cls)
:sx-get "?page=1" :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true" "Any author")))
(defcomp ~blog-filter-author-icon (&key src name)
(defcomp ~filters/author-icon (&key src name)
(img :src src :alt name :class "h-5 w-5 rounded-full object-cover"))
(defcomp ~blog-filter-author-li (&key cls hx-get hx-select icon name count)
(defcomp ~filters/author-li (&key cls hx-get hx-select icon name count)
(li (a :class (str "flex items-center gap-2 px-3 py-1 rounded " cls)
:sx-get hx-get :sx-target "#main-panel" :sx-select hx-select
:sx-swap "outerHTML" :sx-push-url "true"
@@ -61,41 +61,41 @@
(span :class "flex-1")
(span :class "inline-block bg-stone-100 text-stone-600 px-2 py-1 text-xs font-medium border border-stone-200" count))))
(defcomp ~blog-filter-summary (&key (text :as string))
(defcomp ~filters/summary (&key (text :as string))
(span :class "text-sm text-stone-600" text))
;; Data-driven tag groups filter (replaces Python _tag_groups_filter_sx loop)
(defcomp ~blog-tag-groups-filter-from-data (&key groups selected-groups hx-select)
(defcomp ~filters/tag-groups-filter-from-data (&key groups selected-groups hx-select)
(let* ((is-any (empty? (or selected-groups (list))))
(any-cls (if is-any "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50")))
(~blog-filter-nav
(~filters/nav
:items (<>
(~blog-filter-any-topic :cls any-cls :hx-select hx-select)
(~filters/any-topic :cls any-cls :hx-select hx-select)
(map (lambda (g)
(let* ((slug (get g "slug"))
(name (get g "name"))
(is-on (contains? selected-groups slug))
(cls (if is-on "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
(icon (if (get g "feature_image")
(~blog-filter-group-icon-image :src (get g "feature_image") :name name)
(~blog-filter-group-icon-color :style (get g "style") :initial (get g "initial")))))
(~blog-filter-group-li :cls cls :hx-get (str "?group=" slug "&page=1") :hx-select hx-select
(~filters/group-icon-image :src (get g "feature_image") :name name)
(~filters/group-icon-color :style (get g "style") :initial (get g "initial")))))
(~filters/group-li :cls cls :hx-get (str "?group=" slug "&page=1") :hx-select hx-select
:icon icon :name name :count (get g "count"))))
(or groups (list)))))))
;; Data-driven authors filter (replaces Python _authors_filter_sx loop)
(defcomp ~blog-authors-filter-from-data (&key authors selected-authors hx-select)
(defcomp ~filters/authors-filter-from-data (&key authors selected-authors hx-select)
(let* ((is-any (empty? (or selected-authors (list))))
(any-cls (if is-any "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50")))
(~blog-filter-nav
(~filters/nav
:items (<>
(~blog-filter-any-author :cls any-cls :hx-select hx-select)
(~filters/any-author :cls any-cls :hx-select hx-select)
(map (lambda (a)
(let* ((slug (get a "slug"))
(is-on (contains? selected-authors slug))
(cls (if is-on "bg-stone-900 text-white border-stone-900" "bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
(icon (when (get a "profile_image")
(~blog-filter-author-icon :src (get a "profile_image") :name (get a "name")))))
(~blog-filter-author-li :cls cls :hx-get (str "?author=" slug "&page=1") :hx-select hx-select
(~filters/author-icon :src (get a "profile_image") :name (get a "name")))))
(~filters/author-li :cls cls :hx-get (str "?author=" slug "&page=1") :hx-select hx-select
:icon icon :name (get a "name") :count (get a "count"))))
(or authors (list)))))))

View File

@@ -11,7 +11,7 @@
(let ((post (query "blog" "post-by-slug" :slug (trim s))))
(when post
(<> (str "<!-- fragment:" (trim s) " -->")
(~link-card
(~shared:fragments/link-card
:link (app-url "blog" (str "/" (get post "slug") "/"))
:title (get post "title")
:image (get post "feature_image")
@@ -22,7 +22,7 @@
(when slug
(let ((post (query "blog" "post-by-slug" :slug slug)))
(when post
(~link-card
(~shared:fragments/link-card
:link (app-url "blog" (str "/" (get post "slug") "/"))
:title (get post "title")
:image (get post "feature_image")

View File

@@ -30,25 +30,25 @@
(app-url "blog" (str "/" item-slug "/"))))
(selected (or (= item-slug (or first-seg ""))
(= item-slug app))))
(~blog-nav-item-link
(~shared:nav/blog-nav-item-link
:href href
:hx-get href
:selected (if selected "true" "false")
:nav-cls nav-cls
:img (~img-or-placeholder
:img (~shared:misc/img-or-placeholder
:src (get item "feature_image")
:alt (or (get item "label") item-slug)
:size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0")
:label (or (get item "label") item-slug)))) items)
;; Hardcoded artdag link
(~blog-nav-item-link
(~shared:nav/blog-nav-item-link
:href (app-url "artdag" "/")
:hx-get (app-url "artdag" "/")
:selected (if (or (= "artdag" (or first-seg ""))
(= "artdag" app)) "true" "false")
:nav-cls nav-cls
:img (~img-or-placeholder
:img (~shared:misc/img-or-placeholder
:src nil :alt "art-dag"
:size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0")
:label "art-dag")))
@@ -69,8 +69,8 @@
(right-hs (str "on click set #" cid ".scrollLeft to #" cid ".scrollLeft + 200")))
(if (empty? items)
(~blog-nav-empty :wrapper-id "menu-items-nav-wrapper")
(~scroll-nav-wrapper
(~shared:nav/blog-nav-empty :wrapper-id "menu-items-nav-wrapper")
(~shared:misc/scroll-nav-wrapper
:wrapper-id "menu-items-nav-wrapper"
:container-id cid
:arrow-cls arrow-cls

View File

@@ -1,21 +1,21 @@
;; Blog header components
(defcomp ~blog-container-nav (&key container-nav)
(defcomp ~header/container-nav (&key container-nav)
(div :class "flex flex-col sm:flex-row sm:items-center gap-2 border-r border-stone-200 mr-2 sm:max-w-2xl"
:id "entries-calendars-nav-wrapper" container-nav))
(defcomp ~blog-admin-label ()
(defcomp ~header/admin-label ()
(<> (i :class "fa fa-shield-halved" :aria-hidden "true") " admin"))
(defcomp ~blog-admin-nav-item (&key href nav-btn-class label is-selected select-colours)
(defcomp ~header/admin-nav-item (&key href nav-btn-class label is-selected select-colours)
(div :class "relative nav-group"
(a :href href
:aria-selected (when is-selected "true")
:class (str (or nav-btn-class "justify-center cursor-pointer flex flex-row items-center gap-2 rounded bg-stone-200 text-black p-3") " " (or select-colours ""))
label)))
(defcomp ~blog-sub-settings-label (&key icon label)
(defcomp ~header/sub-settings-label (&key icon label)
(<> (i :class icon :aria-hidden "true") " " label))
(defcomp ~blog-sub-admin-label (&key icon label)
(defcomp ~header/sub-admin-label (&key icon label)
(<> (i :class icon :aria-hidden "true") (div label)))

View File

@@ -1,9 +1,9 @@
;; Blog index components
(defcomp ~blog-no-pages ()
(defcomp ~index/no-pages ()
(div :class "col-span-full mt-8 text-center text-stone-500" "No pages found."))
(defcomp ~blog-content-type-tabs (&key posts-href pages-href hx-select posts-cls pages-cls)
(defcomp ~index/content-type-tabs (&key posts-href pages-href hx-select posts-cls pages-cls)
(div :class "flex justify-center gap-1 px-3 pt-3"
(a :href posts-href :sx-get posts-href :sx-target "#main-panel"
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
@@ -12,18 +12,18 @@
:sx-select hx-select :sx-swap "outerHTML" :sx-push-url "true"
:class (str "px-4 py-1.5 rounded-t text-sm font-medium transition-colors " pages-cls) "Pages")))
(defcomp ~blog-main-panel-pages (&key tabs cards)
(defcomp ~index/main-panel-pages (&key tabs cards)
(<> tabs
(div :class "max-w-full px-3 py-3 space-y-3" cards)
(div :class "pb-8")))
(defcomp ~blog-main-panel-posts (&key tabs toggle grid-cls cards)
(defcomp ~index/main-panel-posts (&key tabs toggle grid-cls cards)
(<> tabs
toggle
(div :class grid-cls cards)
(div :class "pb-8")))
(defcomp ~blog-aside (&key search action-buttons tag-groups-filter authors-filter)
(defcomp ~index/aside (&key search action-buttons tag-groups-filter authors-filter)
(<> search
action-buttons
(div :id "category-summary-desktop" :hxx-swap-oob "outerHTML"
@@ -36,12 +36,12 @@
;; ---------------------------------------------------------------------------
;; Helper: CSS class for filter item based on selection state
(defcomp ~blog-filter-cls (&key is-on)
(defcomp ~index/filter-cls (&key is-on)
;; Returns nothing — use inline (if is-on ...) instead
nil)
;; Blog index main content — replaces _blog_main_panel_sx
(defcomp ~blog-index-main-content (&key content-type view cards page total-pages
(defcomp ~index/main-content (&key content-type view cards page total-pages
current-local-href hx-select blog-url-base)
(let* ((posts-href (str blog-url-base "/index"))
(pages-href (str posts-href "?type=pages"))
@@ -51,13 +51,13 @@
"bg-stone-700 text-white" "bg-stone-100 text-stone-600 hover:bg-stone-200")))
(if (= content-type "pages")
;; Pages listing
(~blog-main-panel-pages
:tabs (~blog-content-type-tabs
(~index/main-panel-pages
:tabs (~index/content-type-tabs
:posts-href posts-href :pages-href pages-href
:hx-select hx-select :posts-cls posts-cls :pages-cls pages-cls)
:cards (<>
(map (lambda (card)
(~blog-page-card
(~cards/page-card
:href (get card "href") :hx-select hx-select
:title (get card "title")
:has-calendar (get card "has_calendar")
@@ -67,14 +67,14 @@
:excerpt (get card "excerpt")))
(or cards (list)))
(if (< page total-pages)
(~sentinel-simple
(~shared:misc/sentinel-simple
:id (str "sentinel-" page "-d")
:next-url (str current-local-href
(if (contains? current-local-href "?") "&" "?")
"page=" (+ page 1)))
(if (not (empty? (or cards (list))))
(~end-of-results)
(~blog-no-pages)))))
(~shared:misc/end-of-results)
(~index/no-pages)))))
;; Posts listing
(let* ((grid-cls (if (= view "tile")
"max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"
@@ -88,19 +88,19 @@
(tile-cls (if (= view "tile")
"bg-stone-200 text-stone-800"
"text-stone-400 hover:text-stone-600")))
(~blog-main-panel-posts
:tabs (~blog-content-type-tabs
(~index/main-panel-posts
:tabs (~index/content-type-tabs
:posts-href posts-href :pages-href pages-href
:hx-select hx-select :posts-cls posts-cls :pages-cls pages-cls)
:toggle (~view-toggle
:toggle (~shared:misc/view-toggle
:list-href list-href :tile-href tile-href :hx-select hx-select
:list-cls list-cls :tile-cls tile-cls :storage-key "blog_view"
:list-svg (~list-svg) :tile-svg (~tile-svg))
:list-svg (~shared:misc/list-svg) :tile-svg (~shared:misc/tile-svg))
:grid-cls grid-cls
:cards (<>
(map (lambda (card)
(if (= view "tile")
(~blog-card-tile
(~cards/tile
:href (get card "href") :hx-select hx-select
:feature-image (get card "feature_image")
:title (get card "title") :is-draft (get card "is_draft")
@@ -108,7 +108,7 @@
:status-timestamp (get card "status_timestamp")
:excerpt (get card "excerpt")
:tags (get card "tags") :authors (get card "authors"))
(~blog-card
(~cards/index
:slug (get card "slug") :href (get card "href") :hx-select hx-select
:title (get card "title") :feature-image (get card "feature_image")
:excerpt (get card "excerpt") :is-draft (get card "is_draft")
@@ -119,52 +119,52 @@
:tags (get card "tags") :authors (get card "authors")
:widget (get card "widget"))))
(or cards (list)))
(~blog-index-sentinel
(~index/sentinel
:page page :total-pages total-pages
:current-local-href current-local-href)))))))
;; Sentinel for blog index infinite scroll
(defcomp ~blog-index-sentinel (&key page total-pages current-local-href)
(defcomp ~index/sentinel (&key page total-pages current-local-href)
(when (< page total-pages)
(let* ((next-url (str current-local-href "?page=" (+ page 1))))
(~sentinel-desktop
(~shared:misc/sentinel-desktop
:id (str "sentinel-" page "-d")
:next-url next-url
:hyperscript "init if not me.dataset.retryMs then set me.dataset.retryMs to 1000 end on htmx:beforeRequest(event) add .hidden to .js-neterr in me remove .hidden from .js-loading in me remove .opacity-100 from me add .opacity-0 to me set trig to null if event.detail and event.detail.triggeringEvent then set trig to event.detail.triggeringEvent end if trig and trig.type is 'intersect' set scroller to the closest .js-grid-viewport if scroller is null then halt end if scroller.scrollTop < 20 then halt end end def backoff() set ms to me.dataset.retryMs if ms > 30000 then set ms to 30000 end add .hidden to .js-loading in me remove .hidden from .js-neterr in me remove .opacity-0 from me add .opacity-100 to me wait ms ms trigger sentinel:retry set ms to ms * 2 if ms > 30000 then set ms to 30000 end set me.dataset.retryMs to ms end on htmx:sendError call backoff() on htmx:responseError call backoff() on htmx:timeout call backoff()"))))
;; Blog index action buttons — replaces _action_buttons_sx
(defcomp ~blog-index-actions (&key is-admin has-user hx-select draft-count drafts
(defcomp ~index/actions (&key is-admin has-user hx-select draft-count drafts
new-post-href new-page-href current-local-href)
(~blog-action-buttons-wrapper
(~filters/action-buttons-wrapper
:inner (<>
(when is-admin
(<>
(~blog-action-button
(~filters/action-button
:href new-post-href :hx-select hx-select
:btn-class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
:title "New Post" :icon-class "fa fa-plus mr-1" :label " New Post")
(~blog-action-button
(~filters/action-button
:href new-page-href :hx-select hx-select
:btn-class "px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition-colors"
:title "New Page" :icon-class "fa fa-plus mr-1" :label " New Page")))
(when (and has-user (or draft-count drafts))
(if drafts
(~blog-drafts-button
(~filters/drafts-button
:href current-local-href :hx-select hx-select
:btn-class "px-3 py-1 rounded bg-stone-700 text-white text-sm hover:bg-stone-800 transition-colors"
:title "Hide Drafts" :label " Drafts " :draft-count (str draft-count))
(let* ((on-href (str current-local-href
(if (contains? current-local-href "?") "&" "?") "drafts=1")))
(~blog-drafts-button-amber
(~filters/drafts-button-amber
:href on-href :hx-select hx-select
:btn-class "px-3 py-1 rounded bg-amber-600 text-white text-sm hover:bg-amber-700 transition-colors"
:title "Show Drafts" :label " Drafts " :draft-count (str draft-count))))))))
;; Tag groups filter — replaces _tag_groups_filter_sx
(defcomp ~blog-index-tag-groups-filter (&key tag-groups is-any-group hx-select)
(~blog-filter-nav
(defcomp ~index/tag-groups-filter (&key tag-groups is-any-group hx-select)
(~filters/nav
:items (<>
(~blog-filter-any-topic
(~filters/any-topic
:cls (if is-any-group
"bg-stone-900 text-white border-stone-900"
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50")
@@ -178,23 +178,23 @@
(colour (get grp "colour"))
(name (get grp "name"))
(icon (if fi
(~blog-filter-group-icon-image :src fi :name name)
(~blog-filter-group-icon-color
(~filters/group-icon-image :src fi :name name)
(~filters/group-icon-color
:style (if colour
(str "background-color: " colour "; color: white;")
"background-color: #e7e5e4; color: #57534e;")
:initial (slice (or name "?") 0 1)))))
(~blog-filter-group-li
(~filters/group-li
:cls cls :hx-get (str "?group=" (get grp "slug") "&page=1")
:hx-select hx-select :icon icon
:name name :count (str (get grp "post_count")))))
(or tag-groups (list))))))
;; Authors filter — replaces _authors_filter_sx
(defcomp ~blog-index-authors-filter (&key authors is-any-author hx-select)
(~blog-filter-nav
(defcomp ~index/authors-filter (&key authors is-any-author hx-select)
(~filters/nav
:items (<>
(~blog-filter-any-author
(~filters/any-author
:cls (if is-any-author
"bg-stone-900 text-white border-stone-900"
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50")
@@ -205,49 +205,49 @@
"bg-stone-900 text-white border-stone-900"
"bg-white text-stone-600 border-stone-300 hover:bg-stone-50"))
(img (get a "profile_image")))
(~blog-filter-author-li
(~filters/author-li
:cls cls :hx-get (str "?author=" (get a "slug") "&page=1")
:hx-select hx-select
:icon (when img (~blog-filter-author-icon :src img :name (get a "name")))
:icon (when img (~filters/author-icon :src img :name (get a "name")))
:name (get a "name")
:count (str (get a "published_post_count")))))
(or authors (list))))))
;; Blog index aside — replaces _blog_aside_sx
(defcomp ~blog-index-aside-content (&key is-admin has-user hx-select draft-count drafts
(defcomp ~index/aside-content (&key is-admin has-user hx-select draft-count drafts
new-post-href new-page-href current-local-href
tag-groups authors is-any-group is-any-author)
(~blog-aside
:search (~search-desktop)
:action-buttons (~blog-index-actions
(~index/aside
:search (~shared:controls/search-desktop)
:action-buttons (~index/actions
:is-admin is-admin :has-user has-user :hx-select hx-select
:draft-count draft-count :drafts drafts
:new-post-href new-post-href :new-page-href new-page-href
:current-local-href current-local-href)
:tag-groups-filter (~blog-index-tag-groups-filter
:tag-groups-filter (~index/tag-groups-filter
:tag-groups tag-groups :is-any-group is-any-group :hx-select hx-select)
:authors-filter (~blog-index-authors-filter
:authors-filter (~index/authors-filter
:authors authors :is-any-author is-any-author :hx-select hx-select)))
;; Blog index mobile filter — replaces _blog_filter_sx
(defcomp ~blog-index-filter-content (&key is-admin has-user hx-select draft-count drafts
(defcomp ~index/filter-content (&key is-admin has-user hx-select draft-count drafts
new-post-href new-page-href current-local-href
tag-groups authors is-any-group is-any-author
tg-summary au-summary)
(~mobile-filter
(~shared:controls/mobile-filter
:filter-summary (<>
(~search-mobile)
(~shared:controls/search-mobile)
(when (not (= tg-summary ""))
(~blog-filter-summary :text tg-summary))
(~filters/summary :text tg-summary))
(when (not (= au-summary ""))
(~blog-filter-summary :text au-summary)))
:action-buttons (~blog-index-actions
(~filters/summary :text au-summary)))
:action-buttons (~index/actions
:is-admin is-admin :has-user has-user :hx-select hx-select
:draft-count draft-count :drafts drafts
:new-post-href new-post-href :new-page-href new-page-href
:current-local-href current-local-href)
:filter-details (<>
(~blog-index-tag-groups-filter
(~index/tag-groups-filter
:tag-groups tag-groups :is-any-group is-any-group :hx-select hx-select)
(~blog-index-authors-filter
(~index/authors-filter
:authors authors :is-any-author is-any-author :hx-select hx-select))))

View File

@@ -7,7 +7,7 @@
;; ---------------------------------------------------------------------------
;; Image card
;; ---------------------------------------------------------------------------
(defcomp ~kg-image (&key (src :as string) (alt :as string?) (caption :as string?) (width :as string?) (href :as string?))
(defcomp ~kg_cards/kg-image (&key (src :as string) (alt :as string?) (caption :as string?) (width :as string?) (href :as string?))
(figure :class (str "kg-card kg-image-card"
(if (= width "wide") " kg-width-wide"
(if (= width "full") " kg-width-full" "")))
@@ -19,7 +19,7 @@
;; ---------------------------------------------------------------------------
;; Gallery card
;; ---------------------------------------------------------------------------
(defcomp ~kg-gallery (&key (images :as list) (caption :as string?))
(defcomp ~kg_cards/kg-gallery (&key (images :as list) (caption :as string?))
(figure :class "kg-card kg-gallery-card kg-width-wide"
(div :class "kg-gallery-container"
(map (lambda (row)
@@ -36,19 +36,19 @@
;; HTML card — wraps user-pasted HTML so the editor can identify the block.
;; Content is native sx children (no longer an opaque HTML string).
;; ---------------------------------------------------------------------------
(defcomp ~kg-html (&rest children)
(defcomp ~kg_cards/kg-html (&rest children)
(div :class "kg-card kg-html-card" children))
;; ---------------------------------------------------------------------------
;; Markdown card — rendered markdown content, editor can identify the block.
;; ---------------------------------------------------------------------------
(defcomp ~kg-md (&rest children)
(defcomp ~kg_cards/kg-md (&rest children)
(div :class "kg-card kg-md-card" children))
;; ---------------------------------------------------------------------------
;; Embed card
;; ---------------------------------------------------------------------------
(defcomp ~kg-embed (&key (html :as string) (caption :as string?))
(defcomp ~kg_cards/kg-embed (&key (html :as string) (caption :as string?))
(figure :class "kg-card kg-embed-card"
(~rich-text :html html)
(when caption (figcaption caption))))
@@ -56,7 +56,7 @@
;; ---------------------------------------------------------------------------
;; Bookmark card
;; ---------------------------------------------------------------------------
(defcomp ~kg-bookmark (&key (url :as string) (title :as string?) (description :as string?) (icon :as string?) (author :as string?) (publisher :as string?) (thumbnail :as string?) (caption :as string?))
(defcomp ~kg_cards/kg-bookmark (&key (url :as string) (title :as string?) (description :as string?) (icon :as string?) (author :as string?) (publisher :as string?) (thumbnail :as string?) (caption :as string?))
(figure :class "kg-card kg-bookmark-card"
(a :class "kg-bookmark-container" :href url
(div :class "kg-bookmark-content"
@@ -75,7 +75,7 @@
;; ---------------------------------------------------------------------------
;; Callout card
;; ---------------------------------------------------------------------------
(defcomp ~kg-callout (&key (color :as string?) (emoji :as string?) (content :as string?))
(defcomp ~kg_cards/kg-callout (&key (color :as string?) (emoji :as string?) (content :as string?))
(div :class (str "kg-card kg-callout-card kg-callout-card-" (or color "grey"))
(when emoji (div :class "kg-callout-emoji" emoji))
(div :class "kg-callout-text" (or content ""))))
@@ -83,14 +83,14 @@
;; ---------------------------------------------------------------------------
;; Button card
;; ---------------------------------------------------------------------------
(defcomp ~kg-button (&key (url :as string) (text :as string?) (alignment :as string?))
(defcomp ~kg_cards/kg-button (&key (url :as string) (text :as string?) (alignment :as string?))
(div :class (str "kg-card kg-button-card kg-align-" (or alignment "center"))
(a :href url :class "kg-btn kg-btn-accent" (or text ""))))
;; ---------------------------------------------------------------------------
;; Toggle card (accordion)
;; ---------------------------------------------------------------------------
(defcomp ~kg-toggle (&key (heading :as string?) (content :as string?))
(defcomp ~kg_cards/kg-toggle (&key (heading :as string?) (content :as string?))
(div :class "kg-card kg-toggle-card" :data-kg-toggle-state "close"
(div :class "kg-toggle-heading"
(h4 :class "kg-toggle-heading-text" (or heading ""))
@@ -101,7 +101,7 @@
;; ---------------------------------------------------------------------------
;; Audio card
;; ---------------------------------------------------------------------------
(defcomp ~kg-audio (&key (src :as string) (title :as string?) (duration :as string?) (thumbnail :as string?))
(defcomp ~kg_cards/kg-audio (&key (src :as string) (title :as string?) (duration :as string?) (thumbnail :as string?))
(div :class "kg-card kg-audio-card"
(if thumbnail
(img :src thumbnail :alt "audio-thumbnail" :class "kg-audio-thumbnail")
@@ -124,7 +124,7 @@
;; ---------------------------------------------------------------------------
;; Video card
;; ---------------------------------------------------------------------------
(defcomp ~kg-video (&key (src :as string) (caption :as string?) (width :as string?) (thumbnail :as string?) (loop :as boolean?))
(defcomp ~kg_cards/kg-video (&key (src :as string) (caption :as string?) (width :as string?) (thumbnail :as string?) (loop :as boolean?))
(figure :class (str "kg-card kg-video-card"
(if (= width "wide") " kg-width-wide"
(if (= width "full") " kg-width-full" "")))
@@ -136,7 +136,7 @@
;; ---------------------------------------------------------------------------
;; File card
;; ---------------------------------------------------------------------------
(defcomp ~kg-file (&key (src :as string) (filename :as string?) (title :as string?) (filesize :as string?) (caption :as string?))
(defcomp ~kg_cards/kg-file (&key (src :as string) (filename :as string?) (title :as string?) (filesize :as string?) (caption :as string?))
(div :class "kg-card kg-file-card"
(a :class "kg-file-card-container" :href src :download (or filename "")
(div :class "kg-file-card-contents"
@@ -149,5 +149,5 @@
;; ---------------------------------------------------------------------------
;; Paywall marker
;; ---------------------------------------------------------------------------
(defcomp ~kg-paywall ()
(defcomp ~kg_cards/kg-paywall ()
(~rich-text :html "<!--members-only-->"))

View File

@@ -3,8 +3,8 @@
;; --- Blog header (invisible row for blog-header-child swap target) ---
(defcomp ~blog-header (&key oob)
(~menu-row-sx :id "blog-row" :level 1
(defcomp ~layouts/header (&key oob)
(~shared:layout/menu-row-sx :id "blog-row" :level 1
:link-label-content (div)
:child-id "blog-header-child" :oob oob))
@@ -12,10 +12,10 @@
(defmacro ~blog-settings-header-auto (oob)
(quasiquote
(~menu-row-sx :id "root-settings-row" :level 1
(~shared:layout/menu-row-sx :id "root-settings-row" :level 1
:link-href (url-for "settings.defpage_settings_home")
:link-label-content (~blog-admin-label)
:nav (~blog-settings-nav)
:link-label-content (~header/admin-label)
:nav (~layouts/settings-nav)
:child-id "root-settings-header-child"
:oob (unquote oob))))
@@ -23,9 +23,9 @@
(defmacro ~blog-sub-settings-header-auto (row-id child-id endpoint icon label oob)
(quasiquote
(~menu-row-sx :id (unquote row-id) :level 2
(~shared:layout/menu-row-sx :id (unquote row-id) :level 2
:link-href (url-for (unquote endpoint))
:link-label-content (~blog-sub-settings-label
:link-label-content (~header/sub-settings-label
:icon (str "fa fa-" (unquote icon))
:label (unquote label))
:child-id (unquote child-id)
@@ -35,47 +35,47 @@
;; Blog layout (root + blog header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-layout-full ()
(defcomp ~layouts/full ()
(<> (~root-header-auto)
(~blog-header)))
(~layouts/header)))
(defcomp ~blog-layout-oob ()
(<> (~blog-header :oob true)
(~clear-oob-div :id "blog-header-child")
(defcomp ~layouts/oob ()
(<> (~layouts/header :oob true)
(~shared:layout/clear-oob-div :id "blog-header-child")
(~root-header-auto true)))
;; ---------------------------------------------------------------------------
;; Settings layout (root + settings header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-settings-layout-full ()
(defcomp ~layouts/settings-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)))
(defcomp ~blog-settings-layout-oob ()
(defcomp ~layouts/settings-layout-oob ()
(<> (~blog-settings-header-auto true)
(~clear-oob-div :id "root-settings-header-child")
(~shared:layout/clear-oob-div :id "root-settings-header-child")
(~root-header-auto true)))
(defcomp ~blog-settings-layout-mobile ()
(~blog-settings-nav))
(defcomp ~layouts/settings-layout-mobile ()
(~layouts/settings-nav))
;; ---------------------------------------------------------------------------
;; Cache layout (root + settings + cache sub-header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-cache-layout-full ()
(defcomp ~layouts/cache-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)
(~blog-sub-settings-header-auto
"cache-row" "cache-header-child"
"settings.defpage_cache_page" "refresh" "Cache")))
(defcomp ~blog-cache-layout-oob ()
(defcomp ~layouts/cache-layout-oob ()
(<> (~blog-sub-settings-header-auto
"cache-row" "cache-header-child"
"settings.defpage_cache_page" "refresh" "Cache" true)
(~clear-oob-div :id "cache-header-child")
(~shared:layout/clear-oob-div :id "cache-header-child")
(~blog-settings-header-auto true)
(~root-header-auto true)))
@@ -83,18 +83,18 @@
;; Snippets layout (root + settings + snippets sub-header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-snippets-layout-full ()
(defcomp ~layouts/snippets-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)
(~blog-sub-settings-header-auto
"snippets-row" "snippets-header-child"
"snippets.defpage_snippets_page" "puzzle-piece" "Snippets")))
(defcomp ~blog-snippets-layout-oob ()
(defcomp ~layouts/snippets-layout-oob ()
(<> (~blog-sub-settings-header-auto
"snippets-row" "snippets-header-child"
"snippets.defpage_snippets_page" "puzzle-piece" "Snippets" true)
(~clear-oob-div :id "snippets-header-child")
(~shared:layout/clear-oob-div :id "snippets-header-child")
(~blog-settings-header-auto true)
(~root-header-auto true)))
@@ -102,18 +102,18 @@
;; Menu Items layout (root + settings + menu-items sub-header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-menu-items-layout-full ()
(defcomp ~layouts/menu-items-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)
(~blog-sub-settings-header-auto
"menu_items-row" "menu_items-header-child"
"menu_items.defpage_menu_items_page" "bars" "Menu Items")))
(defcomp ~blog-menu-items-layout-oob ()
(defcomp ~layouts/menu-items-layout-oob ()
(<> (~blog-sub-settings-header-auto
"menu_items-row" "menu_items-header-child"
"menu_items.defpage_menu_items_page" "bars" "Menu Items" true)
(~clear-oob-div :id "menu_items-header-child")
(~shared:layout/clear-oob-div :id "menu_items-header-child")
(~blog-settings-header-auto true)
(~root-header-auto true)))
@@ -121,18 +121,18 @@
;; Tag Groups layout (root + settings + tag-groups sub-header)
;; ---------------------------------------------------------------------------
(defcomp ~blog-tag-groups-layout-full ()
(defcomp ~layouts/tag-groups-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)
(~blog-sub-settings-header-auto
"tag-groups-row" "tag-groups-header-child"
"blog.tag_groups_admin.defpage_tag_groups_page" "tags" "Tag Groups")))
(defcomp ~blog-tag-groups-layout-oob ()
(defcomp ~layouts/tag-groups-layout-oob ()
(<> (~blog-sub-settings-header-auto
"tag-groups-row" "tag-groups-header-child"
"blog.tag_groups_admin.defpage_tag_groups_page" "tags" "Tag Groups" true)
(~clear-oob-div :id "tag-groups-header-child")
(~shared:layout/clear-oob-div :id "tag-groups-header-child")
(~blog-settings-header-auto true)
(~root-header-auto true)))
@@ -140,31 +140,31 @@
;; Tag Group Edit layout (root + settings + tag-groups sub-header with id)
;; ---------------------------------------------------------------------------
(defcomp ~blog-tag-group-edit-layout-full ()
(defcomp ~layouts/tag-group-edit-layout-full ()
(<> (~root-header-auto)
(~blog-settings-header-auto)
(~menu-row-sx :id "tag-groups-row" :level 2
(~shared:layout/menu-row-sx :id "tag-groups-row" :level 2
:link-href (url-for "blog.tag_groups_admin.defpage_tag_group_edit"
:id (request-view-args "id"))
:link-label-content (~blog-sub-settings-label
:link-label-content (~header/sub-settings-label
:icon "fa fa-tags" :label "Tag Groups")
:child-id "tag-groups-header-child")))
(defcomp ~blog-tag-group-edit-layout-oob ()
(<> (~menu-row-sx :id "tag-groups-row" :level 2
(defcomp ~layouts/tag-group-edit-layout-oob ()
(<> (~shared:layout/menu-row-sx :id "tag-groups-row" :level 2
:link-href (url-for "blog.tag_groups_admin.defpage_tag_group_edit"
:id (request-view-args "id"))
:link-label-content (~blog-sub-settings-label
:link-label-content (~header/sub-settings-label
:icon "fa fa-tags" :label "Tag Groups")
:child-id "tag-groups-header-child"
:oob true)
(~clear-oob-div :id "tag-groups-header-child")
(~shared:layout/clear-oob-div :id "tag-groups-header-child")
(~blog-settings-header-auto true)
(~root-header-auto true)))
;; --- Settings nav links — uses IO primitives ---
(defcomp ~blog-settings-nav ()
(defcomp ~layouts/settings-nav ()
(let* ((sc (select-colours))
(links (list
(dict :endpoint "menu_items.defpage_menu_items_page" :icon "fa fa-bars" :label "Menu Items")
@@ -172,7 +172,7 @@
(dict :endpoint "blog.tag_groups_admin.defpage_tag_groups_page" :icon "fa fa-tags" :label "Tag Groups")
(dict :endpoint "settings.defpage_cache_page" :icon "fa fa-refresh" :label "Cache"))))
(<> (map (lambda (lnk)
(~nav-link
(~shared:layout/nav-link
:href (url-for (get lnk "endpoint"))
:icon (get lnk "icon")
:label (get lnk "label")
@@ -181,5 +181,5 @@
;; --- Editor panel wrapper ---
(defcomp ~blog-editor-panel (&key parts)
(defcomp ~layouts/editor-panel (&key parts)
(<> parts))

View File

@@ -1,6 +1,6 @@
;; Menu item form and page search components
(defcomp ~page-search-item (&key id title slug feature-image)
(defcomp ~menu_items/page-search-item (&key id title slug feature-image)
(div :class "flex items-center gap-3 p-3 hover:bg-stone-50 cursor-pointer border-b last:border-b-0"
:data-page-id id :data-page-title title :data-page-slug slug
:data-page-image (or feature-image "")
@@ -11,50 +11,50 @@
(div :class "font-medium truncate" title)
(div :class "text-xs text-stone-500 truncate" slug))))
(defcomp ~page-search-results (&key items sentinel)
(defcomp ~menu_items/page-search-results (&key items sentinel)
(div :class "border border-stone-200 rounded-md max-h-64 overflow-y-auto"
items sentinel))
(defcomp ~page-search-sentinel (&key url query next-page)
(defcomp ~menu_items/page-search-sentinel (&key url query next-page)
(div :sx-get url :sx-trigger "intersect once" :sx-swap "outerHTML"
:sx-vals (str "{\"q\": \"" query "\", \"page\": " next-page "}")
:class "p-3 text-center text-sm text-stone-400"
(i :class "fa fa-spinner fa-spin") " Loading more..."))
(defcomp ~page-search-empty (&key query)
(defcomp ~menu_items/page-search-empty (&key query)
(div :class "p-3 text-center text-stone-400 border border-stone-200 rounded-md"
(str "No pages found matching \"" query "\"")))
;; Data-driven page search results (replaces Python render_page_search_results loop)
(defcomp ~page-search-results-from-data (&key pages query has-more search-url next-page)
(defcomp ~menu_items/page-search-results-from-data (&key pages query has-more search-url next-page)
(if (and (not pages) query)
(~page-search-empty :query query)
(~menu_items/page-search-empty :query query)
(when pages
(~page-search-results
(~menu_items/page-search-results
:items (<> (map (lambda (p)
(~page-search-item
(~menu_items/page-search-item
:id (get p "id") :title (get p "title")
:slug (get p "slug") :feature-image (get p "feature_image")))
pages))
:sentinel (when has-more
(~page-search-sentinel :url search-url :query query :next-page next-page))))))
(~menu_items/page-search-sentinel :url search-url :query query :next-page next-page))))))
;; Data-driven menu nav items (replaces Python render_menu_items_nav_oob loop)
(defcomp ~blog-menu-nav-from-data (&key items nav-cls container-id arrow-cls scroll-hs)
(defcomp ~menu_items/menu-nav-from-data (&key items nav-cls container-id arrow-cls scroll-hs)
(if (not items)
(~blog-nav-empty :wrapper-id "menu-items-nav-wrapper")
(~scroll-nav-wrapper :wrapper-id "menu-items-nav-wrapper" :container-id container-id
(~shared:nav/blog-nav-empty :wrapper-id "menu-items-nav-wrapper")
(~shared:misc/scroll-nav-wrapper :wrapper-id "menu-items-nav-wrapper" :container-id container-id
:arrow-cls arrow-cls
:left-hs (str "on click set #" container-id ".scrollLeft to #" container-id ".scrollLeft - 200")
:scroll-hs scroll-hs
:right-hs (str "on click set #" container-id ".scrollLeft to #" container-id ".scrollLeft + 200")
:items (<> (map (lambda (item)
(let* ((img (~img-or-placeholder :src (get item "feature_image") :alt (get item "label")
(let* ((img (~shared:misc/img-or-placeholder :src (get item "feature_image") :alt (get item "label")
:size-cls "w-8 h-8 rounded-full object-cover flex-shrink-0")))
(if (= (get item "slug") "cart")
(~blog-nav-item-plain :href (get item "href") :selected (get item "selected")
(~shared:nav/blog-nav-item-plain :href (get item "href") :selected (get item "selected")
:nav-cls nav-cls :img img :label (get item "label"))
(~blog-nav-item-link :href (get item "href") :hx-get (get item "hx_get")
(~shared:nav/blog-nav-item-link :href (get item "href") :hx-get (get item "hx_get")
:selected (get item "selected") :nav-cls nav-cls :img img :label (get item "label")))))
items))
:oob true)))

View File

@@ -1,6 +1,6 @@
;; Blog settings panel components (features, markets, associated entries)
(defcomp ~blog-features-form (&key (features-url :as string) (calendar-checked :as boolean) (market-checked :as boolean) (hs-trigger :as string))
(defcomp ~settings/features-form (&key (features-url :as string) (calendar-checked :as boolean) (market-checked :as boolean) (hs-trigger :as string))
(form :sx-put features-url :sx-target "#features-panel" :sx-swap "outerHTML"
:sx-headers {:Content-Type "application/json"} :sx-encoding "json" :class "space-y-3"
(label :class "flex items-center gap-3 cursor-pointer"
@@ -18,33 +18,33 @@
(i :class "fa fa-shopping-bag text-green-600 mr-1")
" Market \u2014 enable product catalog on this page"))))
(defcomp ~blog-sumup-form (&key sumup-url merchant-code placeholder sumup-configured checkout-prefix)
(defcomp ~settings/sumup-form (&key sumup-url merchant-code placeholder sumup-configured checkout-prefix)
(div :class "mt-4 pt-4 border-t border-stone-100"
(~sumup-settings-form :update-url sumup-url :merchant-code merchant-code
(~shared:misc/sumup-settings-form :update-url sumup-url :merchant-code merchant-code
:placeholder placeholder :sumup-configured sumup-configured
:checkout-prefix checkout-prefix :panel-id "features-panel")))
(defcomp ~blog-features-panel (&key form sumup)
(defcomp ~settings/features-panel (&key form sumup)
(div :id "features-panel" :class "space-y-4 p-4 bg-white rounded-lg border border-stone-200"
(h3 :class "text-lg font-semibold text-stone-800" "Page Features")
form sumup))
;; Markets panel
(defcomp ~blog-market-item (&key (name :as string) (slug :as string) (delete-url :as string) (confirm-text :as string))
(defcomp ~settings/market-item (&key (name :as string) (slug :as string) (delete-url :as string) (confirm-text :as string))
(li :class "flex items-center justify-between p-3 bg-stone-50 rounded"
(div (span :class "font-medium" name)
(span :class "text-stone-400 text-sm ml-2" (str "/" slug "/")))
(button :sx-delete delete-url :sx-target "#markets-panel" :sx-swap "outerHTML"
:sx-confirm confirm-text :class "text-red-600 hover:text-red-800 text-sm" "Delete")))
(defcomp ~blog-markets-list (&key items)
(defcomp ~settings/markets-list (&key items)
(ul :class "space-y-2 mb-4" items))
(defcomp ~blog-markets-empty ()
(defcomp ~settings/markets-empty ()
(p :class "text-stone-500 mb-4 text-sm" "No markets yet."))
(defcomp ~blog-markets-panel (&key list create-url)
(defcomp ~settings/markets-panel (&key list create-url)
(div :id "markets-panel"
(h3 :class "text-lg font-semibold mb-3" "Markets")
list
@@ -59,17 +59,17 @@
;; ---------------------------------------------------------------------------
;; Features panel composition — replaces render_features_panel
(defcomp ~blog-features-panel-content (&key features-url calendar-checked market-checked
(defcomp ~settings/features-panel-content (&key features-url calendar-checked market-checked
show-sumup sumup-url merchant-code placeholder
sumup-configured checkout-prefix)
(~blog-features-panel
:form (~blog-features-form
(~settings/features-panel
:form (~settings/features-form
:features-url features-url
:calendar-checked calendar-checked
:market-checked market-checked
:hs-trigger "on change trigger submit on closest <form/>")
:sumup (when show-sumup
(~blog-sumup-form
(~settings/sumup-form
:sumup-url sumup-url
:merchant-code merchant-code
:placeholder placeholder
@@ -77,13 +77,13 @@
:checkout-prefix checkout-prefix))))
;; Markets panel composition — replaces render_markets_panel
(defcomp ~blog-markets-panel-content (&key markets create-url)
(~blog-markets-panel
(defcomp ~settings/markets-panel-content (&key markets create-url)
(~settings/markets-panel
:list (if (empty? (or markets (list)))
(~blog-markets-empty)
(~blog-markets-list
(~settings/markets-empty)
(~settings/markets-list
:items (map (lambda (m)
(~blog-market-item
(~settings/market-item
:name (get m "name")
:slug (get m "slug")
:delete-url (get m "delete_url")
@@ -93,11 +93,11 @@
;; Associated entries
(defcomp ~blog-entry-image (&key (src :as string?) (title :as string))
(defcomp ~settings/entry-image (&key (src :as string?) (title :as string))
(if src (img :src src :alt title :class "w-8 h-8 rounded-full object-cover flex-shrink-0")
(div :class "w-8 h-8 rounded-full bg-stone-200 flex-shrink-0")))
(defcomp ~blog-associated-entry (&key (confirm-text :as string) (toggle-url :as string) hx-headers img (name :as string) (date-str :as string))
(defcomp ~settings/associated-entry (&key (confirm-text :as string) (toggle-url :as string) hx-headers img (name :as string) (date-str :as string))
(button :type "button"
:class "w-full text-left p-3 rounded border bg-green-50 border-green-300 transition hover:bg-green-100"
:data-confirm "" :data-confirm-title "Remove entry?"
@@ -115,14 +115,14 @@
(div :class "text-xs text-stone-600 mt-1" date-str))
(i :class "fa fa-times-circle text-green-600 text-lg flex-shrink-0"))))
(defcomp ~blog-associated-entries-content (&key items)
(defcomp ~settings/associated-entries-content (&key items)
(div :class "space-y-1" items))
(defcomp ~blog-associated-entries-empty ()
(defcomp ~settings/associated-entries-empty ()
(div :class "text-sm text-stone-400"
"No entries associated yet. Browse calendars below to add entries."))
(defcomp ~blog-associated-entries-panel (&key content)
(defcomp ~settings/associated-entries-panel (&key content)
(div :id "associated-entries-list" :class "border rounded-lg p-4 bg-white"
(h3 :class "text-lg font-semibold mb-4" "Associated Entries")
content))
@@ -131,17 +131,17 @@
;; Associated entries composition — replaces _render_associated_entries
;; ---------------------------------------------------------------------------
(defcomp ~blog-associated-entries-from-data (&key entries csrf)
(~blog-associated-entries-panel
(defcomp ~settings/associated-entries-from-data (&key entries csrf)
(~settings/associated-entries-panel
:content (if (empty? (or entries (list)))
(~blog-associated-entries-empty)
(~blog-associated-entries-content
(~settings/associated-entries-empty)
(~settings/associated-entries-content
:items (map (lambda (e)
(~blog-associated-entry
(~settings/associated-entry
:confirm-text (get e "confirm_text")
:toggle-url (get e "toggle_url")
:hx-headers {:X-CSRFToken csrf}
:img (~blog-entry-image :src (get e "cal_image") :title (get e "cal_title"))
:img (~settings/entry-image :src (get e "cal_image") :title (get e "cal_title"))
:name (get e "name")
:date-str (get e "date_str")))
(or entries (list)))))))
@@ -150,7 +150,7 @@
;; Entries browser composition — replaces _h_post_entries_content
;; ---------------------------------------------------------------------------
(defcomp ~blog-calendar-browser-item (&key (name :as string) (title :as string) (image :as string?) (view-url :as string))
(defcomp ~settings/calendar-browser-item (&key (name :as string) (title :as string) (image :as string?) (view-url :as string))
(details :class "border rounded-lg bg-white" :data-toggle-group "calendar-browser"
(summary :class "p-4 cursor-pointer hover:bg-stone-50 flex items-center gap-3"
(if image
@@ -163,7 +163,7 @@
(div :class "p-4 border-t" :sx-get view-url :sx-trigger "intersect once" :sx-swap "innerHTML"
(div :class "text-sm text-stone-400" "Loading calendar..."))))
(defcomp ~blog-entries-browser-content (&key entries-panel calendars)
(defcomp ~settings/entries-browser-content (&key entries-panel calendars)
(div :id "post-entries-content" :class "space-y-6 p-4"
entries-panel
(div :class "space-y-3"
@@ -171,7 +171,7 @@
(if (empty? (or calendars (list)))
(div :class "text-sm text-stone-400" "No calendars found.")
(map (lambda (cal)
(~blog-calendar-browser-item
(~settings/calendar-browser-item
:name (get cal "name")
:title (get cal "title")
:image (get cal "image")
@@ -182,17 +182,17 @@
;; Post settings form composition — replaces _h_post_settings_content
;; ---------------------------------------------------------------------------
(defcomp ~blog-settings-field-label (&key (text :as string) (field-for :as string))
(defcomp ~settings/field-label (&key (text :as string) (field-for :as string))
(label :for field-for
:class "block text-[13px] font-medium text-stone-500 mb-[4px]" text))
(defcomp ~blog-settings-section (&key (title :as string) content (is-open :as boolean))
(defcomp ~settings/section (&key (title :as string) content (is-open :as boolean))
(details :class "border border-stone-200 rounded-[8px] overflow-hidden" :open is-open
(summary :class "px-[16px] py-[10px] bg-stone-50 text-[14px] font-medium text-stone-600 cursor-pointer select-none hover:bg-stone-100 transition-colors"
title)
(div :class "px-[16px] py-[12px] space-y-[12px]" content)))
(defcomp ~blog-settings-form-content (&key csrf updated-at is-page save-success
(defcomp ~settings/form-content (&key csrf updated-at is-page save-success
slug published-at featured visibility email-only
tags feature-image-alt
meta-title meta-description canonical-url
@@ -209,19 +209,19 @@
(input :type "hidden" :name "updated_at" :value (or updated-at ""))
(div :class "space-y-[12px] mt-[16px]"
;; General
(~blog-settings-section :title "General" :is-open true :content
(~settings/section :title "General" :is-open true :content
(<>
(div (~blog-settings-field-label :text "Slug" :field-for "settings-slug")
(div (~settings/field-label :text "Slug" :field-for "settings-slug")
(input :type "text" :name "slug" :id "settings-slug" :value (or slug "")
:placeholder slug-placeholder :class input-cls))
(div (~blog-settings-field-label :text "Published at" :field-for "settings-published_at")
(div (~settings/field-label :text "Published at" :field-for "settings-published_at")
(input :type "datetime-local" :name "published_at" :id "settings-published_at"
:value (or published-at "") :class input-cls))
(div (label :class "inline-flex items-center gap-[8px] cursor-pointer"
(input :type "checkbox" :name "featured" :id "settings-featured" :checked featured
:class "rounded border-stone-300 text-stone-600 focus:ring-stone-300")
(span :class "text-[14px] text-stone-600" featured-label)))
(div (~blog-settings-field-label :text "Visibility" :field-for "settings-visibility")
(div (~settings/field-label :text "Visibility" :field-for "settings-visibility")
(select :name "visibility" :id "settings-visibility" :class input-cls
(option :value "public" :selected (= visibility "public") "Public")
(option :value "members" :selected (= visibility "members") "Members")
@@ -231,57 +231,57 @@
:class "rounded border-stone-300 text-stone-600 focus:ring-stone-300")
(span :class "text-[14px] text-stone-600" "Email only")))))
;; Tags
(~blog-settings-section :title "Tags" :content
(div (~blog-settings-field-label :text "Tags (comma-separated)" :field-for "settings-tags")
(~settings/section :title "Tags" :content
(div (~settings/field-label :text "Tags (comma-separated)" :field-for "settings-tags")
(input :type "text" :name "tags" :id "settings-tags" :value (or tags "")
:placeholder "news, updates, featured" :class input-cls)
(p :class "text-[12px] text-stone-400 mt-[4px]" "Unknown tags will be created automatically.")))
;; Feature Image
(~blog-settings-section :title "Feature Image" :content
(div (~blog-settings-field-label :text "Alt text" :field-for "settings-feature_image_alt")
(~settings/section :title "Feature Image" :content
(div (~settings/field-label :text "Alt text" :field-for "settings-feature_image_alt")
(input :type "text" :name "feature_image_alt" :id "settings-feature_image_alt"
:value (or feature-image-alt "") :placeholder "Describe the feature image" :class input-cls)))
;; SEO / Meta
(~blog-settings-section :title "SEO / Meta" :content
(~settings/section :title "SEO / Meta" :content
(<>
(div (~blog-settings-field-label :text "Meta title" :field-for "settings-meta_title")
(div (~settings/field-label :text "Meta title" :field-for "settings-meta_title")
(input :type "text" :name "meta_title" :id "settings-meta_title" :value (or meta-title "")
:placeholder "SEO title" :maxlength "300" :class input-cls)
(p :class "text-[12px] text-stone-400 mt-[2px]" "Recommended: 70 characters. Max: 300."))
(div (~blog-settings-field-label :text "Meta description" :field-for "settings-meta_description")
(div (~settings/field-label :text "Meta description" :field-for "settings-meta_description")
(textarea :name "meta_description" :id "settings-meta_description" :rows "2"
:placeholder "SEO description" :maxlength "500" :class textarea-cls
(or meta-description ""))
(p :class "text-[12px] text-stone-400 mt-[2px]" "Recommended: 156 characters."))
(div (~blog-settings-field-label :text "Canonical URL" :field-for "settings-canonical_url")
(div (~settings/field-label :text "Canonical URL" :field-for "settings-canonical_url")
(input :type "url" :name "canonical_url" :id "settings-canonical_url"
:value (or canonical-url "") :placeholder "https://example.com/original-post" :class input-cls))))
;; Facebook / OpenGraph
(~blog-settings-section :title "Facebook / OpenGraph" :content
(~settings/section :title "Facebook / OpenGraph" :content
(<>
(div (~blog-settings-field-label :text "OG title" :field-for "settings-og_title")
(div (~settings/field-label :text "OG title" :field-for "settings-og_title")
(input :type "text" :name "og_title" :id "settings-og_title" :value (or og-title "") :class input-cls))
(div (~blog-settings-field-label :text "OG description" :field-for "settings-og_description")
(div (~settings/field-label :text "OG description" :field-for "settings-og_description")
(textarea :name "og_description" :id "settings-og_description" :rows "2" :class textarea-cls
(or og-description "")))
(div (~blog-settings-field-label :text "OG image URL" :field-for "settings-og_image")
(div (~settings/field-label :text "OG image URL" :field-for "settings-og_image")
(input :type "url" :name "og_image" :id "settings-og_image" :value (or og-image "")
:placeholder "https://..." :class input-cls))))
;; X / Twitter
(~blog-settings-section :title "X / Twitter" :content
(~settings/section :title "X / Twitter" :content
(<>
(div (~blog-settings-field-label :text "Twitter title" :field-for "settings-twitter_title")
(div (~settings/field-label :text "Twitter title" :field-for "settings-twitter_title")
(input :type "text" :name "twitter_title" :id "settings-twitter_title"
:value (or twitter-title "") :class input-cls))
(div (~blog-settings-field-label :text "Twitter description" :field-for "settings-twitter_description")
(div (~settings/field-label :text "Twitter description" :field-for "settings-twitter_description")
(textarea :name "twitter_description" :id "settings-twitter_description" :rows "2" :class textarea-cls
(or twitter-description "")))
(div (~blog-settings-field-label :text "Twitter image URL" :field-for "settings-twitter_image")
(div (~settings/field-label :text "Twitter image URL" :field-for "settings-twitter_image")
(input :type "url" :name "twitter_image" :id "settings-twitter_image"
:value (or twitter-image "") :placeholder "https://..." :class input-cls))))
;; Advanced
(~blog-settings-section :title "Advanced" :content
(div (~blog-settings-field-label :text "Custom template" :field-for "settings-custom_template")
(~settings/section :title "Advanced" :content
(div (~settings/field-label :text "Custom template" :field-for "settings-custom_template")
(input :type "text" :name "custom_template" :id "settings-custom_template"
:value (or custom-template "") :placeholder tmpl-placeholder :class input-cls))))
(div :class "flex items-center gap-[16px] mt-[24px] pt-[16px] border-t border-stone-200"

View File

@@ -9,7 +9,7 @@
:auth :admin
:layout :blog
:data (editor-data)
:content (~blog-editor-content
:content (~editor/content
:csrf csrf :title-placeholder title-placeholder
:create-label create-label :css-href css-href
:js-src js-src :sx-editor-js-src sx-editor-js-src
@@ -20,7 +20,7 @@
:auth :admin
:layout :blog
:data (editor-page-data)
:content (~blog-editor-content
:content (~editor/content
:csrf csrf :title-placeholder title-placeholder
:create-label create-label :css-href css-href
:js-src js-src :sx-editor-js-src sx-editor-js-src
@@ -33,21 +33,21 @@
:auth :admin
:layout (:post-admin :selected "admin")
:data (post-admin-data slug)
:content (~blog-admin-placeholder))
:content (~admin/placeholder))
(defpage post-data
:path "/<slug>/admin/data/"
:auth :admin
:layout (:post-admin :selected "data")
:data (post-data-data slug)
:content (~blog-data-table-content :tablename tablename :model-data model-data))
:content (~admin/data-table-content :tablename tablename :model-data model-data))
(defpage post-preview
:path "/<slug>/admin/preview/"
:auth :admin
:layout (:post-admin :selected "preview")
:data (post-preview-data slug)
:content (~blog-preview-content
:content (~admin/preview-content
:sx-pretty sx-pretty :json-pretty json-pretty
:sx-rendered sx-rendered :lex-rendered lex-rendered))
@@ -56,8 +56,8 @@
:auth :admin
:layout (:post-admin :selected "entries")
:data (post-entries-data slug)
:content (~blog-entries-browser-content
:entries-panel (~blog-associated-entries-from-data :entries entries :csrf csrf)
:content (~settings/entries-browser-content
:entries-panel (~settings/associated-entries-from-data :entries entries :csrf csrf)
:calendars calendars))
(defpage post-settings
@@ -65,7 +65,7 @@
:auth :post_author
:layout (:post-admin :selected "settings")
:data (post-settings-data slug)
:content (~blog-settings-form-content
:content (~settings/form-content
:csrf csrf :updated-at updated-at :is-page is-page
:save-success save-success :slug settings-slug
:published-at published-at :featured featured
@@ -82,7 +82,7 @@
:auth :post_author
:layout (:post-admin :selected "edit")
:data (post-edit-data slug)
:content (~blog-edit-content
:content (~editor/edit-content
:csrf csrf :updated-at updated-at
:title-val title-val :excerpt-val excerpt-val
:feature-image feature-image :feature-image-caption feature-image-caption
@@ -111,7 +111,7 @@
:auth :admin
:layout :blog-cache
:data (service "blog-page" "cache-data")
:content (~blog-cache-panel :clear-url clear-url :csrf csrf))
:content (~admin/cache-panel :clear-url clear-url :csrf csrf))
; --- Snippets ---
@@ -120,7 +120,7 @@
:auth :login
:layout :blog-snippets
:data (service "blog-page" "snippets-data")
:content (~blog-snippets-content
:content (~admin/snippets-content
:snippets snippets :is-admin is-admin :csrf csrf))
; --- Menu Items ---
@@ -130,7 +130,7 @@
:auth :admin
:layout :blog-menu-items
:data (service "blog-page" "menu-items-data")
:content (~blog-menu-items-content
:content (~admin/menu-items-content
:menu-items menu-items :new-url new-url :csrf csrf))
; --- Tag Groups ---
@@ -140,7 +140,7 @@
:auth :admin
:layout :blog-tag-groups
:data (service "blog-page" "tag-groups-data")
:content (~blog-tag-groups-content
:content (~admin/tag-groups-content
:groups groups :unassigned-tags unassigned-tags
:create-url create-url :csrf csrf))
@@ -149,6 +149,6 @@
:auth :admin
:layout :blog-tag-group-edit
:data (service "blog-page" "tag-group-edit-data" :id id)
:content (~blog-tag-group-edit-content
:content (~admin/tag-group-edit-content
:group group :all-tags all-tags
:save-url save-url :delete-url delete-url :csrf csrf))

View File

@@ -167,7 +167,7 @@ class TestCards:
result = lexical_to_sx(_doc({
"type": "image", "src": "photo.jpg", "alt": "test"
}))
assert '(~kg-image :src "photo.jpg" :alt "test")' == result
assert '(~kg_cards/kg-image :src "photo.jpg" :alt "test")' == result
def test_image_wide_with_caption(self):
result = lexical_to_sx(_doc({
@@ -189,7 +189,7 @@ class TestCards:
"type": "bookmark", "url": "https://example.com",
"metadata": {"title": "Example", "description": "A site"}
}))
assert "(~kg-bookmark " in result
assert "(~kg_cards/kg-bookmark " in result
assert ':url "https://example.com"' in result
assert ':title "Example"' in result
@@ -199,7 +199,7 @@ class TestCards:
"calloutEmoji": "💡",
"children": [_text("Note")]
}))
assert "(~kg-callout " in result
assert "(~kg_cards/kg-callout " in result
assert ':color "blue"' in result
def test_button(self):
@@ -207,7 +207,7 @@ class TestCards:
"type": "button", "buttonText": "Click",
"buttonUrl": "https://example.com"
}))
assert "(~kg-button " in result
assert "(~kg_cards/kg-button " in result
assert ':text "Click"' in result
def test_toggle(self):
@@ -215,28 +215,28 @@ class TestCards:
"type": "toggle", "heading": "FAQ",
"children": [_text("Answer")]
}))
assert "(~kg-toggle " in result
assert "(~kg_cards/kg-toggle " in result
assert ':heading "FAQ"' in result
def test_html(self):
result = lexical_to_sx(_doc({
"type": "html", "html": "<div>custom</div>"
}))
assert result == '(~kg-html (div "custom"))'
assert result == '(~kg_cards/kg-html (div "custom"))'
def test_embed(self):
result = lexical_to_sx(_doc({
"type": "embed", "html": "<iframe></iframe>",
"caption": "Video"
}))
assert "(~kg-embed " in result
assert "(~kg_cards/kg-embed " in result
assert ':caption "Video"' in result
def test_markdown(self):
result = lexical_to_sx(_doc({
"type": "markdown", "markdown": "**bold** text"
}))
assert result.startswith("(~kg-md ")
assert result.startswith("(~kg_cards/kg-md ")
assert "(p " in result
assert "(strong " in result
@@ -244,14 +244,14 @@ class TestCards:
result = lexical_to_sx(_doc({
"type": "video", "src": "v.mp4", "cardWidth": "wide"
}))
assert "(~kg-video " in result
assert "(~kg_cards/kg-video " in result
assert ':width "wide"' in result
def test_audio(self):
result = lexical_to_sx(_doc({
"type": "audio", "src": "s.mp3", "title": "Song", "duration": 195
}))
assert "(~kg-audio " in result
assert "(~kg_cards/kg-audio " in result
assert ':duration "3:15"' in result
def test_file(self):
@@ -259,13 +259,13 @@ class TestCards:
"type": "file", "src": "f.pdf", "fileName": "doc.pdf",
"fileSize": 2100000
}))
assert "(~kg-file " in result
assert "(~kg_cards/kg-file " in result
assert ':filename "doc.pdf"' in result
assert "MB" in result
def test_paywall(self):
result = lexical_to_sx(_doc({"type": "paywall"}))
assert result == "(~kg-paywall)"
assert result == "(~kg_cards/kg-paywall)"
# ---------------------------------------------------------------------------