Compare commits
5 Commits
544892edd9
...
0a81a2af01
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a81a2af01 | |||
| 0c9dbd6657 | |||
| a4377668be | |||
| a98354c0f0 | |||
| df8b19ccb8 |
@@ -55,6 +55,104 @@
|
||||
(button :type "submit"
|
||||
: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 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)
|
||||
(let* ((sel-cls "text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600")
|
||||
(active "px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent")
|
||||
(inactive "px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600"))
|
||||
(form :id "post-edit-form" :method "post" :class "max-w-[768px] mx-auto pb-[48px]"
|
||||
(input :type "hidden" :name "csrf_token" :value csrf)
|
||||
(input :type "hidden" :name "updated_at" :value updated-at)
|
||||
(input :type "hidden" :id "lexical-json-input" :name "lexical" :value "")
|
||||
(input :type "hidden" :id "sx-content-input" :name "sx_content" :value (or sx-content-val ""))
|
||||
(input :type "hidden" :id "feature-image-input" :name "feature_image" :value (or feature-image ""))
|
||||
(input :type "hidden" :id "feature-image-caption-input" :name "feature_image_caption" :value (or feature-image-caption ""))
|
||||
(div :id "feature-image-container" :class "relative mt-[16px] mb-[24px] group"
|
||||
(div :id "feature-image-empty" :class (if feature-image "hidden" "")
|
||||
(button :type "button" :id "feature-image-add-btn"
|
||||
:class "text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer"
|
||||
"+ Add feature image"))
|
||||
(div :id "feature-image-filled" :class (str "relative " (if feature-image "" "hidden"))
|
||||
(img :id "feature-image-preview" :src (or feature-image "") :alt ""
|
||||
:class "w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer")
|
||||
(button :type "button" :id "feature-image-delete-btn"
|
||||
:class "absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/70 cursor-pointer text-[14px]"
|
||||
:title "Remove feature image"
|
||||
(i :class "fa-solid fa-trash-can"))
|
||||
(input :type "text" :id "feature-image-caption" :value (or feature-image-caption "")
|
||||
:placeholder "Add a caption..."
|
||||
:class "mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 focus:text-stone-700"))
|
||||
(div :id "feature-image-uploading"
|
||||
:class "hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400"
|
||||
(i :class "fa-solid fa-spinner fa-spin") " Uploading...")
|
||||
(input :type "file" :id "feature-image-file"
|
||||
:accept "image/jpeg,image/png,image/gif,image/webp,image/svg+xml" :class "hidden"))
|
||||
(input :type "text" :name "title" :value (or title-val "") :placeholder title-placeholder
|
||||
:class "w-full text-[36px] font-bold bg-transparent border-none outline-none placeholder:text-stone-300 mb-[8px] leading-tight")
|
||||
(textarea :name "custom_excerpt" :rows "1" :placeholder "Add an excerpt..."
|
||||
:class "w-full text-[18px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed"
|
||||
(or excerpt-val ""))
|
||||
;; Editor tabs
|
||||
(div :class "flex gap-[4px] mb-[8px] border-b border-stone-200"
|
||||
(button :type "button" :id "editor-tab-sx"
|
||||
:class (if has-sx active inactive)
|
||||
:onclick "document.getElementById('sx-editor').style.display='block';document.getElementById('lexical-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-koenig').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'"
|
||||
"SX Editor")
|
||||
(button :type "button" :id "editor-tab-koenig"
|
||||
:class (if has-sx inactive active)
|
||||
:onclick "document.getElementById('lexical-editor').style.display='block';document.getElementById('sx-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-sx').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'"
|
||||
"Koenig (Legacy)"))
|
||||
(div :id "sx-editor" :class "relative w-full bg-transparent"
|
||||
:style (if has-sx "" "display:none"))
|
||||
(div :id "lexical-editor" :class "relative w-full bg-transparent"
|
||||
:style (if has-sx "display:none" ""))
|
||||
;; Initial lexical JSON
|
||||
(script :id "lexical-initial-data" :type "application/json" lexical-json)
|
||||
;; Footer: status + publish mode + newsletter + save + badges
|
||||
(div :class "flex flex-wrap items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200"
|
||||
(select :id "status-select" :name "status" :class sel-cls
|
||||
(option :value "draft" :selected (= status "draft") "Draft")
|
||||
(option :value "published" :selected (= status "published") "Published"))
|
||||
(select :id "publish-mode-select" :name "publish_mode"
|
||||
:class (str sel-cls (if (= status "published") "" " hidden")
|
||||
(if already-emailed " opacity-50 pointer-events-none" ""))
|
||||
:disabled (if already-emailed true nil)
|
||||
(option :value "web" :selected true "Web only")
|
||||
(option :value "email" "Email only")
|
||||
(option :value "both" "Web + Email"))
|
||||
(select :id "newsletter-select" :name "newsletter_slug"
|
||||
:class (str sel-cls " hidden")
|
||||
:disabled (if already-emailed true nil)
|
||||
newsletter-options)
|
||||
(button :type "submit"
|
||||
:class "px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer"
|
||||
"Save")
|
||||
(when footer-extra footer-extra)))))
|
||||
|
||||
;; Publish-mode show/hide script for edit form
|
||||
(defcomp ~blog-editor-publish-js (&key already-emailed)
|
||||
(script
|
||||
"(function() {"
|
||||
" var statusSel = document.getElementById('status-select');"
|
||||
" var modeSel = document.getElementById('publish-mode-select');"
|
||||
" var nlSel = document.getElementById('newsletter-select');"
|
||||
(str " var alreadyEmailed = " (if already-emailed "true" "false") ";")
|
||||
" function sync() {"
|
||||
" var isPublished = statusSel.value === 'published';"
|
||||
" if (isPublished && !alreadyEmailed) { modeSel.classList.remove('hidden'); } else { modeSel.classList.add('hidden'); }"
|
||||
" var needsEmail = isPublished && !alreadyEmailed && (modeSel.value === 'email' || modeSel.value === 'both');"
|
||||
" if (needsEmail) { nlSel.classList.remove('hidden'); } else { nlSel.classList.add('hidden'); }"
|
||||
" }"
|
||||
" statusSel.addEventListener('change', sync);"
|
||||
" modeSel.addEventListener('change', sync);"
|
||||
" sync();"
|
||||
"})();"))
|
||||
|
||||
(defcomp ~blog-editor-styles (&key css-href)
|
||||
(<> (link :rel "stylesheet" :href css-href)
|
||||
(style
|
||||
|
||||
@@ -1271,7 +1271,7 @@ def render_editor_panel(save_error: str | None = None, is_page: bool = False) ->
|
||||
"\n"
|
||||
" if (typeof SxEditor !== 'undefined') {\n"
|
||||
" SxEditor.mount('sx-editor', {\n"
|
||||
" initialSx: window.__SX_INITIAL__ || null,\n"
|
||||
" initialSx: (document.getElementById('sx-content-input') || {}).value || null,\n"
|
||||
" csrfToken: csrfToken,\n"
|
||||
" uploadUrls: uploadUrls,\n"
|
||||
f" oembedUrl: '{oembed_url}',\n"
|
||||
@@ -1678,10 +1678,11 @@ def _raw_html_sx(html: str) -> str:
|
||||
|
||||
|
||||
def _post_edit_content_sx(ctx: dict) -> str:
|
||||
"""Build WYSIWYG editor panel natively (replaces _types/post_edit/_main_panel.html)."""
|
||||
"""Build WYSIWYG editor panel as SX expression (edit page)."""
|
||||
from quart import url_for as qurl, current_app, g, request as qrequest
|
||||
from shared.browser.app.csrf import generate_csrf_token
|
||||
esc = escape
|
||||
from shared.sx.helpers import sx_call
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
ghost_post = ctx.get("ghost_post", {}) or {}
|
||||
save_success = ctx.get("save_success", False)
|
||||
@@ -1706,181 +1707,81 @@ def _post_edit_content_sx(ctx: dict) -> str:
|
||||
|
||||
feature_image = ghost_post.get("feature_image") or ""
|
||||
feature_image_caption = ghost_post.get("feature_image_caption") or ""
|
||||
title_val = esc(ghost_post.get("title") or "")
|
||||
excerpt_val = esc(ghost_post.get("custom_excerpt") or "")
|
||||
updated_at = esc(ghost_post.get("updated_at") or "")
|
||||
title_val = ghost_post.get("title") or ""
|
||||
excerpt_val = ghost_post.get("custom_excerpt") or ""
|
||||
updated_at = ghost_post.get("updated_at") or ""
|
||||
status = ghost_post.get("status") or "draft"
|
||||
lexical_json = ghost_post.get("lexical") or '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}'
|
||||
sx_content = ghost_post.get("sx_content") or ""
|
||||
has_sx = bool(sx_content)
|
||||
|
||||
already_emailed = bool(ghost_post and ghost_post.get("email") and (ghost_post["email"] if isinstance(ghost_post["email"], dict) else {}).get("status"))
|
||||
# For ORM objects the email may be an object
|
||||
email_obj = ghost_post.get("email")
|
||||
if email_obj and not isinstance(email_obj, dict):
|
||||
already_emailed = bool(getattr(email_obj, "status", None))
|
||||
|
||||
parts: list[str] = []
|
||||
|
||||
# Error banner
|
||||
if save_error:
|
||||
parts.append(
|
||||
f'<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">'
|
||||
f'<strong>Save failed:</strong> {esc(save_error)}</div>'
|
||||
)
|
||||
|
||||
# Hidden inputs
|
||||
fi_hidden = f' hidden' if not feature_image else ''
|
||||
fi_visible = f' hidden' if feature_image else ''
|
||||
|
||||
title_placeholder = "Page title..." if is_page else "Post title..."
|
||||
|
||||
form_parts: list[str] = []
|
||||
form_parts.append(f'<input type="hidden" name="csrf_token" value="{csrf}">')
|
||||
form_parts.append(f'<input type="hidden" name="updated_at" value="{updated_at}">')
|
||||
form_parts.append('<input type="hidden" id="lexical-json-input" name="lexical" value="">')
|
||||
form_parts.append(f'<input type="hidden" id="sx-content-input" name="sx_content" value="{esc(sx_content)}">')
|
||||
form_parts.append(f'<input type="hidden" id="feature-image-input" name="feature_image" value="{esc(feature_image)}">')
|
||||
form_parts.append(f'<input type="hidden" id="feature-image-caption-input" name="feature_image_caption" value="{esc(feature_image_caption)}">')
|
||||
|
||||
# Feature image section
|
||||
form_parts.append(
|
||||
f'<div id="feature-image-container" class="relative mt-[16px] mb-[24px] group">'
|
||||
f'<div id="feature-image-empty" class="{"hidden" if feature_image else ""}">'
|
||||
f'<button type="button" id="feature-image-add-btn" class="text-[14px] text-stone-400 hover:text-stone-600 transition-colors cursor-pointer">+ Add feature image</button>'
|
||||
f'</div>'
|
||||
f'<div id="feature-image-filled" class="relative {"" if feature_image else "hidden"}">'
|
||||
f'<img id="feature-image-preview" src="{esc(feature_image)}" alt="" class="w-full max-h-[448px] object-cover rounded-[8px] cursor-pointer">'
|
||||
f'<button type="button" id="feature-image-delete-btn" class="absolute top-[8px] right-[8px] w-[32px] h-[32px] rounded-full bg-black/50 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/70 cursor-pointer text-[14px]" title="Remove feature image"><i class="fa-solid fa-trash-can"></i></button>'
|
||||
f'<input type="text" id="feature-image-caption" value="{esc(feature_image_caption)}" placeholder="Add a caption..." class="mt-[8px] w-full text-[14px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 focus:text-stone-700">'
|
||||
f'</div>'
|
||||
f'<div id="feature-image-uploading" class="hidden flex items-center gap-[8px] mt-[8px] text-[14px] text-stone-400"><i class="fa-solid fa-spinner fa-spin"></i> Uploading...</div>'
|
||||
f'<input type="file" id="feature-image-file" accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml" class="hidden">'
|
||||
f'</div>'
|
||||
)
|
||||
|
||||
# Title
|
||||
form_parts.append(
|
||||
f'<input type="text" name="title" value="{title_val}" placeholder="{title_placeholder}"'
|
||||
f' class="w-full text-[36px] font-bold bg-transparent border-none outline-none placeholder:text-stone-300 mb-[8px] leading-tight">'
|
||||
)
|
||||
|
||||
# Excerpt
|
||||
form_parts.append(
|
||||
f'<textarea name="custom_excerpt" rows="1" placeholder="Add an excerpt..."'
|
||||
f' class="w-full text-[18px] text-stone-500 bg-transparent border-none outline-none placeholder:text-stone-300 resize-none mb-[24px] leading-relaxed">{excerpt_val}</textarea>'
|
||||
)
|
||||
|
||||
# Editor tabs: SX (primary) and Koenig (legacy)
|
||||
has_sx = bool(sx_content)
|
||||
sx_active = 'text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent'
|
||||
sx_inactive = 'text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'
|
||||
form_parts.append(
|
||||
'<div class="flex gap-[4px] mb-[8px] border-b border-stone-200">'
|
||||
f'<button type="button" id="editor-tab-sx" class="px-[12px] py-[6px] text-[13px] font-medium {sx_active if has_sx else sx_inactive}"'
|
||||
""" onclick="document.getElementById('sx-editor').style.display='block';document.getElementById('lexical-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-koenig').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'" """
|
||||
'>SX Editor</button>'
|
||||
f'<button type="button" id="editor-tab-koenig" class="px-[12px] py-[6px] text-[13px] font-medium {sx_inactive if has_sx else sx_active}"'
|
||||
""" onclick="document.getElementById('lexical-editor').style.display='block';document.getElementById('sx-editor').style.display='none';this.className='px-[12px] py-[6px] text-[13px] font-medium text-stone-700 border-b-2 border-stone-700 cursor-pointer bg-transparent';document.getElementById('editor-tab-sx').className='px-[12px] py-[6px] text-[13px] font-medium text-stone-400 border-b-2 border-transparent cursor-pointer bg-transparent hover:text-stone-600'" """
|
||||
'>Koenig (Legacy)</button>'
|
||||
'</div>'
|
||||
)
|
||||
# SX editor mount point
|
||||
form_parts.append(f'<div id="sx-editor" class="relative w-full bg-transparent" style="{"" if has_sx else "display:none"}"></div>')
|
||||
# Koenig editor mount point
|
||||
form_parts.append(f'<div id="lexical-editor" class="relative w-full bg-transparent" style="{"display:none" if has_sx else ""}"></div>')
|
||||
|
||||
# Initial lexical JSON
|
||||
form_parts.append(f'<script id="lexical-initial-data" type="application/json">{lexical_json}</script>')
|
||||
|
||||
# Status + publish footer
|
||||
draft_sel = ' selected' if status == 'draft' else ''
|
||||
pub_sel = ' selected' if status == 'published' else ''
|
||||
mode_hidden = ' hidden' if status != 'published' else ''
|
||||
mode_disabled = ' opacity-50 pointer-events-none' if already_emailed else ''
|
||||
mode_dis_attr = ' disabled' if already_emailed else ''
|
||||
|
||||
nl_options = '<option value="">Select newsletter\u2026</option>'
|
||||
# Newsletter options as SX fragment
|
||||
nl_parts = ['(option :value "" "Select newsletter\u2026")']
|
||||
for nl in newsletters:
|
||||
nl_slug = esc(getattr(nl, "slug", ""))
|
||||
nl_name = esc(getattr(nl, "name", ""))
|
||||
nl_options += f'<option value="{nl_slug}">{nl_name}</option>'
|
||||
nl_slug = sx_serialize(getattr(nl, "slug", ""))
|
||||
nl_name = sx_serialize(getattr(nl, "name", ""))
|
||||
nl_parts.append(f"(option :value {nl_slug} {nl_name})")
|
||||
nl_opts_sx = SxExpr("(<> " + " ".join(nl_parts) + ")")
|
||||
|
||||
footer_extra = ''
|
||||
# Footer extra badges as SX fragment
|
||||
badge_parts: list[str] = []
|
||||
if save_success:
|
||||
footer_extra += ' <span class="text-[14px] text-green-600">Saved.</span>'
|
||||
badge_parts.append('(span :class "text-[14px] text-green-600" "Saved.")')
|
||||
publish_requested = qrequest.args.get("publish_requested") if hasattr(qrequest, 'args') else None
|
||||
if publish_requested:
|
||||
footer_extra += ' <span class="text-[14px] text-blue-600">Publish requested \u2014 an admin will review.</span>'
|
||||
badge_parts.append('(span :class "text-[14px] text-blue-600" "Publish requested \u2014 an admin will review.")')
|
||||
if post.get("publish_requested"):
|
||||
footer_extra += ' <span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800">Publish requested</span>'
|
||||
badge_parts.append('(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-blue-100 text-blue-800" "Publish requested")')
|
||||
if already_emailed:
|
||||
nl_name = ""
|
||||
newsletter = ghost_post.get("newsletter")
|
||||
if newsletter:
|
||||
nl_name = getattr(newsletter, "name", "") if not isinstance(newsletter, dict) else newsletter.get("name", "")
|
||||
suffix = f" to {esc(nl_name)}" if nl_name else ""
|
||||
footer_extra += f' <span class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800">Emailed{suffix}</span>'
|
||||
suffix = f" to {nl_name}" if nl_name else ""
|
||||
badge_parts.append(f'(span :class "inline-block px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-800" "Emailed{suffix}")')
|
||||
footer_extra_sx = SxExpr("(<> " + " ".join(badge_parts) + ")") if badge_parts else None
|
||||
|
||||
form_parts.append(
|
||||
f'<div class="flex flex-wrap items-center gap-[16px] mt-[32px] pt-[16px] border-t border-stone-200">'
|
||||
f'<select id="status-select" name="status" class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600">'
|
||||
f'<option value="draft"{draft_sel}>Draft</option>'
|
||||
f'<option value="published"{pub_sel}>Published</option></select>'
|
||||
f'<select id="publish-mode-select" name="publish_mode" class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600{mode_hidden}{mode_disabled}"{mode_dis_attr}>'
|
||||
f'<option value="web" selected>Web only</option><option value="email">Email only</option><option value="both">Web + Email</option></select>'
|
||||
f'<select id="newsletter-select" name="newsletter_slug" class="text-[14px] rounded-[4px] border border-stone-200 px-[8px] py-[6px] bg-white text-stone-600 hidden"{mode_dis_attr}>{nl_options}</select>'
|
||||
f'<button type="submit" class="px-[20px] py-[6px] bg-stone-700 text-white text-[14px] rounded-[8px] hover:bg-stone-800 transition-colors cursor-pointer">Save</button>'
|
||||
f'{footer_extra}</div>'
|
||||
)
|
||||
parts: list[str] = []
|
||||
|
||||
form_html = '<form id="post-edit-form" method="post" class="max-w-[768px] mx-auto pb-[48px]">' + "".join(form_parts) + '</form>'
|
||||
parts.append(form_html)
|
||||
# Error banner
|
||||
if save_error:
|
||||
parts.append(sx_call("blog-editor-error", error=save_error))
|
||||
|
||||
# Publish-mode show/hide JS
|
||||
already_emailed_js = 'true' if already_emailed else 'false'
|
||||
parts.append(
|
||||
'<script>'
|
||||
'(function() {'
|
||||
" var statusSel = document.getElementById('status-select');"
|
||||
" var modeSel = document.getElementById('publish-mode-select');"
|
||||
" var nlSel = document.getElementById('newsletter-select');"
|
||||
f' var alreadyEmailed = {already_emailed_js};'
|
||||
' function sync() {'
|
||||
" var isPublished = statusSel.value === 'published';"
|
||||
" if (isPublished && !alreadyEmailed) { modeSel.classList.remove('hidden'); } else { modeSel.classList.add('hidden'); }"
|
||||
" var needsEmail = isPublished && !alreadyEmailed && (modeSel.value === 'email' || modeSel.value === 'both');"
|
||||
" if (needsEmail) { nlSel.classList.remove('hidden'); } else { nlSel.classList.add('hidden'); }"
|
||||
' }'
|
||||
" statusSel.addEventListener('change', sync);"
|
||||
" modeSel.addEventListener('change', sync);"
|
||||
' sync();'
|
||||
'})();'
|
||||
'</script>'
|
||||
)
|
||||
# Form (sx_content_val populates #sx-content-input; JS reads from there)
|
||||
parts.append(sx_call("blog-editor-edit-form",
|
||||
csrf=csrf,
|
||||
updated_at=str(updated_at),
|
||||
title_val=title_val,
|
||||
excerpt_val=excerpt_val,
|
||||
feature_image=feature_image,
|
||||
feature_image_caption=feature_image_caption,
|
||||
sx_content_val=sx_content,
|
||||
lexical_json=lexical_json,
|
||||
has_sx=has_sx,
|
||||
title_placeholder=title_placeholder,
|
||||
status=status,
|
||||
already_emailed=already_emailed,
|
||||
newsletter_options=nl_opts_sx,
|
||||
footer_extra=footer_extra_sx,
|
||||
))
|
||||
|
||||
# Editor CSS + styles + SX editor styles
|
||||
from shared.sx.helpers import sx_call
|
||||
parts.append(f'<link rel="stylesheet" href="{esc(editor_css)}">')
|
||||
# Publish-mode JS
|
||||
parts.append(sx_call("blog-editor-publish-js", already_emailed=already_emailed))
|
||||
|
||||
# Editor CSS + styles
|
||||
parts.append(sx_call("blog-editor-styles", css_href=editor_css))
|
||||
parts.append(sx_call("sx-editor-styles"))
|
||||
parts.append(
|
||||
'<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; }'
|
||||
'</style>'
|
||||
)
|
||||
|
||||
# Initial sx content for SX editor
|
||||
sx_initial_escaped = sx_content.replace('\\', '\\\\').replace("'", "\\'").replace('\n', '\\n').replace('\r', '')
|
||||
parts.append(f"<script>window.__SX_INITIAL__ = '{sx_initial_escaped}' || null;</script>")
|
||||
|
||||
# Editor JS + SX editor JS + init
|
||||
parts.append(f'<script src="{esc(editor_js)}"></script>')
|
||||
parts.append(f'<script src="{esc(sx_editor_js)}"></script>')
|
||||
parts.append(
|
||||
'<script>'
|
||||
# Editor JS + init
|
||||
init_js = (
|
||||
'(function() {'
|
||||
# Font size overrides for Koenig
|
||||
" function applyEditorFontSize() {"
|
||||
" document.documentElement.style.fontSize = '62.5%';"
|
||||
" document.body.style.fontSize = '1.6rem';"
|
||||
@@ -1956,7 +1857,7 @@ def _post_edit_content_sx(ctx: dict) -> str:
|
||||
' });'
|
||||
" if (typeof SxEditor !== 'undefined') {"
|
||||
" SxEditor.mount('sx-editor', {"
|
||||
" initialSx: window.__SX_INITIAL__ || null,"
|
||||
" initialSx: (document.getElementById('sx-content-input') || {}).value || null,"
|
||||
' csrfToken: csrfToken,'
|
||||
' uploadUrls: uploadUrls,'
|
||||
f" oembedUrl: '{oembed_url}',"
|
||||
@@ -1976,10 +1877,13 @@ def _post_edit_content_sx(ctx: dict) -> str:
|
||||
" if (typeof window.mountEditor === 'function') { clearInterval(_t); init(); }"
|
||||
' }, 50); }'
|
||||
'})();'
|
||||
'</script>'
|
||||
)
|
||||
parts.append(sx_call("blog-editor-scripts",
|
||||
js_src=editor_js,
|
||||
sx_editor_js_src=sx_editor_js,
|
||||
init_js=init_js))
|
||||
|
||||
return _raw_html_sx("".join(parts))
|
||||
return "(<> " + " ".join(parts) + ")"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
@@ -358,3 +358,41 @@ Each service migrates independently, no coordination needed:
|
||||
3. Enable SSR for bots (Phase 2) — per-page opt-in
|
||||
4. Client data primitives (Phase 4) — global once sx.js updated
|
||||
5. Data-only navigation (Phase 5) — automatic for any `defpage` route
|
||||
|
||||
---
|
||||
|
||||
## Why: Architectural Rationale
|
||||
|
||||
The end state is: **sx.js is the only JavaScript in the browser.** All application code — components, pages, routing, event handling, data fetching — is expressed in sx, evaluated by the interpreter, with behavior mediated through bound primitives.
|
||||
|
||||
### Benefits
|
||||
|
||||
**Single language everywhere.** Components, pages, routing, event handling, data fetching — all sx. No context-switching between JS idioms and template syntax. One language for the entire frontend and the server rendering path.
|
||||
|
||||
**Portability.** The same source runs on any VM that implements the ~50-primitive interface. Today: Python + JS. Tomorrow: WASM, edge workers, native mobile, embedded devices. Coupled to a primitive contract, not to a specific runtime.
|
||||
|
||||
**Smaller wire transfer.** S-expressions are terser than equivalent JS. Combined with content-addressed caching (hash/localStorage), most navigations transfer zero code — just data.
|
||||
|
||||
**Inspectability.** The sx source is the running program — no build step, no source maps, no minification. View source shows exactly what executes. The AST is the structure the evaluator walks. Debugging is tracing a tree.
|
||||
|
||||
**Controlled surface area.** The only JS that runs is sx.js. Everything else is mediated through defined primitives. No npm supply chain. No third-party scripts with ambient DOM access. Components can only do what primitives allow — the capability surface is fully controlled.
|
||||
|
||||
**Hot-reloadable everything.** Components are data (cached AST). Swapping a definition is replacing a dict entry. No module system, no import graph, no HMR machinery. Already works for .sx file changes in dev mode — extends to behaviors too.
|
||||
|
||||
**AI-friendly.** S-expressions are trivially parseable and generatable. An LLM produces correct sx far more reliably than JS/JSX — fewer syntax edge cases, no semicolons/braces/arrow-function ambiguities. The codebase becomes more amenable to automated generation and transformation.
|
||||
|
||||
**Security boundary.** No `eval()`, no dynamic `<script>` injection, no prototype pollution. The sx evaluator is a sandbox — it only resolves symbols against the primitive table and component env. Auditing what any sx expression can do means auditing the primitive bindings.
|
||||
|
||||
### Performance and WASM
|
||||
|
||||
The tradeoff is interpreter overhead — a tree-walking interpreter is slower than native JS execution. For UI rendering (building DOM, handling events, fetching data), this is not the bottleneck — DOM operations dominate, and those are the same speed regardless of initiator.
|
||||
|
||||
If performance ever becomes a concern, WASM is the escape hatch at three levels:
|
||||
|
||||
1. **Evaluator in WASM.** Rewrite `sxEval` in Rust/Zig → WASM. The tight inner loop (symbol lookup, env traversal, function application) runs ~10-50x faster. DOM rendering stays in JS (it calls browser APIs regardless).
|
||||
|
||||
2. **Compile sx to WASM.** Ahead-of-time compiler: `.sx` → WASM modules. Each `defcomp` becomes a WASM function returning DOM instructions. Eliminates the interpreter entirely. The content-addressed cache stores compiled WASM blobs instead of sx source.
|
||||
|
||||
3. **Compute-heavy primitives in WASM.** Keep the sx interpreter in JS, bind specific primitives to WASM (image processing, crypto, data transformation). Most pragmatic and least disruptive — additive, no architecture change.
|
||||
|
||||
The primitive-binding model means the evaluator doesn't care what's behind a primitive. `(blur-image data radius)` could be a JS Canvas call today and a WASM JAX kernel tomorrow. The sx source doesn't change.
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
{% extends "_types/social/index.html" %}
|
||||
{% block title %}@{{ actor.preferred_username }} — Rose Ash{% endblock %}
|
||||
{% block social_content %}
|
||||
<div class="py-8">
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h1 class="text-2xl font-bold">{{ actor.display_name or actor.preferred_username }}</h1>
|
||||
<p class="text-stone-500">@{{ actor.preferred_username }}@{{ config.get('ap_domain', 'rose-ash.com') }}</p>
|
||||
{% if actor.summary %}
|
||||
<p class="mt-2">{{ actor.summary }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-bold mb-4">Activities ({{ total }})</h2>
|
||||
{% if activities %}
|
||||
<div class="space-y-4">
|
||||
{% for a in activities %}
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<span class="font-medium">{{ a.activity_type }}</span>
|
||||
<span class="text-sm text-stone-400">{{ a.published.strftime('%Y-%m-%d %H:%M') if a.published }}</span>
|
||||
</div>
|
||||
{% if a.object_type %}
|
||||
<span class="text-sm text-stone-500">{{ a.object_type }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-stone-500">No activities yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,42 +0,0 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% call links.menu_row(id='social-lite-row', oob=oob) %}
|
||||
<div class="w-full flex flex-row items-center gap-2 flex-wrap">
|
||||
{% if actor %}
|
||||
<nav class="flex gap-3 text-sm items-center flex-wrap">
|
||||
<a href="{{ url_for('ap_social.search') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.search') %}font-bold{% endif %}">
|
||||
Search
|
||||
</a>
|
||||
<a href="{{ url_for('ap_social.following_list') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.following_list') %}font-bold{% endif %}">
|
||||
Following
|
||||
</a>
|
||||
<a href="{{ url_for('ap_social.followers_list') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.followers_list') %}font-bold{% endif %}">
|
||||
Followers
|
||||
</a>
|
||||
<a href="{{ url_for('activitypub.actor_profile', username=actor.preferred_username) }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200">
|
||||
@{{ actor.preferred_username }}
|
||||
</a>
|
||||
<a href="{{ federation_url('/social/') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 text-stone-500">
|
||||
Hub
|
||||
</a>
|
||||
</nav>
|
||||
{% else %}
|
||||
<nav class="flex gap-3 text-sm items-center">
|
||||
<a href="{{ url_for('ap_social.search') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 {% if request.path == url_for('ap_social.search') %}font-bold{% endif %}">
|
||||
Search
|
||||
</a>
|
||||
<a href="{{ federation_url('/social/') }}"
|
||||
class="px-2 py-1 rounded hover:bg-stone-200 text-stone-500">
|
||||
Hub
|
||||
</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endmacro %}
|
||||
@@ -1,10 +0,0 @@
|
||||
{% extends '_types/root/_index.html' %}
|
||||
{% block meta %}{% endblock %}
|
||||
{% block root_header_child %}
|
||||
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||
{% call index_row('social-lite-header-child', '_types/social_lite/header/_header.html') %}
|
||||
{% endcall %}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{% block social_content %}{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -1,63 +0,0 @@
|
||||
{% for a in actors %}
|
||||
<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4"
|
||||
id="actor-{{ a.actor_url | replace('/', '_') | replace(':', '_') }}">
|
||||
{% if a.icon_url %}
|
||||
<img src="{{ a.icon_url }}" alt="" class="w-12 h-12 rounded-full">
|
||||
{% else %}
|
||||
<div class="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold">
|
||||
{{ (a.display_name or a.preferred_username)[0] | upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
{% if list_type == "following" and a.id %}
|
||||
<a href="{{ url_for('ap_social.actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline">
|
||||
{{ a.display_name or a.preferred_username }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="https://{{ a.domain }}/@{{ a.preferred_username }}" target="_blank" rel="noopener" class="font-semibold text-stone-900 hover:underline">
|
||||
{{ a.display_name or a.preferred_username }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="text-sm text-stone-500">@{{ a.preferred_username }}@{{ a.domain }}</div>
|
||||
{% if a.summary %}
|
||||
<div class="text-sm text-stone-600 mt-1 truncate">{{ a.summary | striptags }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if actor %}
|
||||
<div class="flex-shrink-0">
|
||||
{% if list_type == "following" or a.actor_url in (followed_urls or []) %}
|
||||
<form method="post" action="{{ url_for('ap_social.unfollow') }}"
|
||||
sx-post="{{ url_for('ap_social.unfollow') }}"
|
||||
sx-target="closest article"
|
||||
sx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
|
||||
<button type="submit" class="text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100">
|
||||
Unfollow
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('ap_social.follow') }}"
|
||||
sx-post="{{ url_for('ap_social.follow') }}"
|
||||
sx-target="closest article"
|
||||
sx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
|
||||
<button type="submit" class="text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700">
|
||||
Follow Back
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
{% if actors | length >= 20 %}
|
||||
<div sx-get="{{ url_for('ap_social.' ~ list_type ~ '_list_page', page=page + 1) }}"
|
||||
sx-trigger="revealed"
|
||||
sx-swap="outerHTML">
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,53 +0,0 @@
|
||||
<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4">
|
||||
{% if item.boosted_by %}
|
||||
<div class="text-sm text-stone-500 mb-2">
|
||||
Boosted by {{ item.boosted_by }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
{% if item.actor_icon %}
|
||||
<img src="{{ item.actor_icon }}" alt="" class="w-10 h-10 rounded-full">
|
||||
{% else %}
|
||||
<div class="w-10 h-10 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-sm">
|
||||
{{ item.actor_name[0] | upper if item.actor_name else '?' }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="font-semibold text-stone-900">{{ item.actor_name }}</span>
|
||||
<span class="text-sm text-stone-500">
|
||||
@{{ item.actor_username }}{% if item.actor_domain %}@{{ item.actor_domain }}{% endif %}
|
||||
</span>
|
||||
<span class="text-sm text-stone-400 ml-auto">
|
||||
{% if item.published %}
|
||||
{{ item.published.strftime('%b %d, %H:%M') }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if item.summary %}
|
||||
<details class="mt-2">
|
||||
<summary class="text-stone-500 cursor-pointer">CW: {{ item.summary }}</summary>
|
||||
<div class="mt-2 prose prose-sm prose-stone max-w-none">{{ item.content | safe }}</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<div class="mt-2 prose prose-sm prose-stone max-w-none">{{ item.content | safe }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-2 flex gap-3 text-sm text-stone-400">
|
||||
{% if item.url and item.post_type == "remote" %}
|
||||
<a href="{{ item.url }}" target="_blank" rel="noopener" class="hover:underline">
|
||||
original
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if item.object_id %}
|
||||
<a href="{{ federation_url('/social/') }}" class="hover:underline">
|
||||
View on Hub
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -1,61 +0,0 @@
|
||||
{% for a in actors %}
|
||||
<article class="bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-3 flex items-center gap-4"
|
||||
id="actor-{{ a.actor_url | replace('/', '_') | replace(':', '_') }}">
|
||||
{% if a.icon_url %}
|
||||
<img src="{{ a.icon_url }}" alt="" class="w-12 h-12 rounded-full">
|
||||
{% else %}
|
||||
<div class="w-12 h-12 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold">
|
||||
{{ (a.display_name or a.preferred_username)[0] | upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
{% if a.id %}
|
||||
<a href="{{ url_for('ap_social.actor_timeline', id=a.id) }}" class="font-semibold text-stone-900 hover:underline">
|
||||
{{ a.display_name or a.preferred_username }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="font-semibold text-stone-900">{{ a.display_name or a.preferred_username }}</span>
|
||||
{% endif %}
|
||||
<div class="text-sm text-stone-500">@{{ a.preferred_username }}@{{ a.domain }}</div>
|
||||
{% if a.summary %}
|
||||
<div class="text-sm text-stone-600 mt-1 truncate">{{ a.summary | striptags }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if actor %}
|
||||
<div class="flex-shrink-0">
|
||||
{% if a.actor_url in (followed_urls or []) %}
|
||||
<form method="post" action="{{ url_for('ap_social.unfollow') }}"
|
||||
sx-post="{{ url_for('ap_social.unfollow') }}"
|
||||
sx-target="closest article"
|
||||
sx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
|
||||
<button type="submit" class="text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100">
|
||||
Unfollow
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('ap_social.follow') }}"
|
||||
sx-post="{{ url_for('ap_social.follow') }}"
|
||||
sx-target="closest article"
|
||||
sx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="actor_url" value="{{ a.actor_url }}">
|
||||
<button type="submit" class="text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700">
|
||||
Follow
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
{% if actors | length >= 20 %}
|
||||
<div sx-get="{{ url_for('ap_social.search_page', q=query, page=page + 1) }}"
|
||||
sx-trigger="revealed"
|
||||
sx-swap="outerHTML">
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,13 +0,0 @@
|
||||
{% for item in items %}
|
||||
{% include "social/_post_card.html" %}
|
||||
{% endfor %}
|
||||
|
||||
{% if items %}
|
||||
{% set last = items[-1] %}
|
||||
{% if timeline_type == "actor" %}
|
||||
<div sx-get="{{ url_for('ap_social.actor_timeline_page', id=actor_id, before=last.published.isoformat()) }}"
|
||||
sx-trigger="revealed"
|
||||
sx-swap="outerHTML">
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -1,53 +0,0 @@
|
||||
{% extends "_types/social_lite/index.html" %}
|
||||
|
||||
{% block title %}{{ remote_actor.display_name or remote_actor.preferred_username }} — Rose Ash{% endblock %}
|
||||
|
||||
{% block social_content %}
|
||||
<div class="bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
{% if remote_actor.icon_url %}
|
||||
<img src="{{ remote_actor.icon_url }}" alt="" class="w-16 h-16 rounded-full">
|
||||
{% else %}
|
||||
<div class="w-16 h-16 rounded-full bg-stone-300 flex items-center justify-center text-stone-600 font-bold text-xl">
|
||||
{{ (remote_actor.display_name or remote_actor.preferred_username)[0] | upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex-1">
|
||||
<h1 class="text-xl font-bold">{{ remote_actor.display_name or remote_actor.preferred_username }}</h1>
|
||||
<div class="text-stone-500">@{{ remote_actor.preferred_username }}@{{ remote_actor.domain }}</div>
|
||||
{% if remote_actor.summary %}
|
||||
<div class="text-sm text-stone-600 mt-2">{{ remote_actor.summary | safe }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if actor %}
|
||||
<div class="flex-shrink-0">
|
||||
{% if is_following %}
|
||||
<form method="post" action="{{ url_for('ap_social.unfollow') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="actor_url" value="{{ remote_actor.actor_url }}">
|
||||
<button type="submit" class="border border-stone-300 rounded px-4 py-2 hover:bg-stone-100">
|
||||
Unfollow
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('ap_social.follow') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="actor_url" value="{{ remote_actor.actor_url }}">
|
||||
<button type="submit" class="bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700">
|
||||
Follow
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="timeline">
|
||||
{% set timeline_type = "actor" %}
|
||||
{% set actor_id = remote_actor.id %}
|
||||
{% include "social/_timeline_items.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,12 +0,0 @@
|
||||
{% extends "_types/social_lite/index.html" %}
|
||||
|
||||
{% block title %}Followers — Rose Ash{% endblock %}
|
||||
|
||||
{% block social_content %}
|
||||
<h1 class="text-2xl font-bold mb-6">Followers <span class="text-stone-400 font-normal">({{ total }})</span></h1>
|
||||
|
||||
<div id="actor-list">
|
||||
{% set list_type = "followers" %}
|
||||
{% include "social/_actor_list_items.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,13 +0,0 @@
|
||||
{% extends "_types/social_lite/index.html" %}
|
||||
|
||||
{% block title %}Following — Rose Ash{% endblock %}
|
||||
|
||||
{% block social_content %}
|
||||
<h1 class="text-2xl font-bold mb-6">Following <span class="text-stone-400 font-normal">({{ total }})</span></h1>
|
||||
|
||||
<div id="actor-list">
|
||||
{% set list_type = "following" %}
|
||||
{% set followed_urls = [] %}
|
||||
{% include "social/_actor_list_items.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,33 +0,0 @@
|
||||
{% extends "_types/social_lite/index.html" %}
|
||||
|
||||
{% block title %}Social — Rose Ash{% endblock %}
|
||||
|
||||
{% block social_content %}
|
||||
<h1 class="text-2xl font-bold mb-6">Social</h1>
|
||||
|
||||
{% if actor %}
|
||||
<div class="space-y-3">
|
||||
<a href="{{ url_for('ap_social.search') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
|
||||
<div class="font-semibold">Search</div>
|
||||
<div class="text-sm text-stone-500">Find and follow accounts on the fediverse</div>
|
||||
</a>
|
||||
<a href="{{ url_for('ap_social.following_list') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
|
||||
<div class="font-semibold">Following</div>
|
||||
<div class="text-sm text-stone-500">Accounts you follow</div>
|
||||
</a>
|
||||
<a href="{{ url_for('ap_social.followers_list') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
|
||||
<div class="font-semibold">Followers</div>
|
||||
<div class="text-sm text-stone-500">Accounts following you here</div>
|
||||
</a>
|
||||
<a href="{{ federation_url('/social/') }}" class="block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50">
|
||||
<div class="font-semibold">Hub</div>
|
||||
<div class="text-sm text-stone-500">Full social experience — timeline, compose, notifications</div>
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-stone-500">
|
||||
<a href="{{ url_for('ap_social.search') }}" class="underline">Search</a> for accounts on the fediverse, or visit the
|
||||
<a href="{{ federation_url('/social/') }}" class="underline">Hub</a> to get started.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,32 +0,0 @@
|
||||
{% extends "_types/social_lite/index.html" %}
|
||||
|
||||
{% block title %}Search — Rose Ash{% endblock %}
|
||||
|
||||
{% block social_content %}
|
||||
<h1 class="text-2xl font-bold mb-6">Search</h1>
|
||||
|
||||
<form method="get" action="{{ url_for('ap_social.search') }}" class="mb-6"
|
||||
sx-get="{{ url_for('ap_social.search_page') }}"
|
||||
sx-target="#search-results"
|
||||
sx-push-url="{{ url_for('ap_social.search') }}">
|
||||
<div class="flex gap-2">
|
||||
<input type="text" name="q" value="{{ query }}"
|
||||
class="flex-1 border border-stone-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-stone-500"
|
||||
placeholder="Search users or @user@instance.tld">
|
||||
<button type="submit"
|
||||
class="bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700">
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if query and total %}
|
||||
<p class="text-sm text-stone-500 mb-4">{{ total }} result{{ 's' if total != 1 }} for <strong>{{ query }}</strong></p>
|
||||
{% elif query %}
|
||||
<p class="text-stone-500 mb-4">No results found for <strong>{{ query }}</strong></p>
|
||||
{% endif %}
|
||||
|
||||
<div id="search-results">
|
||||
{% include "social/_search_results.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -57,6 +57,77 @@ def _is_aggregate(app_name: str) -> bool:
|
||||
return app_name == "federation"
|
||||
|
||||
|
||||
async def _render_profile_sx(actor, activities, total):
|
||||
"""Render the federation actor profile page using SX."""
|
||||
from markupsafe import escape
|
||||
from shared.sx.page import get_template_context
|
||||
from shared.sx.helpers import full_page_sx, oob_page_sx, sx_response
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.config import config
|
||||
|
||||
def _e(v):
|
||||
s = str(v) if v else ""
|
||||
return str(escape(s)).replace('"', '\\"')
|
||||
|
||||
username = _e(actor.preferred_username)
|
||||
display_name = _e(actor.display_name or actor.preferred_username)
|
||||
ap_domain = config().get("ap_domain", "rose-ash.com")
|
||||
|
||||
summary_el = ""
|
||||
if actor.summary:
|
||||
summary_el = f'(p :class "mt-2" "{_e(actor.summary)}")'
|
||||
|
||||
activity_items = []
|
||||
for a in activities:
|
||||
ts = ""
|
||||
if a.published:
|
||||
ts = a.published.strftime("%Y-%m-%d %H:%M")
|
||||
obj_el = ""
|
||||
if a.object_type:
|
||||
obj_el = f'(span :class "text-sm text-stone-500" "{_e(a.object_type)}")'
|
||||
activity_items.append(
|
||||
f'(div :class "bg-white rounded-lg shadow p-4"'
|
||||
f' (div :class "flex justify-between items-start"'
|
||||
f' (span :class "font-medium" "{_e(a.activity_type)}")'
|
||||
f' (span :class "text-sm text-stone-400" "{_e(ts)}"))'
|
||||
f' {obj_el})')
|
||||
|
||||
if activities:
|
||||
activities_el = ('(div :class "space-y-4" ' +
|
||||
" ".join(activity_items) + ")")
|
||||
else:
|
||||
activities_el = '(p :class "text-stone-500" "No activities yet.")'
|
||||
|
||||
content = (
|
||||
f'(div :id "main-panel"'
|
||||
f' (div :class "py-8"'
|
||||
f' (div :class "bg-white rounded-lg shadow p-6 mb-6"'
|
||||
f' (h1 :class "text-2xl font-bold" "{display_name}")'
|
||||
f' (p :class "text-stone-500" "@{username}@{_e(ap_domain)}")'
|
||||
f' {summary_el})'
|
||||
f' (h2 :class "text-xl font-bold mb-4" "Activities ({total})")'
|
||||
f' {activities_el}))')
|
||||
|
||||
tctx = await get_template_context()
|
||||
|
||||
if is_htmx_request():
|
||||
# Import federation layout for OOB headers
|
||||
try:
|
||||
from federation.sxc.pages import _social_oob
|
||||
oob_headers = _social_oob(tctx)
|
||||
except ImportError:
|
||||
oob_headers = ""
|
||||
return sx_response(oob_page_sx(oobs=oob_headers, content=content))
|
||||
else:
|
||||
try:
|
||||
from federation.sxc.pages import _social_full
|
||||
header_rows = _social_full(tctx)
|
||||
except ImportError:
|
||||
from shared.sx.helpers import root_header_sx
|
||||
header_rows = root_header_sx(tctx)
|
||||
return full_page_sx(tctx, header_rows=header_rows, content=content)
|
||||
|
||||
|
||||
def create_activitypub_blueprint(app_name: str) -> Blueprint:
|
||||
"""Return a Blueprint with AP endpoints for *app_name*."""
|
||||
bp = Blueprint("activitypub", __name__)
|
||||
@@ -272,16 +343,10 @@ def create_activitypub_blueprint(app_name: str) -> Blueprint:
|
||||
|
||||
# HTML: federation renders its own profile; other apps redirect there
|
||||
if aggregate:
|
||||
from quart import render_template
|
||||
activities, total = await services.federation.get_outbox(
|
||||
g._ap_s, username, page=1, per_page=20,
|
||||
)
|
||||
return await render_template(
|
||||
"federation/profile.html",
|
||||
actor=actor,
|
||||
activities=activities,
|
||||
total=total,
|
||||
)
|
||||
return await _render_profile_sx(actor, activities, total)
|
||||
from quart import redirect
|
||||
return redirect(f"https://{fed_domain}/users/{username}")
|
||||
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
|
||||
Lightweight social UI for blog/market/events. Federation keeps the full
|
||||
social hub (timeline, compose, notifications, interactions).
|
||||
|
||||
All rendering uses s-expressions (no Jinja templates).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from quart import Blueprint, request, g, redirect, url_for, abort, render_template, Response
|
||||
from quart import Blueprint, request, g, redirect, url_for, abort, Response
|
||||
|
||||
from shared.services.registry import services
|
||||
|
||||
@@ -77,15 +79,36 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
abort(403, "You need to choose a federation username first")
|
||||
return actor
|
||||
|
||||
async def _render_social_page(content: str, actor=None, title: str = "Social"):
|
||||
"""Render a full social page or OOB response depending on request type."""
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sx.page import get_template_context
|
||||
from shared.sx.helpers import full_page_sx, oob_page_sx, sx_response
|
||||
from shared.infrastructure.ap_social_sx import (
|
||||
_social_full_headers, _social_oob_headers,
|
||||
)
|
||||
|
||||
tctx = await get_template_context()
|
||||
kw = {"actor": actor}
|
||||
|
||||
if is_htmx_request():
|
||||
oob_headers = _social_oob_headers(tctx, **kw)
|
||||
return sx_response(oob_page_sx(
|
||||
oobs=oob_headers,
|
||||
content=content,
|
||||
))
|
||||
else:
|
||||
header_rows = _social_full_headers(tctx, **kw)
|
||||
return full_page_sx(tctx, header_rows=header_rows, content=content)
|
||||
|
||||
# -- Index ----------------------------------------------------------------
|
||||
|
||||
@bp.get("/")
|
||||
async def index():
|
||||
actor = getattr(g, "_social_actor", None)
|
||||
return await render_template(
|
||||
"social/index.html",
|
||||
actor=actor,
|
||||
)
|
||||
from shared.infrastructure.ap_social_sx import social_index_content_sx
|
||||
content = social_index_content_sx(actor)
|
||||
return await _render_social_page(content, actor, title="Social")
|
||||
|
||||
# -- Search ---------------------------------------------------------------
|
||||
|
||||
@@ -103,15 +126,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
g._ap_s, actor.preferred_username, page=1, per_page=1000,
|
||||
)
|
||||
followed_urls = {a.actor_url for a in following}
|
||||
return await render_template(
|
||||
"social/search.html",
|
||||
query=query,
|
||||
actors=actors,
|
||||
total=total,
|
||||
page=1,
|
||||
followed_urls=followed_urls,
|
||||
actor=actor,
|
||||
)
|
||||
from shared.infrastructure.ap_social_sx import social_search_content_sx
|
||||
content = social_search_content_sx(query, actors, total, 1, followed_urls, actor)
|
||||
return await _render_social_page(content, actor, title="Search")
|
||||
|
||||
@bp.get("/search/page")
|
||||
async def search_page():
|
||||
@@ -130,15 +147,10 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
g._ap_s, actor.preferred_username, page=1, per_page=1000,
|
||||
)
|
||||
followed_urls = {a.actor_url for a in following}
|
||||
return await render_template(
|
||||
"social/_search_results.html",
|
||||
actors=actors,
|
||||
total=total,
|
||||
page=page,
|
||||
query=query,
|
||||
followed_urls=followed_urls,
|
||||
actor=actor,
|
||||
)
|
||||
from shared.infrastructure.ap_social_sx import search_results_sx
|
||||
from shared.sx.helpers import sx_response
|
||||
content = search_results_sx(actors, total, page, query, followed_urls, actor)
|
||||
return sx_response(content)
|
||||
|
||||
# -- Follow / Unfollow ----------------------------------------------------
|
||||
|
||||
@@ -169,7 +181,7 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
return redirect(request.referrer or url_for("ap_social.search"))
|
||||
|
||||
async def _actor_card_response(actor, remote_actor_url, is_followed):
|
||||
"""Re-render a single actor card after follow/unfollow via HTMX."""
|
||||
"""Re-render a single actor card after follow/unfollow."""
|
||||
remote_dto = await services.federation.get_or_fetch_remote_actor(
|
||||
g._ap_s, remote_actor_url,
|
||||
)
|
||||
@@ -181,15 +193,12 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
list_type = "followers"
|
||||
else:
|
||||
list_type = "following"
|
||||
return await render_template(
|
||||
"social/_actor_list_items.html",
|
||||
actors=[remote_dto],
|
||||
total=0,
|
||||
page=1,
|
||||
list_type=list_type,
|
||||
followed_urls=followed_urls,
|
||||
actor=actor,
|
||||
from shared.infrastructure.ap_social_sx import actor_list_items_sx
|
||||
from shared.sx.helpers import sx_response
|
||||
content = actor_list_items_sx(
|
||||
[remote_dto], 0, 1, list_type, followed_urls, actor,
|
||||
)
|
||||
return sx_response(content)
|
||||
|
||||
# -- Followers ------------------------------------------------------------
|
||||
|
||||
@@ -203,14 +212,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
g._ap_s, actor.preferred_username, page=1, per_page=1000,
|
||||
)
|
||||
followed_urls = {a.actor_url for a in following}
|
||||
return await render_template(
|
||||
"social/followers.html",
|
||||
actors=actors,
|
||||
total=total,
|
||||
page=1,
|
||||
followed_urls=followed_urls,
|
||||
actor=actor,
|
||||
)
|
||||
from shared.infrastructure.ap_social_sx import social_followers_content_sx
|
||||
content = social_followers_content_sx(actors, total, 1, followed_urls, actor)
|
||||
return await _render_social_page(content, actor, title="Followers")
|
||||
|
||||
@bp.get("/followers/page")
|
||||
async def followers_list_page():
|
||||
@@ -223,15 +227,10 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
g._ap_s, actor.preferred_username, page=1, per_page=1000,
|
||||
)
|
||||
followed_urls = {a.actor_url for a in following}
|
||||
return await render_template(
|
||||
"social/_actor_list_items.html",
|
||||
actors=actors,
|
||||
total=total,
|
||||
page=page,
|
||||
list_type="followers",
|
||||
followed_urls=followed_urls,
|
||||
actor=actor,
|
||||
)
|
||||
from shared.infrastructure.ap_social_sx import actor_list_items_sx
|
||||
from shared.sx.helpers import sx_response
|
||||
content = actor_list_items_sx(actors, total, page, "followers", followed_urls, actor)
|
||||
return sx_response(content)
|
||||
|
||||
# -- Following ------------------------------------------------------------
|
||||
|
||||
@@ -241,13 +240,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
actors, total = await services.federation.get_following(
|
||||
g._ap_s, actor.preferred_username,
|
||||
)
|
||||
return await render_template(
|
||||
"social/following.html",
|
||||
actors=actors,
|
||||
total=total,
|
||||
page=1,
|
||||
actor=actor,
|
||||
)
|
||||
from shared.infrastructure.ap_social_sx import social_following_content_sx
|
||||
content = social_following_content_sx(actors, total, 1, actor)
|
||||
return await _render_social_page(content, actor, title="Following")
|
||||
|
||||
@bp.get("/following/page")
|
||||
async def following_list_page():
|
||||
@@ -256,15 +251,10 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
actors, total = await services.federation.get_following(
|
||||
g._ap_s, actor.preferred_username, page=page,
|
||||
)
|
||||
return await render_template(
|
||||
"social/_actor_list_items.html",
|
||||
actors=actors,
|
||||
total=total,
|
||||
page=page,
|
||||
list_type="following",
|
||||
followed_urls=set(),
|
||||
actor=actor,
|
||||
)
|
||||
from shared.infrastructure.ap_social_sx import actor_list_items_sx
|
||||
from shared.sx.helpers import sx_response
|
||||
content = actor_list_items_sx(actors, total, page, "following", set(), actor)
|
||||
return sx_response(content)
|
||||
|
||||
# -- Actor timeline -------------------------------------------------------
|
||||
|
||||
@@ -295,13 +285,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
is_following = existing is not None
|
||||
return await render_template(
|
||||
"social/actor_timeline.html",
|
||||
remote_actor=remote_dto,
|
||||
items=items,
|
||||
is_following=is_following,
|
||||
actor=actor,
|
||||
)
|
||||
from shared.infrastructure.ap_social_sx import social_actor_timeline_content_sx
|
||||
content = social_actor_timeline_content_sx(remote_dto, items, is_following, actor)
|
||||
return await _render_social_page(content, actor)
|
||||
|
||||
@bp.get("/actor/<int:id>/timeline")
|
||||
async def actor_timeline_page(id: int):
|
||||
@@ -316,12 +302,9 @@ def create_ap_social_blueprint(app_name: str) -> Blueprint:
|
||||
items = await services.federation.get_actor_timeline(
|
||||
g._ap_s, id, before=before,
|
||||
)
|
||||
return await render_template(
|
||||
"social/_timeline_items.html",
|
||||
items=items,
|
||||
timeline_type="actor",
|
||||
actor_id=id,
|
||||
actor=actor,
|
||||
)
|
||||
from shared.infrastructure.ap_social_sx import timeline_items_sx
|
||||
from shared.sx.helpers import sx_response
|
||||
content = timeline_items_sx(items, "actor", id, actor)
|
||||
return sx_response(content)
|
||||
|
||||
return bp
|
||||
|
||||
593
shared/infrastructure/ap_social_sx.py
Normal file
593
shared/infrastructure/ap_social_sx.py
Normal file
@@ -0,0 +1,593 @@
|
||||
"""SX content builders for the per-app AP social blueprint.
|
||||
|
||||
Builds s-expression source strings for all social pages, replacing
|
||||
the Jinja templates in shared/browser/templates/social/.
|
||||
|
||||
All dynamic values (URLs, CSRF tokens) are resolved server-side in Python
|
||||
and embedded as string literals — the SX is rendered client-side where
|
||||
server primitives like url-for and csrf-token are unavailable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from markupsafe import escape
|
||||
|
||||
from shared.sx.helpers import (
|
||||
sx_call, root_header_sx, oob_header_sx,
|
||||
mobile_menu_sx, mobile_root_nav_sx, full_page_sx, oob_page_sx,
|
||||
)
|
||||
from shared.sx.parser import SxExpr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layout — "social-lite": root header + social nav row
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def setup_social_layout() -> None:
|
||||
"""Register the social-lite layout. Called once during app startup."""
|
||||
from shared.sx.layouts import register_custom_layout
|
||||
register_custom_layout(
|
||||
"social-lite",
|
||||
_social_full_headers,
|
||||
_social_oob_headers,
|
||||
_social_mobile,
|
||||
)
|
||||
|
||||
|
||||
def _social_nav_items(actor: Any) -> str:
|
||||
"""Build the social nav items as sx source.
|
||||
|
||||
All URLs resolved server-side via Quart's url_for.
|
||||
"""
|
||||
from quart import url_for
|
||||
from shared.infrastructure.urls import app_url
|
||||
|
||||
search_url = _e(url_for("ap_social.search"))
|
||||
hub_url = _e(app_url("federation", "/social/"))
|
||||
|
||||
parts: list[str] = []
|
||||
if actor:
|
||||
following_url = _e(url_for("ap_social.following_list"))
|
||||
followers_url = _e(url_for("ap_social.followers_list"))
|
||||
username = _e(getattr(actor, "preferred_username", ""))
|
||||
try:
|
||||
profile_url = _e(url_for("activitypub.actor_profile",
|
||||
username=actor.preferred_username))
|
||||
except Exception:
|
||||
profile_url = ""
|
||||
|
||||
parts.append(f'(a :href "{search_url}"'
|
||||
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm" "Search")')
|
||||
parts.append(f'(a :href "{following_url}"'
|
||||
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm" "Following")')
|
||||
parts.append(f'(a :href "{followers_url}"'
|
||||
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm" "Followers")')
|
||||
if profile_url:
|
||||
parts.append(f'(a :href "{profile_url}"'
|
||||
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm"'
|
||||
f' "@{username}")')
|
||||
parts.append(f'(a :href "{hub_url}"'
|
||||
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm text-stone-500"'
|
||||
f' "Hub")')
|
||||
else:
|
||||
parts.append(f'(a :href "{search_url}"'
|
||||
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm" "Search")')
|
||||
parts.append(f'(a :href "{hub_url}"'
|
||||
f' :class "px-2 py-1 rounded hover:bg-stone-200 text-sm text-stone-500"'
|
||||
f' "Hub")')
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _social_header_row(actor: Any) -> str:
|
||||
"""Build the social nav header row as sx source."""
|
||||
nav = _social_nav_items(actor)
|
||||
return (
|
||||
f'(div :id "social-lite-header-child"'
|
||||
f' :class "flex flex-col items-center md:flex-row justify-center'
|
||||
f' md:justify-between w-full p-1 bg-stone-300"'
|
||||
f' (div :class "w-full flex flex-row items-center gap-2 flex-wrap"'
|
||||
f' (nav :class "flex gap-3 text-sm items-center flex-wrap" {nav})))'
|
||||
)
|
||||
|
||||
|
||||
def _social_full_headers(ctx: dict, **kw: Any) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
actor = kw.get("actor")
|
||||
social_row = _social_header_row(actor)
|
||||
return "(<> " + root_hdr + " " + social_row + ")"
|
||||
|
||||
|
||||
def _social_oob_headers(ctx: dict, **kw: Any) -> str:
|
||||
root_hdr = root_header_sx(ctx)
|
||||
actor = kw.get("actor")
|
||||
social_row = _social_header_row(actor)
|
||||
rows = "(<> " + root_hdr + " " + social_row + ")"
|
||||
return oob_header_sx("root-header-child", "social-lite-header-child", rows)
|
||||
|
||||
|
||||
def _social_mobile(ctx: dict, **kw: Any) -> str:
|
||||
return mobile_menu_sx(mobile_root_nav_sx(ctx))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _e(val: Any) -> str:
|
||||
"""Escape a value for safe embedding in sx source strings."""
|
||||
s = str(val) if val else ""
|
||||
return str(escape(s)).replace('"', '\\"')
|
||||
|
||||
|
||||
def _esc_raw(html: str) -> str:
|
||||
"""Escape raw HTML for embedding as a string literal in sx.
|
||||
|
||||
The string will be passed to (raw! ...) so it should NOT be HTML-escaped,
|
||||
only the sx string delimiters need escaping.
|
||||
"""
|
||||
return html.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def _actor_initial(a: Any) -> str:
|
||||
"""Get the uppercase first character of an actor's display name or username."""
|
||||
name = _actor_name(a)
|
||||
return name[0].upper() if name else "?"
|
||||
|
||||
|
||||
def _actor_name(a: Any) -> str:
|
||||
"""Get display name or preferred username from an actor (DTO or dict)."""
|
||||
if isinstance(a, dict):
|
||||
return a.get("display_name") or a.get("preferred_username") or ""
|
||||
return getattr(a, "display_name", None) or getattr(a, "preferred_username", "") or ""
|
||||
|
||||
|
||||
def _attr(a: Any, key: str, default: str = "") -> Any:
|
||||
"""Get attribute from DTO or dict."""
|
||||
if isinstance(a, dict):
|
||||
return a.get(key, default)
|
||||
return getattr(a, key, default)
|
||||
|
||||
|
||||
def _strip_tags(s: str) -> str:
|
||||
import re
|
||||
return re.sub(r"<[^>]+>", "", s)
|
||||
|
||||
|
||||
def _csrf() -> str:
|
||||
"""Get the CSRF token as a string."""
|
||||
from quart import current_app
|
||||
fn = current_app.jinja_env.globals.get("csrf_token")
|
||||
if callable(fn):
|
||||
return str(fn())
|
||||
return ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Actor card — used in search results, followers, following
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _actor_card_sx(a: Any, followed_urls: set, actor: Any,
|
||||
list_type: str = "search") -> str:
|
||||
"""Build sx source for a single actor card."""
|
||||
from quart import url_for
|
||||
|
||||
actor_url = _attr(a, "actor_url", "")
|
||||
safe_id = actor_url.replace("/", "_").replace(":", "_")
|
||||
icon_url = _attr(a, "icon_url", "")
|
||||
display_name = _actor_name(a)
|
||||
username = _attr(a, "preferred_username", "")
|
||||
domain = _attr(a, "domain", "")
|
||||
summary = _attr(a, "summary", "")
|
||||
actor_id = _attr(a, "id")
|
||||
csrf = _e(_csrf())
|
||||
|
||||
# Avatar
|
||||
if icon_url:
|
||||
avatar = f'(img :src "{_e(icon_url)}" :alt "" :class "w-12 h-12 rounded-full")'
|
||||
else:
|
||||
initial = _actor_initial(a)
|
||||
avatar = (f'(div :class "w-12 h-12 rounded-full bg-stone-300 flex items-center'
|
||||
f' justify-center text-stone-600 font-bold" "{initial}")')
|
||||
|
||||
# Name link
|
||||
if (list_type in ("following", "search")) and actor_id:
|
||||
tl_url = _e(url_for("ap_social.actor_timeline", id=actor_id))
|
||||
name_el = (f'(a :href "{tl_url}"'
|
||||
f' :class "font-semibold text-stone-900 hover:underline"'
|
||||
f' "{_e(display_name)}")')
|
||||
else:
|
||||
name_el = (f'(a :href "https://{_e(domain)}/@{_e(username)}"'
|
||||
f' :target "_blank" :rel "noopener"'
|
||||
f' :class "font-semibold text-stone-900 hover:underline"'
|
||||
f' "{_e(display_name)}")')
|
||||
|
||||
handle = f'(div :class "text-sm text-stone-500" "@{_e(username)}@{_e(domain)}")'
|
||||
|
||||
# Summary
|
||||
summary_el = ""
|
||||
if summary:
|
||||
clean = _strip_tags(summary)
|
||||
summary_el = (f'(div :class "text-sm text-stone-600 mt-1 truncate"'
|
||||
f' "{_e(clean)}")')
|
||||
|
||||
# Follow/unfollow button
|
||||
button_el = ""
|
||||
if actor:
|
||||
is_followed = (list_type == "following" or actor_url in (followed_urls or set()))
|
||||
if is_followed:
|
||||
unfollow_url = _e(url_for("ap_social.unfollow"))
|
||||
button_el = (
|
||||
f'(div :class "flex-shrink-0"'
|
||||
f' (form :method "post" :action "{unfollow_url}"'
|
||||
f' :sx-post "{unfollow_url}"'
|
||||
f' :sx-target "closest article" :sx-swap "outerHTML"'
|
||||
f' (input :type "hidden" :name "csrf_token" :value "{csrf}")'
|
||||
f' (input :type "hidden" :name "actor_url" :value "{_e(actor_url)}")'
|
||||
f' (button :type "submit"'
|
||||
f' :class "text-sm border border-stone-300 rounded px-3 py-1 hover:bg-stone-100"'
|
||||
f' "Unfollow")))')
|
||||
else:
|
||||
follow_url = _e(url_for("ap_social.follow"))
|
||||
label = "Follow Back" if list_type == "followers" else "Follow"
|
||||
button_el = (
|
||||
f'(div :class "flex-shrink-0"'
|
||||
f' (form :method "post" :action "{follow_url}"'
|
||||
f' :sx-post "{follow_url}"'
|
||||
f' :sx-target "closest article" :sx-swap "outerHTML"'
|
||||
f' (input :type "hidden" :name "csrf_token" :value "{csrf}")'
|
||||
f' (input :type "hidden" :name "actor_url" :value "{_e(actor_url)}")'
|
||||
f' (button :type "submit"'
|
||||
f' :class "text-sm bg-stone-800 text-white rounded px-3 py-1 hover:bg-stone-700"'
|
||||
f' "{label}")))')
|
||||
|
||||
return (
|
||||
f'(article :class "bg-white rounded-lg shadow-sm border border-stone-200'
|
||||
f' p-4 mb-3 flex items-center gap-4" :id "actor-{_e(safe_id)}"'
|
||||
f' {avatar}'
|
||||
f' (div :class "flex-1 min-w-0" {name_el} {handle} {summary_el})'
|
||||
f' {button_el})'
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Actor list items — paginated fragment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def actor_list_items_sx(actors: list, total: int, page: int,
|
||||
list_type: str, followed_urls: set, actor: Any) -> str:
|
||||
"""Build sx source for a list of actor cards with pagination sentinel."""
|
||||
from quart import url_for
|
||||
|
||||
parts = [_actor_card_sx(a, followed_urls, actor, list_type) for a in actors]
|
||||
|
||||
# Infinite scroll sentinel
|
||||
if len(actors) >= 20:
|
||||
next_page = page + 1
|
||||
ep = f"ap_social.{list_type}_list_page"
|
||||
next_url = _e(url_for(ep, page=next_page))
|
||||
parts.append(
|
||||
f'(div :sx-get "{next_url}"'
|
||||
f' :sx-trigger "revealed" :sx-swap "outerHTML")')
|
||||
|
||||
return "(<> " + " ".join(parts) + ")" if parts else '""'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search results — paginated fragment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def search_results_sx(actors: list, total: int, page: int,
|
||||
query: str, followed_urls: set, actor: Any) -> str:
|
||||
"""Build sx source for search results with pagination sentinel."""
|
||||
from quart import url_for
|
||||
|
||||
parts = [_actor_card_sx(a, followed_urls, actor, "search") for a in actors]
|
||||
|
||||
if len(actors) >= 20:
|
||||
next_page = page + 1
|
||||
next_url = _e(url_for("ap_social.search_page", q=query, page=next_page))
|
||||
parts.append(
|
||||
f'(div :sx-get "{next_url}"'
|
||||
f' :sx-trigger "revealed" :sx-swap "outerHTML")')
|
||||
|
||||
return "(<> " + " ".join(parts) + ")" if parts else '""'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post card — timeline item
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _post_card_sx(item: Any) -> str:
|
||||
"""Build sx source for a single post/status card."""
|
||||
from shared.infrastructure.urls import app_url
|
||||
|
||||
actor_name = _attr(item, "actor_name", "")
|
||||
actor_username = _attr(item, "actor_username", "")
|
||||
actor_domain = _attr(item, "actor_domain", "")
|
||||
actor_icon = _attr(item, "actor_icon", "")
|
||||
content = _attr(item, "content", "")
|
||||
summary = _attr(item, "summary", "")
|
||||
published = _attr(item, "published")
|
||||
boosted_by = _attr(item, "boosted_by", "")
|
||||
url = _attr(item, "url", "")
|
||||
object_id = _attr(item, "object_id", "")
|
||||
post_type = _attr(item, "post_type", "")
|
||||
|
||||
boost_el = ""
|
||||
if boosted_by:
|
||||
boost_el = (f'(div :class "text-sm text-stone-500 mb-2"'
|
||||
f' "Boosted by {_e(boosted_by)}")')
|
||||
|
||||
# Avatar
|
||||
if actor_icon:
|
||||
avatar = f'(img :src "{_e(actor_icon)}" :alt "" :class "w-10 h-10 rounded-full")'
|
||||
else:
|
||||
initial = actor_name[0].upper() if actor_name else "?"
|
||||
avatar = (f'(div :class "w-10 h-10 rounded-full bg-stone-300 flex items-center'
|
||||
f' justify-center text-stone-600 font-bold text-sm" "{initial}")')
|
||||
|
||||
# Handle
|
||||
handle_text = f"@{_e(actor_username)}"
|
||||
if actor_domain:
|
||||
handle_text += f"@{_e(actor_domain)}"
|
||||
|
||||
# Timestamp
|
||||
time_el = ""
|
||||
if published:
|
||||
if hasattr(published, "strftime"):
|
||||
ts = published.strftime("%b %d, %H:%M")
|
||||
else:
|
||||
ts = str(published)
|
||||
time_el = f'(span :class "text-sm text-stone-400 ml-auto" "{_e(ts)}")'
|
||||
|
||||
# Content — raw HTML from AP, render with raw!
|
||||
if summary:
|
||||
content_el = (
|
||||
f'(details :class "mt-2"'
|
||||
f' (summary :class "text-stone-500 cursor-pointer" "CW: {_e(summary)}")'
|
||||
f' (div :class "mt-2 prose prose-sm prose-stone max-w-none"'
|
||||
f' (raw! "{_esc_raw(content)}")))')
|
||||
else:
|
||||
content_el = (
|
||||
f'(div :class "mt-2 prose prose-sm prose-stone max-w-none"'
|
||||
f' (raw! "{_esc_raw(content)}"))')
|
||||
|
||||
# Links
|
||||
links: list[str] = []
|
||||
if url and post_type == "remote":
|
||||
links.append(
|
||||
f'(a :href "{_e(url)}" :target "_blank" :rel "noopener"'
|
||||
f' :class "hover:underline" "original")')
|
||||
if object_id:
|
||||
hub_url = _e(app_url("federation", "/social/"))
|
||||
links.append(
|
||||
f'(a :href "{hub_url}"'
|
||||
f' :class "hover:underline" "View on Hub")')
|
||||
links_el = ""
|
||||
if links:
|
||||
links_el = ('(div :class "mt-2 flex gap-3 text-sm text-stone-400" '
|
||||
+ " ".join(links) + ")")
|
||||
|
||||
return (
|
||||
f'(article :class "bg-white rounded-lg shadow-sm border border-stone-200 p-4 mb-4"'
|
||||
f' {boost_el}'
|
||||
f' (div :class "flex items-start gap-3"'
|
||||
f' {avatar}'
|
||||
f' (div :class "flex-1 min-w-0"'
|
||||
f' (div :class "flex items-baseline gap-2"'
|
||||
f' (span :class "font-semibold text-stone-900" "{_e(actor_name)}")'
|
||||
f' (span :class "text-sm text-stone-500" "{handle_text}")'
|
||||
f' {time_el})'
|
||||
f' {content_el}'
|
||||
f' {links_el})))'
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timeline items — paginated fragment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def timeline_items_sx(items: list, timeline_type: str = "",
|
||||
actor_id: int | None = None, actor: Any = None) -> str:
|
||||
"""Build sx source for timeline items with infinite scroll sentinel."""
|
||||
from quart import url_for
|
||||
|
||||
parts = [_post_card_sx(item) for item in items]
|
||||
|
||||
if items and timeline_type == "actor" and actor_id:
|
||||
last = items[-1]
|
||||
published = _attr(last, "published")
|
||||
if published and hasattr(published, "isoformat"):
|
||||
before = published.isoformat()
|
||||
else:
|
||||
before = str(published) if published else ""
|
||||
if before:
|
||||
next_url = _e(url_for("ap_social.actor_timeline_page",
|
||||
id=actor_id, before=before))
|
||||
parts.append(
|
||||
f'(div :sx-get "{next_url}"'
|
||||
f' :sx-trigger "revealed" :sx-swap "outerHTML")')
|
||||
|
||||
return "(<> " + " ".join(parts) + ")" if parts else '""'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full page content builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def social_index_content_sx(actor: Any) -> str:
|
||||
"""Build sx source for the social index page content."""
|
||||
from quart import url_for
|
||||
from shared.infrastructure.urls import app_url
|
||||
|
||||
search_url = _e(url_for("ap_social.search"))
|
||||
hub_url = _e(app_url("federation", "/social/"))
|
||||
|
||||
if actor:
|
||||
following_url = _e(url_for("ap_social.following_list"))
|
||||
followers_url = _e(url_for("ap_social.followers_list"))
|
||||
return (
|
||||
f'(div :id "main-panel"'
|
||||
f' (h1 :class "text-2xl font-bold mb-6" "Social")'
|
||||
f' (div :class "space-y-3"'
|
||||
f' (a :href "{search_url}"'
|
||||
f' :class "block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50"'
|
||||
f' (div :class "font-semibold" "Search")'
|
||||
f' (div :class "text-sm text-stone-500" "Find and follow accounts on the fediverse"))'
|
||||
f' (a :href "{following_url}"'
|
||||
f' :class "block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50"'
|
||||
f' (div :class "font-semibold" "Following")'
|
||||
f' (div :class "text-sm text-stone-500" "Accounts you follow"))'
|
||||
f' (a :href "{followers_url}"'
|
||||
f' :class "block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50"'
|
||||
f' (div :class "font-semibold" "Followers")'
|
||||
f' (div :class "text-sm text-stone-500" "Accounts following you here"))'
|
||||
f' (a :href "{hub_url}"'
|
||||
f' :class "block bg-white rounded-lg shadow-sm border border-stone-200 p-4 hover:bg-stone-50"'
|
||||
f' (div :class "font-semibold" "Hub")'
|
||||
f' (div :class "text-sm text-stone-500"'
|
||||
f' "Full social experience \\u2014 timeline, compose, notifications"))))')
|
||||
else:
|
||||
return (
|
||||
f'(div :id "main-panel"'
|
||||
f' (h1 :class "text-2xl font-bold mb-6" "Social")'
|
||||
f' (p :class "text-stone-500"'
|
||||
f' (a :href "{search_url}" :class "underline" "Search")'
|
||||
f' " for accounts on the fediverse, or visit the "'
|
||||
f' (a :href "{hub_url}" :class "underline" "Hub")'
|
||||
f' " to get started."))')
|
||||
|
||||
|
||||
def social_search_content_sx(query: str, actors: list, total: int,
|
||||
page: int, followed_urls: set, actor: Any) -> str:
|
||||
"""Build sx source for the search page content."""
|
||||
from quart import url_for
|
||||
|
||||
search_url = _e(url_for("ap_social.search"))
|
||||
search_page_url = _e(url_for("ap_social.search_page"))
|
||||
|
||||
# Results message
|
||||
msg = ""
|
||||
if query and total:
|
||||
s = "s" if total != 1 else ""
|
||||
msg = (f'(p :class "text-sm text-stone-500 mb-4"'
|
||||
f' "{total} result{s} for " (strong "{_e(query)}"))')
|
||||
elif query:
|
||||
msg = (f'(p :class "text-stone-500 mb-4"'
|
||||
f' "No results found for " (strong "{_e(query)}"))')
|
||||
|
||||
results = search_results_sx(actors, total, page, query, followed_urls, actor)
|
||||
|
||||
return (
|
||||
f'(div :id "main-panel"'
|
||||
f' (h1 :class "text-2xl font-bold mb-6" "Search")'
|
||||
f' (form :method "get" :action "{search_url}"'
|
||||
f' :sx-get "{search_page_url}"'
|
||||
f' :sx-target "#search-results"'
|
||||
f' :sx-push-url "{search_url}"'
|
||||
f' :class "mb-6"'
|
||||
f' (div :class "flex gap-2"'
|
||||
f' (input :type "text" :name "q" :value "{_e(query)}"'
|
||||
f' :class "flex-1 border border-stone-300 rounded-lg px-4 py-2'
|
||||
f' focus:outline-none focus:ring-2 focus:ring-stone-500"'
|
||||
f' :placeholder "Search users or @user@instance.tld")'
|
||||
f' (button :type "submit"'
|
||||
f' :class "bg-stone-800 text-white px-6 py-2 rounded hover:bg-stone-700"'
|
||||
f' "Search")))'
|
||||
f' {msg}'
|
||||
f' (div :id "search-results" {results}))'
|
||||
)
|
||||
|
||||
|
||||
def social_followers_content_sx(actors: list, total: int, page: int,
|
||||
followed_urls: set, actor: Any) -> str:
|
||||
"""Build sx source for the followers page content."""
|
||||
items = actor_list_items_sx(actors, total, page, "followers", followed_urls, actor)
|
||||
return (
|
||||
f'(div :id "main-panel"'
|
||||
f' (h1 :class "text-2xl font-bold mb-6" "Followers "'
|
||||
f' (span :class "text-stone-400 font-normal" "({total})"))'
|
||||
f' (div :id "actor-list" {items}))'
|
||||
)
|
||||
|
||||
|
||||
def social_following_content_sx(actors: list, total: int,
|
||||
page: int, actor: Any) -> str:
|
||||
"""Build sx source for the following page content."""
|
||||
items = actor_list_items_sx(actors, total, page, "following", set(), actor)
|
||||
return (
|
||||
f'(div :id "main-panel"'
|
||||
f' (h1 :class "text-2xl font-bold mb-6" "Following "'
|
||||
f' (span :class "text-stone-400 font-normal" "({total})"))'
|
||||
f' (div :id "actor-list" {items}))'
|
||||
)
|
||||
|
||||
|
||||
def social_actor_timeline_content_sx(remote_actor: Any, items: list,
|
||||
is_following: bool, actor: Any) -> str:
|
||||
"""Build sx source for the actor timeline page content."""
|
||||
from quart import url_for
|
||||
|
||||
ra = remote_actor
|
||||
display_name = _actor_name(ra)
|
||||
username = _attr(ra, "preferred_username", "")
|
||||
domain = _attr(ra, "domain", "")
|
||||
icon_url = _attr(ra, "icon_url", "")
|
||||
summary = _attr(ra, "summary", "")
|
||||
actor_url = _attr(ra, "actor_url", "")
|
||||
ra_id = _attr(ra, "id")
|
||||
csrf = _e(_csrf())
|
||||
|
||||
# Avatar
|
||||
if icon_url:
|
||||
avatar = f'(img :src "{_e(icon_url)}" :alt "" :class "w-16 h-16 rounded-full")'
|
||||
else:
|
||||
initial = display_name[0].upper() if display_name else "?"
|
||||
avatar = (f'(div :class "w-16 h-16 rounded-full bg-stone-300 flex items-center'
|
||||
f' justify-center text-stone-600 font-bold text-xl" "{initial}")')
|
||||
|
||||
# Summary — raw HTML from AP
|
||||
summary_el = ""
|
||||
if summary:
|
||||
summary_el = (f'(div :class "text-sm text-stone-600 mt-2"'
|
||||
f' (raw! "{_esc_raw(summary)}"))')
|
||||
|
||||
# Follow/unfollow button
|
||||
button_el = ""
|
||||
if actor:
|
||||
if is_following:
|
||||
unfollow_url = _e(url_for("ap_social.unfollow"))
|
||||
button_el = (
|
||||
f'(div :class "flex-shrink-0"'
|
||||
f' (form :method "post" :action "{unfollow_url}"'
|
||||
f' (input :type "hidden" :name "csrf_token" :value "{csrf}")'
|
||||
f' (input :type "hidden" :name "actor_url" :value "{_e(actor_url)}")'
|
||||
f' (button :type "submit"'
|
||||
f' :class "border border-stone-300 rounded px-4 py-2 hover:bg-stone-100"'
|
||||
f' "Unfollow")))')
|
||||
else:
|
||||
follow_url = _e(url_for("ap_social.follow"))
|
||||
button_el = (
|
||||
f'(div :class "flex-shrink-0"'
|
||||
f' (form :method "post" :action "{follow_url}"'
|
||||
f' (input :type "hidden" :name "csrf_token" :value "{csrf}")'
|
||||
f' (input :type "hidden" :name "actor_url" :value "{_e(actor_url)}")'
|
||||
f' (button :type "submit"'
|
||||
f' :class "bg-stone-800 text-white rounded px-4 py-2 hover:bg-stone-700"'
|
||||
f' "Follow")))')
|
||||
|
||||
tl = timeline_items_sx(items, "actor", ra_id, actor)
|
||||
|
||||
return (
|
||||
f'(div :id "main-panel"'
|
||||
f' (div :class "bg-white rounded-lg shadow-sm border border-stone-200 p-6 mb-6"'
|
||||
f' (div :class "flex items-center gap-4"'
|
||||
f' {avatar}'
|
||||
f' (div :class "flex-1"'
|
||||
f' (h1 :class "text-xl font-bold" "{_e(display_name)}")'
|
||||
f' (div :class "text-stone-500" "@{_e(username)}@{_e(domain)}")'
|
||||
f' {summary_el})'
|
||||
f' {button_el}))'
|
||||
f' (div :id "timeline" {tl}))'
|
||||
)
|
||||
@@ -160,6 +160,8 @@ def create_base_app(
|
||||
# Auto-register per-app social blueprint (not federation — it has its own)
|
||||
if name in AP_APPS and name != "federation":
|
||||
from shared.infrastructure.ap_social import create_ap_social_blueprint
|
||||
from shared.infrastructure.ap_social_sx import setup_social_layout
|
||||
setup_social_layout()
|
||||
app.register_blueprint(create_ap_social_blueprint(name))
|
||||
|
||||
# --- device id (all apps, including account) ---
|
||||
|
||||
@@ -2249,7 +2249,9 @@
|
||||
return null;
|
||||
}
|
||||
|
||||
root.className = (root.className || "") + " sx-editor";
|
||||
// Clear any previous mount
|
||||
root.innerHTML = "";
|
||||
root.className = (root.className || "").replace(/\bsx-editor\b/g, "").trim() + " sx-editor";
|
||||
|
||||
var container = el("div", { className: "sx-blocks-container" });
|
||||
root.appendChild(container);
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
|
||||
var RE_WS = /\s+/y;
|
||||
var RE_COMMENT = /;[^\n]*/y;
|
||||
var RE_STRING = /"(?:[^"\\]|\\.)*"/y;
|
||||
var RE_STRING = /"(?:[^"\\]|\\[\s\S])*"/y;
|
||||
var RE_NUMBER = /-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/y;
|
||||
var RE_KEYWORD = /:[a-zA-Z_][a-zA-Z0-9_>:\-]*/y;
|
||||
var RE_SYMBOL = /[a-zA-Z_~*+\-><=/!?&][a-zA-Z0-9_~*+\-><=/!?.:&]*/y;
|
||||
|
||||
@@ -254,7 +254,7 @@ def search_desktop_sx(ctx: dict) -> str:
|
||||
)
|
||||
|
||||
|
||||
def post_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
def post_header_sx(ctx: dict, *, oob: bool = False, child: str = "") -> str:
|
||||
"""Build the post-level header row as sx call string."""
|
||||
post = ctx.get("post") or {}
|
||||
slug = post.get("slug", "")
|
||||
@@ -273,6 +273,7 @@ def post_header_sx(ctx: dict, *, oob: bool = False) -> str:
|
||||
link_label_content=SxExpr(label_sx),
|
||||
nav=SxExpr(nav_sx) if nav_sx else None,
|
||||
child_id="post-header-child",
|
||||
child=SxExpr(child) if child else None,
|
||||
oob=oob, external=True,
|
||||
)
|
||||
|
||||
@@ -378,6 +379,7 @@ def sx_call(component_name: str, **kwargs: Any) -> str:
|
||||
return "(" + " ".join(parts) + ")"
|
||||
|
||||
|
||||
|
||||
def components_for_request() -> str:
|
||||
"""Return defcomp/defmacro source for definitions the client doesn't have yet.
|
||||
|
||||
|
||||
@@ -104,16 +104,18 @@ def _post_full(ctx: dict, **kw: Any) -> str:
|
||||
|
||||
def _post_oob(ctx: dict, **kw: Any) -> str:
|
||||
post_hdr = post_header_sx(ctx, oob=True)
|
||||
return post_hdr
|
||||
# Also replace #post-header-child (empty — clears any nested admin rows)
|
||||
child_oob = oob_header_sx("post-header-child", "", "")
|
||||
return "(<> " + post_hdr + " " + child_oob + ")"
|
||||
|
||||
|
||||
def _post_admin_full(ctx: dict, **kw: Any) -> str:
|
||||
slug = ctx.get("post", {}).get("slug", "")
|
||||
selected = kw.get("selected", "")
|
||||
root_hdr = root_header_sx(ctx)
|
||||
post_hdr = post_header_sx(ctx)
|
||||
admin_hdr = post_admin_header_sx(ctx, slug, selected=selected)
|
||||
return "(<> " + root_hdr + " " + post_hdr + " " + admin_hdr + ")"
|
||||
post_hdr = post_header_sx(ctx, child=admin_hdr)
|
||||
return "(<> " + root_hdr + " " + post_hdr + ")"
|
||||
|
||||
|
||||
def _post_admin_oob(ctx: dict, **kw: Any) -> str:
|
||||
|
||||
@@ -257,6 +257,24 @@ def prim_split(s: str, sep: str = " ") -> list[str]:
|
||||
def prim_join(sep: str, coll: list) -> str:
|
||||
return sep.join(str(x) for x in coll)
|
||||
|
||||
@register_primitive("replace")
|
||||
def prim_replace(s: str, old: str, new: str) -> str:
|
||||
return s.replace(old, new)
|
||||
|
||||
@register_primitive("strip-tags")
|
||||
def prim_strip_tags(s: str) -> str:
|
||||
"""Strip HTML tags from a string."""
|
||||
import re
|
||||
return re.sub(r"<[^>]+>", "", s)
|
||||
|
||||
@register_primitive("slice")
|
||||
def prim_slice(coll: Any, start: int, end: Any = None) -> Any:
|
||||
"""Slice a string or list: (slice coll start end?)."""
|
||||
start = int(start)
|
||||
if end is None or end is NIL:
|
||||
return coll[start:]
|
||||
return coll[start:int(end)]
|
||||
|
||||
@register_primitive("starts-with?")
|
||||
def prim_starts_with(s, prefix: str) -> bool:
|
||||
if not isinstance(s, str):
|
||||
|
||||
@@ -41,6 +41,7 @@ IO_PRIMITIVES: frozenset[str] = frozenset({
|
||||
"nav-tree",
|
||||
"get-children",
|
||||
"g",
|
||||
"csrf-token",
|
||||
})
|
||||
|
||||
|
||||
@@ -314,6 +315,17 @@ async def _io_g(
|
||||
return getattr(g, key, None)
|
||||
|
||||
|
||||
async def _io_csrf_token(
|
||||
args: list[Any], kwargs: dict[str, Any], ctx: RequestContext
|
||||
) -> str:
|
||||
"""``(csrf-token)`` → current CSRF token string."""
|
||||
from quart import current_app
|
||||
csrf = current_app.jinja_env.globals.get("csrf_token")
|
||||
if callable(csrf):
|
||||
return csrf()
|
||||
return ""
|
||||
|
||||
|
||||
_IO_HANDLERS: dict[str, Any] = {
|
||||
"frag": _io_frag,
|
||||
"query": _io_query,
|
||||
@@ -326,4 +338,5 @@ _IO_HANDLERS: dict[str, Any] = {
|
||||
"nav-tree": _io_nav_tree,
|
||||
"get-children": _io_get_children,
|
||||
"g": _io_g,
|
||||
"csrf-token": _io_csrf_token,
|
||||
}
|
||||
|
||||
@@ -709,4 +709,177 @@ def register(url_prefix: str = "/") -> Blueprint:
|
||||
oob_comp = _oob_code("retry-comp", comp_text)
|
||||
return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})')
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Reference attribute detail API endpoints (for live demos)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _ref_wire(wire_id: str, sx_src: str) -> str:
|
||||
"""Build OOB swap showing the wire response text."""
|
||||
from sxc.sx_components import _oob_code
|
||||
return _oob_code(f"ref-wire-{wire_id}", sx_src)
|
||||
|
||||
@bp.get("/reference/api/time")
|
||||
async def ref_time():
|
||||
from shared.sx.helpers import sx_response
|
||||
now = datetime.now().strftime("%H:%M:%S")
|
||||
sx_src = f'(span :class "text-stone-800 text-sm" "Server time: " (strong "{now}"))'
|
||||
oob = _ref_wire("sx-get", sx_src)
|
||||
return sx_response(f'(<> {sx_src} {oob})')
|
||||
|
||||
@csrf_exempt
|
||||
@bp.post("/reference/api/greet")
|
||||
async def ref_greet():
|
||||
from shared.sx.helpers import sx_response
|
||||
form = await request.form
|
||||
name = form.get("name") or "stranger"
|
||||
sx_src = f'(span :class "text-stone-800 text-sm" "Hello, " (strong "{name}") "!")'
|
||||
oob = _ref_wire("sx-post", sx_src)
|
||||
return sx_response(f'(<> {sx_src} {oob})')
|
||||
|
||||
@csrf_exempt
|
||||
@bp.put("/reference/api/status")
|
||||
async def ref_status():
|
||||
from shared.sx.helpers import sx_response
|
||||
form = await request.form
|
||||
status = form.get("status", "unknown")
|
||||
sx_src = f'(span :class "text-stone-700 text-sm" "Status: " (strong "{status}") " — updated via PUT")'
|
||||
oob = _ref_wire("sx-put", sx_src)
|
||||
return sx_response(f'(<> {sx_src} {oob})')
|
||||
|
||||
@csrf_exempt
|
||||
@bp.patch("/reference/api/theme")
|
||||
async def ref_theme():
|
||||
from shared.sx.helpers import sx_response
|
||||
form = await request.form
|
||||
theme = form.get("theme", "unknown")
|
||||
sx_src = f'"{theme}"'
|
||||
oob = _ref_wire("sx-patch", sx_src)
|
||||
return sx_response(f'(<> {sx_src} {oob})')
|
||||
|
||||
@csrf_exempt
|
||||
@bp.delete("/reference/api/item/<item_id>")
|
||||
async def ref_delete(item_id: str):
|
||||
from shared.sx.helpers import sx_response
|
||||
oob = _ref_wire("sx-delete", '""')
|
||||
return sx_response(f'(<> {oob})')
|
||||
|
||||
@bp.get("/reference/api/trigger-search")
|
||||
async def ref_trigger_search():
|
||||
from shared.sx.helpers import sx_response
|
||||
q = request.args.get("q", "")
|
||||
if not q:
|
||||
sx_src = '(span :class "text-stone-400 text-sm" "Start typing to trigger a search.")'
|
||||
else:
|
||||
sx_src = f'(span :class "text-stone-800 text-sm" "Results for: " (strong "{q}"))'
|
||||
oob = _ref_wire("sx-trigger", sx_src)
|
||||
return sx_response(f'(<> {sx_src} {oob})')
|
||||
|
||||
@bp.get("/reference/api/swap-item")
|
||||
async def ref_swap_item():
|
||||
from shared.sx.helpers import sx_response
|
||||
now = datetime.now().strftime("%H:%M:%S")
|
||||
sx_src = f'(div :class "text-sm text-violet-700" "New item (" "{now}" ")")'
|
||||
oob = _ref_wire("sx-swap", sx_src)
|
||||
return sx_response(f'(<> {sx_src} {oob})')
|
||||
|
||||
@bp.get("/reference/api/oob")
|
||||
async def ref_oob():
|
||||
from shared.sx.helpers import sx_response
|
||||
now = datetime.now().strftime("%H:%M:%S")
|
||||
sx_src = (
|
||||
f'(<>'
|
||||
f' (span :class "text-emerald-700 text-sm" "Main updated at " "{now}")'
|
||||
f' (div :id "ref-oob-side" :sx-swap-oob "innerHTML"'
|
||||
f' (span :class "text-violet-700 text-sm" "OOB updated at " "{now}")))')
|
||||
oob = _ref_wire("sx-swap-oob", sx_src)
|
||||
return sx_response(f'(<> {sx_src} {oob})')
|
||||
|
||||
@bp.get("/reference/api/select-page")
|
||||
async def ref_select_page():
|
||||
from shared.sx.helpers import sx_response
|
||||
now = datetime.now().strftime("%H:%M:%S")
|
||||
sx_src = (
|
||||
f'(<>'
|
||||
f' (div :id "the-header" (h3 "Page header — not selected"))'
|
||||
f' (div :id "the-content"'
|
||||
f' (span :class "text-emerald-700 text-sm"'
|
||||
f' "This fragment was selected from a larger response. Time: " "{now}"))'
|
||||
f' (div :id "the-footer" (p "Page footer — not selected")))')
|
||||
oob = _ref_wire("sx-select", sx_src)
|
||||
return sx_response(f'(<> {sx_src} {oob})')
|
||||
|
||||
@bp.get("/reference/api/slow-echo")
|
||||
async def ref_slow_echo():
|
||||
from shared.sx.helpers import sx_response
|
||||
await asyncio.sleep(0.8)
|
||||
q = request.args.get("q", "")
|
||||
sx_src = f'(span :class "text-stone-800 text-sm" "Echo: " (strong "{q}"))'
|
||||
oob = _ref_wire("sx-sync", sx_src)
|
||||
return sx_response(f'(<> {sx_src} {oob})')
|
||||
|
||||
@csrf_exempt
|
||||
@bp.post("/reference/api/upload-name")
|
||||
async def ref_upload_name():
|
||||
from shared.sx.helpers import sx_response
|
||||
files = await request.files
|
||||
f = files.get("file")
|
||||
name = f.filename if f else "(no file)"
|
||||
sx_src = f'(span :class "text-stone-800 text-sm" "Received: " (strong "{name}"))'
|
||||
oob = _ref_wire("sx-encoding", sx_src)
|
||||
return sx_response(f'(<> {sx_src} {oob})')
|
||||
|
||||
@bp.get("/reference/api/echo-headers")
|
||||
async def ref_echo_headers():
|
||||
from shared.sx.helpers import sx_response
|
||||
custom = [(k, v) for k, v in request.headers if k.lower().startswith("x-")]
|
||||
if not custom:
|
||||
sx_src = '(span :class "text-stone-400 text-sm" "No custom headers received.")'
|
||||
else:
|
||||
items = " ".join(
|
||||
f'(li (strong "{k}") ": " "{v}")' for k, v in custom)
|
||||
sx_src = f'(ul :class "text-sm text-stone-700 space-y-1" {items})'
|
||||
oob = _ref_wire("sx-headers", sx_src)
|
||||
return sx_response(f'(<> {sx_src} {oob})')
|
||||
|
||||
@bp.get("/reference/api/echo-vals")
|
||||
async def ref_echo_vals_get():
|
||||
from shared.sx.helpers import sx_response
|
||||
vals = list(request.args.items())
|
||||
if not vals:
|
||||
sx_src = '(span :class "text-stone-400 text-sm" "No values received.")'
|
||||
else:
|
||||
items = " ".join(
|
||||
f'(li (strong "{k}") ": " "{v}")' for k, v in vals)
|
||||
sx_src = f'(ul :class "text-sm text-stone-700 space-y-1" {items})'
|
||||
oob_include = _ref_wire("sx-include", sx_src)
|
||||
return sx_response(f'(<> {sx_src} {oob_include})')
|
||||
|
||||
@csrf_exempt
|
||||
@bp.post("/reference/api/echo-vals")
|
||||
async def ref_echo_vals_post():
|
||||
from shared.sx.helpers import sx_response
|
||||
form = await request.form
|
||||
vals = list(form.items())
|
||||
if not vals:
|
||||
sx_src = '(span :class "text-stone-400 text-sm" "No values received.")'
|
||||
else:
|
||||
items = " ".join(
|
||||
f'(li (strong "{k}") ": " "{v}")' for k, v in vals)
|
||||
sx_src = f'(ul :class "text-sm text-stone-700 space-y-1" {items})'
|
||||
oob = _ref_wire("sx-vals", sx_src)
|
||||
return sx_response(f'(<> {sx_src} {oob})')
|
||||
|
||||
_ref_flaky = {"n": 0}
|
||||
|
||||
@bp.get("/reference/api/flaky")
|
||||
async def ref_flaky():
|
||||
from shared.sx.helpers import sx_response
|
||||
_ref_flaky["n"] += 1
|
||||
n = _ref_flaky["n"]
|
||||
if n % 3 != 0:
|
||||
return Response("", status=503, content_type="text/plain")
|
||||
sx_src = f'(span :class "text-emerald-700 text-sm" "Success on attempt " "{n}" "!")'
|
||||
oob = _ref_wire("sx-retry", sx_src)
|
||||
return sx_response(f'(<> {sx_src} {oob})')
|
||||
|
||||
return bp
|
||||
|
||||
@@ -20,7 +20,7 @@ DOCS_NAV = [
|
||||
]
|
||||
|
||||
REFERENCE_NAV = [
|
||||
("Attributes", "/reference/"),
|
||||
("Attributes", "/reference/attributes"),
|
||||
("Headers", "/reference/headers"),
|
||||
("Events", "/reference/events"),
|
||||
("JS API", "/reference/js-api"),
|
||||
@@ -107,6 +107,7 @@ BEHAVIOR_ATTRS = [
|
||||
("sx-vals", "Add values to the request as a JSON string", True),
|
||||
("sx-media", "Only enable this element when the media query matches", True),
|
||||
("sx-disable", "Disable sx processing on this element and its children", True),
|
||||
("sx-on:*", "Inline event handler — e.g. sx-on:click runs JavaScript on event", True),
|
||||
]
|
||||
|
||||
SX_UNIQUE_ATTRS = [
|
||||
@@ -236,3 +237,481 @@ EDIT_ROW_DATA = [
|
||||
{"id": "3", "name": "Widget C", "price": "12.00", "stock": "305"},
|
||||
{"id": "4", "name": "Widget D", "price": "45.00", "stock": "67"},
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reference: Attribute detail pages
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ATTR_DETAILS: dict[str, dict] = {
|
||||
# --- Request Attributes ---
|
||||
"sx-get": {
|
||||
"description": (
|
||||
"Issues a GET request to the given URL when triggered. "
|
||||
"The response HTML is swapped into the target element. "
|
||||
"This is the most common sx attribute — use it for loading content, "
|
||||
"navigation, and any read operation."
|
||||
),
|
||||
"demo": "ref-get-demo",
|
||||
"example": (
|
||||
'(button :sx-get "/reference/api/time"\n'
|
||||
' :sx-target "#ref-get-result"\n'
|
||||
' :sx-swap "innerHTML"\n'
|
||||
' "Load server time")'
|
||||
),
|
||||
"handler": (
|
||||
'(defhandler ref-time (&key)\n'
|
||||
' (let ((now (format-time (now) "%H:%M:%S")))\n'
|
||||
' (span :class "text-stone-800 text-sm"\n'
|
||||
' "Server time: " (strong now))))'
|
||||
),
|
||||
},
|
||||
"sx-post": {
|
||||
"description": (
|
||||
"Issues a POST request to the given URL. "
|
||||
"Form values from the enclosing form (or sx-include target) are sent as the request body. "
|
||||
"Use for creating resources, submitting forms, and any write operation."
|
||||
),
|
||||
"demo": "ref-post-demo",
|
||||
"example": (
|
||||
'(form :sx-post "/reference/api/greet"\n'
|
||||
' :sx-target "#ref-post-result"\n'
|
||||
' :sx-swap "innerHTML"\n'
|
||||
' (input :type "text" :name "name"\n'
|
||||
' :placeholder "Your name")\n'
|
||||
' (button :type "submit" "Greet"))'
|
||||
),
|
||||
"handler": (
|
||||
'(defhandler ref-greet (&key)\n'
|
||||
' (let ((name (or (form-data "name") "stranger")))\n'
|
||||
' (span :class "text-stone-800 text-sm"\n'
|
||||
' "Hello, " (strong name) "!")))'
|
||||
),
|
||||
},
|
||||
"sx-put": {
|
||||
"description": (
|
||||
"Issues a PUT request to the given URL. "
|
||||
"Used for full replacement updates of a resource. "
|
||||
"Form values are sent as the request body."
|
||||
),
|
||||
"demo": "ref-put-demo",
|
||||
"example": (
|
||||
'(button :sx-put "/reference/api/status"\n'
|
||||
' :sx-target "#ref-put-view"\n'
|
||||
' :sx-swap "innerHTML"\n'
|
||||
' :sx-vals "{\\"status\\": \\"published\\"}"\n'
|
||||
' "Publish")'
|
||||
),
|
||||
"handler": (
|
||||
'(defhandler ref-status (&key)\n'
|
||||
' (let ((status (or (form-data "status") "unknown")))\n'
|
||||
' (span :class "text-stone-700 text-sm"\n'
|
||||
' "Status: " (strong status) " — updated via PUT")))'
|
||||
),
|
||||
},
|
||||
"sx-delete": {
|
||||
"description": (
|
||||
"Issues a DELETE request to the given URL. "
|
||||
"Commonly paired with sx-confirm for a confirmation dialog, "
|
||||
'and sx-swap "delete" to remove the element from the DOM.'
|
||||
),
|
||||
"demo": "ref-delete-demo",
|
||||
"example": (
|
||||
'(button :sx-delete "/reference/api/item/1"\n'
|
||||
' :sx-target "#ref-del-1"\n'
|
||||
' :sx-swap "delete"\n'
|
||||
' "Remove")'
|
||||
),
|
||||
"handler": (
|
||||
'(defhandler ref-delete (&key item-id)\n'
|
||||
' ;; Empty response — swap "delete" removes the target\n'
|
||||
' "")'
|
||||
),
|
||||
},
|
||||
"sx-patch": {
|
||||
"description": (
|
||||
"Issues a PATCH request to the given URL. "
|
||||
"Used for partial updates — only changed fields are sent. "
|
||||
"Form values are sent as the request body."
|
||||
),
|
||||
"demo": "ref-patch-demo",
|
||||
"example": (
|
||||
'(button :sx-patch "/reference/api/theme"\n'
|
||||
' :sx-vals "{\\"theme\\": \\"dark\\"}"\n'
|
||||
' :sx-target "#ref-patch-val"\n'
|
||||
' :sx-swap "innerHTML"\n'
|
||||
' "Dark")'
|
||||
),
|
||||
"handler": (
|
||||
'(defhandler ref-theme (&key)\n'
|
||||
' (let ((theme (or (form-data "theme") "unknown")))\n'
|
||||
' (str theme)))'
|
||||
),
|
||||
},
|
||||
|
||||
# --- Behavior Attributes ---
|
||||
"sx-trigger": {
|
||||
"description": (
|
||||
"Specifies which DOM event triggers the request. "
|
||||
"Defaults to 'click' for most elements and 'submit' for forms. "
|
||||
"Supports modifiers: once, changed, delay:<time>, from:<selector>, "
|
||||
"intersect, revealed, load, every:<time>. "
|
||||
"Multiple triggers can be comma-separated."
|
||||
),
|
||||
"demo": "ref-trigger-demo",
|
||||
"example": (
|
||||
'(input :type "text" :name "q"\n'
|
||||
' :placeholder "Type to search..."\n'
|
||||
' :sx-get "/reference/api/trigger-search"\n'
|
||||
' :sx-trigger "input changed delay:300ms"\n'
|
||||
' :sx-target "#ref-trigger-result"\n'
|
||||
' :sx-swap "innerHTML")'
|
||||
),
|
||||
"handler": (
|
||||
'(defhandler ref-trigger-search (&key)\n'
|
||||
' (let ((q (or (request-arg "q") "")))\n'
|
||||
' (if (empty? q)\n'
|
||||
' (span "Start typing to trigger a search.")\n'
|
||||
' (span "Results for: " (strong q)))))'
|
||||
),
|
||||
},
|
||||
"sx-target": {
|
||||
"description": (
|
||||
"CSS selector identifying which element receives the response content. "
|
||||
'Defaults to the element itself. Use "closest <selector>" to find '
|
||||
"the nearest ancestor matching the selector."
|
||||
),
|
||||
"demo": "ref-target-demo",
|
||||
"example": (
|
||||
';; Two buttons targeting different elements\n'
|
||||
'(button :sx-get "/reference/api/time"\n'
|
||||
' :sx-target "#ref-target-a"\n'
|
||||
' :sx-swap "innerHTML"\n'
|
||||
' "Update Box A")\n'
|
||||
'\n'
|
||||
'(button :sx-get "/reference/api/time"\n'
|
||||
' :sx-target "#ref-target-b"\n'
|
||||
' :sx-swap "innerHTML"\n'
|
||||
' "Update Box B")'
|
||||
),
|
||||
},
|
||||
"sx-swap": {
|
||||
"description": (
|
||||
"Controls how the response is swapped into the target element. "
|
||||
"Values: innerHTML (default), outerHTML, afterend, beforeend, "
|
||||
"afterbegin, beforebegin, delete, none."
|
||||
),
|
||||
"demo": "ref-swap-demo",
|
||||
"example": (
|
||||
';; Append to the end of a list\n'
|
||||
'(button :sx-get "/reference/api/swap-item"\n'
|
||||
' :sx-target "#ref-swap-list"\n'
|
||||
' :sx-swap "beforeend"\n'
|
||||
' "beforeend")\n'
|
||||
'\n'
|
||||
';; Prepend to the start\n'
|
||||
'(button :sx-get "/reference/api/swap-item"\n'
|
||||
' :sx-target "#ref-swap-list"\n'
|
||||
' :sx-swap "afterbegin"\n'
|
||||
' "afterbegin")'
|
||||
),
|
||||
"handler": (
|
||||
'(defhandler ref-swap-item (&key)\n'
|
||||
' (let ((now (format-time (now) "%H:%M:%S")))\n'
|
||||
' (div :class "text-sm text-violet-700"\n'
|
||||
' "New item (" now ")")))'
|
||||
),
|
||||
},
|
||||
"sx-swap-oob": {
|
||||
"description": (
|
||||
"Out-of-band swap — updates elements elsewhere in the DOM by ID, "
|
||||
"outside the normal target. The server includes extra elements in "
|
||||
"the response with sx-swap-oob attributes, and they are swapped "
|
||||
"into matching elements in the page."
|
||||
),
|
||||
"demo": "ref-oob-demo",
|
||||
"example": (
|
||||
'(button :sx-get "/reference/api/oob"\n'
|
||||
' :sx-target "#ref-oob-main"\n'
|
||||
' :sx-swap "innerHTML"\n'
|
||||
' "Update both boxes")'
|
||||
),
|
||||
"handler": (
|
||||
'(defhandler ref-oob (&key)\n'
|
||||
' (let ((now (format-time (now) "%H:%M:%S")))\n'
|
||||
' (<>\n'
|
||||
' (span "Main updated at " now)\n'
|
||||
' (div :id "ref-oob-side"\n'
|
||||
' :sx-swap-oob "innerHTML"\n'
|
||||
' (span "OOB updated at " now)))))'
|
||||
),
|
||||
},
|
||||
"sx-select": {
|
||||
"description": (
|
||||
"CSS selector to pick a fragment from the response HTML. "
|
||||
"Only the matching element is swapped into the target. "
|
||||
"Useful for extracting part of a full-page response."
|
||||
),
|
||||
"demo": "ref-select-demo",
|
||||
"example": (
|
||||
'(button :sx-get "/reference/api/select-page"\n'
|
||||
' :sx-target "#ref-select-result"\n'
|
||||
' :sx-select "#the-content"\n'
|
||||
' :sx-swap "innerHTML"\n'
|
||||
' "Load (selecting #the-content)")'
|
||||
),
|
||||
"handler": (
|
||||
'(defhandler ref-select-page (&key)\n'
|
||||
' (let ((now (format-time (now) "%H:%M:%S")))\n'
|
||||
' (<>\n'
|
||||
' (div :id "the-header" (h3 "Page header — not selected"))\n'
|
||||
' (div :id "the-content"\n'
|
||||
' (span "Selected fragment. Time: " now))\n'
|
||||
' (div :id "the-footer" (p "Page footer — not selected")))))'
|
||||
),
|
||||
},
|
||||
"sx-confirm": {
|
||||
"description": (
|
||||
"Shows a browser confirmation dialog before issuing the request. "
|
||||
"The request is cancelled if the user clicks Cancel. "
|
||||
"The value is the message shown in the dialog."
|
||||
),
|
||||
"demo": "ref-confirm-demo",
|
||||
"example": (
|
||||
'(button :sx-delete "/reference/api/item/confirm"\n'
|
||||
' :sx-target "#ref-confirm-item"\n'
|
||||
' :sx-swap "delete"\n'
|
||||
' :sx-confirm "Are you sure you want to delete this file?"\n'
|
||||
' "Delete")'
|
||||
),
|
||||
},
|
||||
"sx-push-url": {
|
||||
"description": (
|
||||
'Push the request URL into the browser location bar, enabling '
|
||||
'back/forward navigation. Set to "true" to push the request URL, '
|
||||
'or provide a custom URL string.'
|
||||
),
|
||||
"demo": "ref-pushurl-demo",
|
||||
"example": (
|
||||
'(a :href "/reference/attributes/sx-get"\n'
|
||||
' :sx-get "/reference/attributes/sx-get"\n'
|
||||
' :sx-target "#main-panel"\n'
|
||||
' :sx-select "#main-panel"\n'
|
||||
' :sx-swap "outerHTML"\n'
|
||||
' :sx-push-url "true"\n'
|
||||
' "sx-get page")'
|
||||
),
|
||||
},
|
||||
"sx-sync": {
|
||||
"description": (
|
||||
"Controls synchronization of concurrent requests from the same element. "
|
||||
'Strategies: "drop" (ignore new while in-flight), '
|
||||
'"replace" (abort in-flight, send new), '
|
||||
'"queue" (queue and send after current completes).'
|
||||
),
|
||||
"demo": "ref-sync-demo",
|
||||
"example": (
|
||||
'(input :type "text" :name "q"\n'
|
||||
' :placeholder "Type quickly..."\n'
|
||||
' :sx-get "/reference/api/slow-echo"\n'
|
||||
' :sx-trigger "input changed delay:100ms"\n'
|
||||
' :sx-sync "replace"\n'
|
||||
' :sx-target "#ref-sync-result"\n'
|
||||
' :sx-swap "innerHTML")'
|
||||
),
|
||||
"handler": (
|
||||
'(defhandler ref-slow-echo (&key)\n'
|
||||
' (sleep 800)\n'
|
||||
' (let ((q (or (request-arg "q") "")))\n'
|
||||
' (span "Echo: " (strong q))))'
|
||||
),
|
||||
},
|
||||
"sx-encoding": {
|
||||
"description": (
|
||||
"Sets the encoding type for the request body. "
|
||||
'Use "multipart/form-data" for file uploads. '
|
||||
"Defaults to application/x-www-form-urlencoded for forms."
|
||||
),
|
||||
"demo": "ref-encoding-demo",
|
||||
"example": (
|
||||
'(form :sx-post "/reference/api/upload-name"\n'
|
||||
' :sx-encoding "multipart/form-data"\n'
|
||||
' :sx-target "#ref-encoding-result"\n'
|
||||
' :sx-swap "innerHTML"\n'
|
||||
' (input :type "file" :name "file")\n'
|
||||
' (button :type "submit" "Upload"))'
|
||||
),
|
||||
"handler": (
|
||||
'(defhandler ref-upload-name (&key)\n'
|
||||
' (let ((name (or (file-name "file") "(no file)")))\n'
|
||||
' (span "Received: " (strong name))))'
|
||||
),
|
||||
},
|
||||
"sx-headers": {
|
||||
"description": (
|
||||
"Adds custom headers to the request as a JSON object string. "
|
||||
"Useful for passing metadata like API keys or content types."
|
||||
),
|
||||
"demo": "ref-headers-demo",
|
||||
"example": (
|
||||
'(button :sx-get "/reference/api/echo-headers"\n'
|
||||
' :sx-headers \'{"X-Custom-Token": "abc123", "X-Request-Source": "demo"}\'\n'
|
||||
' :sx-target "#ref-headers-result"\n'
|
||||
' :sx-swap "innerHTML"\n'
|
||||
' "Send with custom headers")'
|
||||
),
|
||||
"handler": (
|
||||
'(defhandler ref-echo-headers (&key)\n'
|
||||
' (let ((headers (request-headers :prefix "X-")))\n'
|
||||
' (if (empty? headers)\n'
|
||||
' (span "No custom headers received.")\n'
|
||||
' (ul (map (fn (h)\n'
|
||||
' (li (strong (first h)) ": " (last h)))\n'
|
||||
' headers)))))'
|
||||
),
|
||||
},
|
||||
"sx-include": {
|
||||
"description": (
|
||||
"Include values from additional elements in the request. "
|
||||
"Takes a CSS selector. The matched element's form values "
|
||||
"(inputs, selects, textareas) are added to the request."
|
||||
),
|
||||
"demo": "ref-include-demo",
|
||||
"example": (
|
||||
'(select :id "ref-inc-cat" :name "category"\n'
|
||||
' (option :value "all" "All")\n'
|
||||
' (option :value "books" "Books")\n'
|
||||
' (option :value "tools" "Tools"))\n'
|
||||
'\n'
|
||||
'(button :sx-get "/reference/api/echo-vals"\n'
|
||||
' :sx-include "#ref-inc-cat"\n'
|
||||
' :sx-target "#ref-include-result"\n'
|
||||
' :sx-swap "innerHTML"\n'
|
||||
' "Filter")'
|
||||
),
|
||||
"handler": (
|
||||
'(defhandler ref-echo-vals (&key)\n'
|
||||
' (let ((vals (request-args)))\n'
|
||||
' (if (empty? vals)\n'
|
||||
' (span "No values received.")\n'
|
||||
' (ul (map (fn (v)\n'
|
||||
' (li (strong (first v)) ": " (last v)))\n'
|
||||
' vals)))))'
|
||||
),
|
||||
},
|
||||
"sx-vals": {
|
||||
"description": (
|
||||
"Adds extra values to the request as a JSON object string. "
|
||||
"These are merged with any form values. "
|
||||
"Useful for passing additional data without hidden inputs."
|
||||
),
|
||||
"demo": "ref-vals-demo",
|
||||
"example": (
|
||||
'(button :sx-post "/reference/api/echo-vals"\n'
|
||||
' :sx-vals \'{"source": "demo", "page": "3"}\'\n'
|
||||
' :sx-target "#ref-vals-result"\n'
|
||||
' :sx-swap "innerHTML"\n'
|
||||
' "Send with extra values")'
|
||||
),
|
||||
},
|
||||
"sx-media": {
|
||||
"description": (
|
||||
"Only enables the sx attributes on this element when the given "
|
||||
"CSS media query matches. When the media query does not match, "
|
||||
"the element behaves as a normal HTML element."
|
||||
),
|
||||
"demo": "ref-media-demo",
|
||||
"example": (
|
||||
'(a :href "/reference/attributes/sx-get"\n'
|
||||
' :sx-get "/reference/attributes/sx-get"\n'
|
||||
' :sx-target "#main-panel"\n'
|
||||
' :sx-select "#main-panel"\n'
|
||||
' :sx-swap "outerHTML"\n'
|
||||
' :sx-push-url "true"\n'
|
||||
' :sx-media "(min-width: 768px)"\n'
|
||||
' "sx navigation (desktop only)")'
|
||||
),
|
||||
},
|
||||
"sx-disable": {
|
||||
"description": (
|
||||
"Disables sx processing on this element and all its children. "
|
||||
"The element renders as normal HTML without any sx behavior. "
|
||||
"Useful for opting out of sx in specific subtrees."
|
||||
),
|
||||
"demo": "ref-disable-demo",
|
||||
"example": (
|
||||
';; Left box: sx works normally\n'
|
||||
';; Right box: sx-disable prevents any sx behavior\n'
|
||||
'(div :sx-disable "true"\n'
|
||||
' (button :sx-get "/reference/api/time"\n'
|
||||
' :sx-target "#ref-dis-b"\n'
|
||||
' :sx-swap "innerHTML"\n'
|
||||
' "Load")\n'
|
||||
' ;; This button will NOT fire an sx request\n'
|
||||
' )'
|
||||
),
|
||||
},
|
||||
"sx-on:*": {
|
||||
"description": (
|
||||
"Inline event handler — attaches JavaScript to a DOM event. "
|
||||
'The * is replaced by the event name (e.g. sx-on:click, sx-on:keydown). '
|
||||
"The handler code runs as inline JavaScript with 'this' bound to the element."
|
||||
),
|
||||
"demo": "ref-on-demo",
|
||||
"example": (
|
||||
'(button\n'
|
||||
' :sx-on:click "document.getElementById(\'ref-on-result\')\n'
|
||||
' .textContent = \'Clicked at \' + new Date()\n'
|
||||
' .toLocaleTimeString()"\n'
|
||||
' "Click me")'
|
||||
),
|
||||
},
|
||||
|
||||
# --- Unique to sx ---
|
||||
"sx-retry": {
|
||||
"description": (
|
||||
"Enables exponential backoff retry on request failure. "
|
||||
'Set to "true" for default retry behavior (3 attempts, 1s/2s/4s delays) '
|
||||
"or provide a custom retry count."
|
||||
),
|
||||
"demo": "ref-retry-demo",
|
||||
"example": (
|
||||
'(button :sx-get "/reference/api/flaky"\n'
|
||||
' :sx-target "#ref-retry-result"\n'
|
||||
' :sx-swap "innerHTML"\n'
|
||||
' :sx-retry "true"\n'
|
||||
' "Call flaky endpoint")'
|
||||
),
|
||||
"handler": (
|
||||
'(defhandler ref-flaky (&key)\n'
|
||||
' (let ((n (inc-counter "ref-flaky")))\n'
|
||||
' (if (!= (mod n 3) 0)\n'
|
||||
' (error 503)\n'
|
||||
' (span "Success on attempt " n "!"))))'
|
||||
),
|
||||
},
|
||||
"data-sx": {
|
||||
"description": (
|
||||
"Client-side rendering — evaluates the s-expression source in this "
|
||||
"attribute and renders the result into the element. No server request "
|
||||
"is made. Useful for purely client-side UI and interactive components."
|
||||
),
|
||||
"demo": "ref-data-sx-demo",
|
||||
"example": (
|
||||
'(div :data-sx "(div :class \\"p-3 bg-violet-50 rounded\\"\n'
|
||||
' (h3 :class \\"font-semibold\\" \\"Client-rendered\\")\n'
|
||||
' (p \\"Evaluated in the browser.\\")")'
|
||||
),
|
||||
},
|
||||
"data-sx-env": {
|
||||
"description": (
|
||||
"Provides environment variables as a JSON object for data-sx rendering. "
|
||||
"These values are available as variables in the s-expression."
|
||||
),
|
||||
"demo": "ref-data-sx-env-demo",
|
||||
"example": (
|
||||
'(div\n'
|
||||
' :data-sx "(div (h3 title) (p message))"\n'
|
||||
' :data-sx-env \'{"title": "Dynamic", "message": "From env"}\')'
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -35,9 +35,15 @@
|
||||
(map (fn (cell) (td :class "px-3 py-2 text-stone-700" cell)) row)))
|
||||
rows)))))
|
||||
|
||||
(defcomp ~doc-attr-row (&key attr description exists)
|
||||
(defcomp ~doc-attr-row (&key attr description exists href)
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700 whitespace-nowrap" attr)
|
||||
(td :class "px-3 py-2 font-mono text-sm whitespace-nowrap"
|
||||
(if href
|
||||
(a :href href
|
||||
:sx-get href :sx-target "#main-panel" :sx-select "#main-panel"
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class "text-violet-700 hover:text-violet-900 underline" attr)
|
||||
(span :class "text-violet-700" attr)))
|
||||
(td :class "px-3 py-2 text-stone-700 text-sm" description)
|
||||
(td :class "px-3 py-2 text-center"
|
||||
(if exists
|
||||
|
||||
149
sx/sxc/handlers/reference.sx
Normal file
149
sx/sxc/handlers/reference.sx
Normal file
@@ -0,0 +1,149 @@
|
||||
;; SX reference — defhandler definitions for attribute detail demos
|
||||
;;
|
||||
;; These serve the live demos on the Reference > Attributes detail pages.
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Shared: return server time
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defhandler ref-time (&key)
|
||||
(let ((now (format-time (now) "%H:%M:%S")))
|
||||
(span :class "text-stone-800 text-sm"
|
||||
"Server time: " (strong now))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-post: greet
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defhandler ref-greet (&key)
|
||||
(let ((name (or (form-data "name") "stranger")))
|
||||
(span :class "text-stone-800 text-sm"
|
||||
"Hello, " (strong name) "!")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-put: update status
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defhandler ref-status (&key)
|
||||
(let ((status (or (form-data "status") "unknown")))
|
||||
(span :class "text-stone-700 text-sm"
|
||||
"Status: " (strong status) " — updated via PUT")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-patch: update theme
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defhandler ref-theme (&key)
|
||||
(let ((theme (or (form-data "theme") "unknown")))
|
||||
(str theme)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-delete: remove item
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defhandler ref-delete (&key item-id)
|
||||
"")
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-trigger: search
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defhandler ref-trigger-search (&key)
|
||||
(let ((q (or (request-arg "q") "")))
|
||||
(if (empty? q)
|
||||
(span :class "text-stone-400 text-sm" "Start typing to trigger a search.")
|
||||
(span :class "text-stone-800 text-sm"
|
||||
"Results for: " (strong q)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-swap: new item
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defhandler ref-swap-item (&key)
|
||||
(let ((now (format-time (now) "%H:%M:%S")))
|
||||
(div :class "text-sm text-violet-700"
|
||||
"New item (" now ")")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-swap-oob: update two targets
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defhandler ref-oob (&key)
|
||||
(let ((now (format-time (now) "%H:%M:%S")))
|
||||
(<>
|
||||
(span :class "text-emerald-700 text-sm"
|
||||
"Main updated at " now)
|
||||
(div :id "ref-oob-side" :sx-swap-oob "innerHTML"
|
||||
(span :class "text-violet-700 text-sm"
|
||||
"OOB updated at " now)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-select: page with multiple sections
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defhandler ref-select-page (&key)
|
||||
(let ((now (format-time (now) "%H:%M:%S")))
|
||||
(<>
|
||||
(div :id "the-header"
|
||||
(h3 "Page header — not selected"))
|
||||
(div :id "the-content"
|
||||
(span :class "text-emerald-700 text-sm"
|
||||
"This fragment was selected from a larger response. Time: " now))
|
||||
(div :id "the-footer"
|
||||
(p "Page footer — not selected")))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-sync: slow echo
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defhandler ref-slow-echo (&key)
|
||||
(sleep 800)
|
||||
(let ((q (or (request-arg "q") "")))
|
||||
(span :class "text-stone-800 text-sm"
|
||||
"Echo: " (strong q))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-encoding: file upload name
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defhandler ref-upload-name (&key)
|
||||
(let ((name (or (file-name "file") "(no file)")))
|
||||
(span :class "text-stone-800 text-sm"
|
||||
"Received: " (strong name))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-headers: echo custom headers
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defhandler ref-echo-headers (&key)
|
||||
(let ((headers (request-headers :prefix "X-")))
|
||||
(if (empty? headers)
|
||||
(span :class "text-stone-400 text-sm" "No custom headers received.")
|
||||
(ul :class "text-sm text-stone-700 space-y-1"
|
||||
(map (fn (h)
|
||||
(li (strong (first h)) ": " (last h)))
|
||||
headers)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-include / sx-vals: echo all values
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defhandler ref-echo-vals (&key)
|
||||
(let ((vals (request-args)))
|
||||
(if (empty? vals)
|
||||
(span :class "text-stone-400 text-sm" "No values received.")
|
||||
(ul :class "text-sm text-stone-700 space-y-1"
|
||||
(map (fn (v)
|
||||
(li (strong (first v)) ": " (last v)))
|
||||
vals)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-retry: flaky endpoint
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defhandler ref-flaky (&key)
|
||||
(let ((n (inc-counter "ref-flaky")))
|
||||
(if (!= (mod n 3) 0)
|
||||
(error 503)
|
||||
(span :class "text-emerald-700 text-sm"
|
||||
"Success on attempt " n "!"))))
|
||||
@@ -148,6 +148,7 @@ def _register_sx_helpers() -> None:
|
||||
from shared.sx.pages import register_page_helpers
|
||||
from sxc.sx_components import (
|
||||
_docs_content_sx, _reference_content_sx,
|
||||
_reference_index_sx, _reference_attr_detail_sx,
|
||||
_protocol_content_sx, _examples_content_sx,
|
||||
_essay_content_sx,
|
||||
_docs_nav_sx, _reference_nav_sx,
|
||||
@@ -189,6 +190,8 @@ def _register_sx_helpers() -> None:
|
||||
"home-content": _home_content,
|
||||
"docs-content": _docs_content_sx,
|
||||
"reference-content": _reference_content_sx,
|
||||
"reference-index-content": _reference_index_sx,
|
||||
"reference-attr-detail": _reference_attr_detail_sx,
|
||||
"protocol-content": _protocol_content_sx,
|
||||
"examples-content": _examples_content_sx,
|
||||
"essay-content": _essay_content_sx,
|
||||
|
||||
@@ -48,9 +48,9 @@
|
||||
:section "Reference"
|
||||
:sub-label "Reference"
|
||||
:sub-href "/reference/"
|
||||
:sub-nav (reference-nav "Attributes")
|
||||
:selected "Attributes")
|
||||
:content (reference-content ""))
|
||||
:sub-nav (reference-nav "")
|
||||
:selected "")
|
||||
:content (reference-index-content))
|
||||
|
||||
(defpage reference-page
|
||||
:path "/reference/<slug>"
|
||||
@@ -63,6 +63,17 @@
|
||||
:selected (or (find-current REFERENCE_NAV slug) ""))
|
||||
:content (reference-content slug))
|
||||
|
||||
(defpage reference-attr-detail
|
||||
:path "/reference/attributes/<slug>"
|
||||
:auth :public
|
||||
:layout (:sx-section
|
||||
:section "Reference"
|
||||
:sub-label "Reference"
|
||||
:sub-href "/reference/"
|
||||
:sub-nav (reference-nav "Attributes")
|
||||
:selected "Attributes")
|
||||
:content (reference-attr-detail slug))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Protocols section
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
408
sx/sxc/reference.sx
Normal file
408
sx/sxc/reference.sx
Normal file
@@ -0,0 +1,408 @@
|
||||
;; SX reference — demo components for attribute detail pages
|
||||
;;
|
||||
;; Each attribute gets a small, focused demo showing exactly
|
||||
;; what that attribute does.
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-get
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-get-demo ()
|
||||
(div :class "space-y-3"
|
||||
(button
|
||||
:sx-get "/reference/api/time"
|
||||
:sx-target "#ref-get-result"
|
||||
:sx-swap "innerHTML"
|
||||
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||
"Load server time")
|
||||
(div :id "ref-get-result"
|
||||
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
|
||||
"Click to load.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-post
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-post-demo ()
|
||||
(div :class "space-y-3"
|
||||
(form
|
||||
:sx-post "/reference/api/greet"
|
||||
:sx-target "#ref-post-result"
|
||||
:sx-swap "innerHTML"
|
||||
:class "flex gap-2"
|
||||
(input :type "text" :name "name" :placeholder "Your name"
|
||||
:class "flex-1 px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500")
|
||||
(button :type "submit"
|
||||
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||
"Greet"))
|
||||
(div :id "ref-post-result"
|
||||
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
|
||||
"Submit to see greeting.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-put
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-put-demo ()
|
||||
(div :id "ref-put-view"
|
||||
(div :class "flex items-center justify-between p-3 bg-stone-50 rounded"
|
||||
(span :class "text-stone-700 text-sm" "Status: " (strong "draft"))
|
||||
(button
|
||||
:sx-put "/reference/api/status"
|
||||
:sx-target "#ref-put-view"
|
||||
:sx-swap "innerHTML"
|
||||
:sx-vals "{\"status\": \"published\"}"
|
||||
:class "px-3 py-1 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
|
||||
"Publish"))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-delete
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-delete-demo ()
|
||||
(div :class "space-y-2"
|
||||
(div :id "ref-del-1" :class "flex items-center justify-between p-2 border border-stone-200 rounded"
|
||||
(span :class "text-sm text-stone-700" "Item A")
|
||||
(button :sx-delete "/reference/api/item/1"
|
||||
:sx-target "#ref-del-1" :sx-swap "delete"
|
||||
:class "text-red-500 text-sm hover:text-red-700" "Remove"))
|
||||
(div :id "ref-del-2" :class "flex items-center justify-between p-2 border border-stone-200 rounded"
|
||||
(span :class "text-sm text-stone-700" "Item B")
|
||||
(button :sx-delete "/reference/api/item/2"
|
||||
:sx-target "#ref-del-2" :sx-swap "delete"
|
||||
:class "text-red-500 text-sm hover:text-red-700" "Remove"))
|
||||
(div :id "ref-del-3" :class "flex items-center justify-between p-2 border border-stone-200 rounded"
|
||||
(span :class "text-sm text-stone-700" "Item C")
|
||||
(button :sx-delete "/reference/api/item/3"
|
||||
:sx-target "#ref-del-3" :sx-swap "delete"
|
||||
:class "text-red-500 text-sm hover:text-red-700" "Remove"))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-patch
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-patch-demo ()
|
||||
(div :id "ref-patch-view" :class "space-y-2"
|
||||
(div :class "p-3 bg-stone-50 rounded"
|
||||
(span :class "text-stone-700 text-sm" "Theme: " (strong :id "ref-patch-val" "light")))
|
||||
(div :class "flex gap-2"
|
||||
(button :sx-patch "/reference/api/theme"
|
||||
:sx-vals "{\"theme\": \"dark\"}"
|
||||
:sx-target "#ref-patch-val" :sx-swap "innerHTML"
|
||||
:class "px-3 py-1 bg-stone-800 text-white rounded text-sm" "Dark")
|
||||
(button :sx-patch "/reference/api/theme"
|
||||
:sx-vals "{\"theme\": \"light\"}"
|
||||
:sx-target "#ref-patch-val" :sx-swap "innerHTML"
|
||||
:class "px-3 py-1 bg-white border border-stone-300 text-stone-700 rounded text-sm" "Light"))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-trigger
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-trigger-demo ()
|
||||
(div :class "space-y-3"
|
||||
(input :type "text" :name "q" :placeholder "Type to search..."
|
||||
:sx-get "/reference/api/trigger-search"
|
||||
:sx-trigger "input changed delay:300ms"
|
||||
:sx-target "#ref-trigger-result"
|
||||
:sx-swap "innerHTML"
|
||||
:class "w-full px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500")
|
||||
(div :id "ref-trigger-result"
|
||||
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
|
||||
"Start typing to trigger a search.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-target
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-target-demo ()
|
||||
(div :class "space-y-3"
|
||||
(div :class "flex gap-2"
|
||||
(button :sx-get "/reference/api/time"
|
||||
:sx-target "#ref-target-a"
|
||||
:sx-swap "innerHTML"
|
||||
:class "px-3 py-1 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
|
||||
"Update Box A")
|
||||
(button :sx-get "/reference/api/time"
|
||||
:sx-target "#ref-target-b"
|
||||
:sx-swap "innerHTML"
|
||||
:class "px-3 py-1 bg-emerald-600 text-white rounded text-sm hover:bg-emerald-700"
|
||||
"Update Box B"))
|
||||
(div :class "grid grid-cols-2 gap-3"
|
||||
(div :id "ref-target-a" :class "p-3 rounded border border-violet-200 bg-violet-50 text-sm text-stone-500"
|
||||
"Box A")
|
||||
(div :id "ref-target-b" :class "p-3 rounded border border-emerald-200 bg-emerald-50 text-sm text-stone-500"
|
||||
"Box B"))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-swap
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-swap-demo ()
|
||||
(div :class "space-y-3"
|
||||
(div :class "flex gap-2 flex-wrap"
|
||||
(button :sx-get "/reference/api/swap-item"
|
||||
:sx-target "#ref-swap-list" :sx-swap "beforeend"
|
||||
:class "px-3 py-1 bg-violet-600 text-white rounded text-sm" "beforeend")
|
||||
(button :sx-get "/reference/api/swap-item"
|
||||
:sx-target "#ref-swap-list" :sx-swap "afterbegin"
|
||||
:class "px-3 py-1 bg-emerald-600 text-white rounded text-sm" "afterbegin")
|
||||
(button :sx-get "/reference/api/swap-item"
|
||||
:sx-target "#ref-swap-list" :sx-swap "innerHTML"
|
||||
:class "px-3 py-1 bg-blue-600 text-white rounded text-sm" "innerHTML"))
|
||||
(div :id "ref-swap-list"
|
||||
:class "p-3 rounded border border-stone-200 space-y-1 min-h-[3rem]"
|
||||
(div :class "text-sm text-stone-500" "Original item"))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-swap-oob
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-oob-demo ()
|
||||
(div :class "space-y-3"
|
||||
(button :sx-get "/reference/api/oob"
|
||||
:sx-target "#ref-oob-main"
|
||||
:sx-swap "innerHTML"
|
||||
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
|
||||
"Update both boxes")
|
||||
(div :class "grid grid-cols-2 gap-3"
|
||||
(div :class "rounded border border-stone-200 p-3"
|
||||
(div :class "text-xs text-stone-400 mb-1" "Main target")
|
||||
(div :id "ref-oob-main" :class "text-sm text-stone-500" "Waiting..."))
|
||||
(div :class "rounded border border-stone-200 p-3"
|
||||
(div :class "text-xs text-stone-400 mb-1" "OOB target")
|
||||
(div :id "ref-oob-side" :class "text-sm text-stone-500" "Waiting...")))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-select
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-select-demo ()
|
||||
(div :class "space-y-3"
|
||||
(button :sx-get "/reference/api/select-page"
|
||||
:sx-target "#ref-select-result"
|
||||
:sx-select "#the-content"
|
||||
:sx-swap "innerHTML"
|
||||
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
|
||||
"Load (selecting #the-content)")
|
||||
(div :id "ref-select-result"
|
||||
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
|
||||
"Only the selected fragment will appear here.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-confirm
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-confirm-demo ()
|
||||
(div :class "space-y-2"
|
||||
(div :id "ref-confirm-item"
|
||||
:class "flex items-center justify-between p-3 border border-stone-200 rounded"
|
||||
(span :class "text-sm text-stone-700" "Important file.txt")
|
||||
(button :sx-delete "/reference/api/item/confirm"
|
||||
:sx-target "#ref-confirm-item" :sx-swap "delete"
|
||||
:sx-confirm "Are you sure you want to delete this file?"
|
||||
:class "px-3 py-1 text-red-500 text-sm border border-red-200 rounded hover:bg-red-50"
|
||||
"Delete"))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-push-url
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-pushurl-demo ()
|
||||
(div :class "space-y-3"
|
||||
(div :class "flex gap-2"
|
||||
(a :href "/reference/attributes/sx-get"
|
||||
:sx-get "/reference/attributes/sx-get"
|
||||
:sx-target "#main-panel" :sx-select "#main-panel"
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class "px-3 py-1 bg-violet-100 text-violet-700 rounded text-sm no-underline hover:bg-violet-200"
|
||||
"sx-get page")
|
||||
(a :href "/reference/attributes/sx-post"
|
||||
:sx-get "/reference/attributes/sx-post"
|
||||
:sx-target "#main-panel" :sx-select "#main-panel"
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
:class "px-3 py-1 bg-violet-100 text-violet-700 rounded text-sm no-underline hover:bg-violet-200"
|
||||
"sx-post page"))
|
||||
(p :class "text-sm text-stone-500"
|
||||
"Click a link — the URL bar updates without a full page reload. Use browser back to return.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-sync
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-sync-demo ()
|
||||
(div :class "space-y-3"
|
||||
(input :type "text" :name "q" :placeholder "Type quickly..."
|
||||
:sx-get "/reference/api/slow-echo"
|
||||
:sx-trigger "input changed delay:100ms"
|
||||
:sx-sync "replace"
|
||||
:sx-target "#ref-sync-result"
|
||||
:sx-swap "innerHTML"
|
||||
:class "w-full px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500")
|
||||
(p :class "text-xs text-stone-400"
|
||||
"With sync:replace, each new keystroke aborts the in-flight request.")
|
||||
(div :id "ref-sync-result"
|
||||
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
|
||||
"Type to see only the latest result.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-encoding
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-encoding-demo ()
|
||||
(div :class "space-y-3"
|
||||
(form :sx-post "/reference/api/upload-name"
|
||||
:sx-encoding "multipart/form-data"
|
||||
:sx-target "#ref-encoding-result"
|
||||
:sx-swap "innerHTML"
|
||||
:class "flex gap-2"
|
||||
(input :type "file" :name "file"
|
||||
:class "flex-1 text-sm text-stone-500 file:mr-2 file:px-3 file:py-1 file:rounded file:border-0 file:text-sm file:bg-violet-50 file:text-violet-700")
|
||||
(button :type "submit"
|
||||
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
|
||||
"Upload"))
|
||||
(div :id "ref-encoding-result"
|
||||
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
|
||||
"Select a file and submit.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-headers
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-headers-demo ()
|
||||
(div :class "space-y-3"
|
||||
(button :sx-get "/reference/api/echo-headers"
|
||||
:sx-headers "{\"X-Custom-Token\": \"abc123\", \"X-Request-Source\": \"demo\"}"
|
||||
:sx-target "#ref-headers-result"
|
||||
:sx-swap "innerHTML"
|
||||
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
|
||||
"Send with custom headers")
|
||||
(div :id "ref-headers-result"
|
||||
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
|
||||
"Click to see echoed headers.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-include
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-include-demo ()
|
||||
(div :class "space-y-3"
|
||||
(div :class "flex gap-2 items-end"
|
||||
(div
|
||||
(label :class "block text-xs text-stone-500 mb-1" "Category")
|
||||
(select :id "ref-inc-cat" :name "category"
|
||||
:class "px-3 py-2 border border-stone-300 rounded text-sm"
|
||||
(option :value "all" "All")
|
||||
(option :value "books" "Books")
|
||||
(option :value "tools" "Tools")))
|
||||
(button :sx-get "/reference/api/echo-vals"
|
||||
:sx-include "#ref-inc-cat"
|
||||
:sx-target "#ref-include-result"
|
||||
:sx-swap "innerHTML"
|
||||
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
|
||||
"Filter"))
|
||||
(div :id "ref-include-result"
|
||||
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
|
||||
"Click Filter — the select value is included in the request.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-vals
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-vals-demo ()
|
||||
(div :class "space-y-3"
|
||||
(button :sx-post "/reference/api/echo-vals"
|
||||
:sx-vals "{\"source\": \"demo\", \"page\": \"3\"}"
|
||||
:sx-target "#ref-vals-result"
|
||||
:sx-swap "innerHTML"
|
||||
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
|
||||
"Send with extra values")
|
||||
(div :id "ref-vals-result"
|
||||
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
|
||||
"Click to see echoed values.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-media
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-media-demo ()
|
||||
(div :class "space-y-3"
|
||||
(a :href "/reference/attributes/sx-get"
|
||||
:sx-get "/reference/attributes/sx-get"
|
||||
:sx-target "#main-panel" :sx-select "#main-panel"
|
||||
:sx-swap "outerHTML" :sx-push-url "true"
|
||||
:sx-media "(min-width: 768px)"
|
||||
:class "inline-block px-4 py-2 bg-violet-600 text-white rounded text-sm no-underline hover:bg-violet-700"
|
||||
"sx navigation (desktop only)")
|
||||
(p :class "text-sm text-stone-500"
|
||||
"On screens narrower than 768px this link uses normal navigation. On wider screens it uses sx.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-disable
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-disable-demo ()
|
||||
(div :class "space-y-3"
|
||||
(div :class "grid grid-cols-2 gap-3"
|
||||
(div :class "p-3 border border-stone-200 rounded"
|
||||
(p :class "text-xs text-stone-400 mb-2" "sx enabled")
|
||||
(button :sx-get "/reference/api/time"
|
||||
:sx-target "#ref-dis-a" :sx-swap "innerHTML"
|
||||
:class "px-3 py-1 bg-violet-600 text-white rounded text-sm" "Load")
|
||||
(div :id "ref-dis-a" :class "mt-2 text-sm text-stone-500" "—"))
|
||||
(div :sx-disable "true" :class "p-3 border border-stone-200 rounded"
|
||||
(p :class "text-xs text-stone-400 mb-2" "sx disabled")
|
||||
(button :sx-get "/reference/api/time"
|
||||
:sx-target "#ref-dis-b" :sx-swap "innerHTML"
|
||||
:class "px-3 py-1 bg-stone-400 text-white rounded text-sm" "Load")
|
||||
(div :id "ref-dis-b" :class "mt-2 text-sm text-stone-500"
|
||||
"Button won't fire sx request")))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-on:*
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-on-demo ()
|
||||
(div :class "space-y-3"
|
||||
(button
|
||||
:sx-on:click "document.getElementById('ref-on-result').textContent = 'Clicked at ' + new Date().toLocaleTimeString()"
|
||||
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
|
||||
"Click me")
|
||||
(div :id "ref-on-result"
|
||||
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
|
||||
"Click the button — runs JavaScript, no server request.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; sx-retry
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-retry-demo ()
|
||||
(div :class "space-y-3"
|
||||
(button :sx-get "/reference/api/flaky"
|
||||
:sx-target "#ref-retry-result"
|
||||
:sx-swap "innerHTML"
|
||||
:sx-retry "true"
|
||||
:class "px-4 py-2 bg-violet-600 text-white rounded text-sm hover:bg-violet-700"
|
||||
"Call flaky endpoint")
|
||||
(div :id "ref-retry-result"
|
||||
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
|
||||
"This endpoint fails 2 out of 3 times. sx-retry retries automatically.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; data-sx
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-data-sx-demo ()
|
||||
(div :class "space-y-3"
|
||||
(div :data-sx "(div :class \"p-3 bg-violet-50 rounded\" (h3 :class \"font-semibold text-violet-800\" \"Client-rendered\") (p :class \"text-sm text-stone-600\" \"This was evaluated in the browser — no server request.\"))")
|
||||
(p :class "text-xs text-stone-400" "The content above is rendered client-side from the data-sx attribute.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; data-sx-env
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~ref-data-sx-env-demo ()
|
||||
(div :class "space-y-3"
|
||||
(div :data-sx "(div :class \"p-3 bg-emerald-50 rounded\" (h3 :class \"font-semibold text-emerald-800\" title) (p :class \"text-sm text-stone-600\" message))"
|
||||
:data-sx-env "{\"title\": \"Dynamic content\", \"message\": \"Variables passed via data-sx-env are available in the expression.\"}")
|
||||
(p :class "text-xs text-stone-400" "The title and message above come from the data-sx-env JSON.")))
|
||||
@@ -189,10 +189,13 @@ def _doc_nav_sx(items: list[tuple[str, str]], current: str) -> str:
|
||||
|
||||
def _attr_table_sx(title: str, attrs: list[tuple[str, str, bool]]) -> str:
|
||||
"""Build an attribute reference table."""
|
||||
from content.pages import ATTR_DETAILS
|
||||
rows = []
|
||||
for attr, desc, exists in attrs:
|
||||
href = f"/reference/attributes/{attr}" if exists and attr in ATTR_DETAILS else None
|
||||
rows.append(sx_call("doc-attr-row", attr=attr, description=desc,
|
||||
exists="true" if exists else None))
|
||||
exists="true" if exists else None,
|
||||
href=href))
|
||||
return (
|
||||
f'(div :class "space-y-3"'
|
||||
f' (h3 :class "text-xl font-semibold text-stone-700" "{title}")'
|
||||
@@ -470,7 +473,6 @@ def _docs_server_rendering_sx() -> str:
|
||||
|
||||
def _reference_content_sx(slug: str) -> str:
|
||||
builders = {
|
||||
"": _reference_attrs_sx,
|
||||
"attributes": _reference_attrs_sx,
|
||||
"headers": _reference_headers_sx,
|
||||
"events": _reference_events_sx,
|
||||
@@ -479,6 +481,98 @@ def _reference_content_sx(slug: str) -> str:
|
||||
return builders.get(slug or "", _reference_attrs_sx)()
|
||||
|
||||
|
||||
def _reference_index_sx() -> str:
|
||||
"""Build the reference index page with links to sub-sections."""
|
||||
sections = [
|
||||
("Attributes", "/reference/attributes",
|
||||
"All sx attributes — request verbs, behavior modifiers, and sx-unique features."),
|
||||
("Headers", "/reference/headers",
|
||||
"Custom HTTP headers used to coordinate between the sx client and server."),
|
||||
("Events", "/reference/events",
|
||||
"DOM events fired during the sx request lifecycle."),
|
||||
("JS API", "/reference/js-api",
|
||||
"JavaScript functions for parsing, evaluating, and rendering s-expressions."),
|
||||
]
|
||||
cards = []
|
||||
for label, href, desc in sections:
|
||||
cards.append(
|
||||
f'(a :href "{href}"'
|
||||
f' :sx-get "{href}" :sx-target "#main-panel" :sx-select "#main-panel"'
|
||||
f' :sx-swap "outerHTML" :sx-push-url "true"'
|
||||
f' :class "block p-5 rounded-lg border border-stone-200 hover:border-violet-300'
|
||||
f' hover:shadow-sm transition-all no-underline"'
|
||||
f' (h3 :class "text-lg font-semibold text-violet-700 mb-1" "{label}")'
|
||||
f' (p :class "text-stone-600 text-sm" "{desc}"))'
|
||||
)
|
||||
return (
|
||||
f'(~doc-page :title "Reference"'
|
||||
f' (p :class "text-stone-600 mb-6"'
|
||||
f' "Complete reference for the sx client library.")'
|
||||
f' (div :class "grid gap-4 sm:grid-cols-2"'
|
||||
f' {" ".join(cards)}))'
|
||||
)
|
||||
|
||||
|
||||
def _reference_attr_detail_sx(slug: str) -> str:
|
||||
"""Build a detail page for a single sx attribute."""
|
||||
from content.pages import ATTR_DETAILS
|
||||
detail = ATTR_DETAILS.get(slug)
|
||||
if not detail:
|
||||
return (
|
||||
f'(~doc-page :title "Not Found"'
|
||||
f' (p :class "text-stone-600"'
|
||||
f' "No documentation found for \\"{slug}\\"."))'
|
||||
)
|
||||
|
||||
title = slug
|
||||
desc = detail["description"]
|
||||
escaped_desc = desc.replace('\\', '\\\\').replace('"', '\\"')
|
||||
|
||||
# Live demo
|
||||
demo_name = detail.get("demo")
|
||||
demo_sx = ""
|
||||
if demo_name:
|
||||
demo_sx = (
|
||||
f' (~example-card :title "Demo"'
|
||||
f' (~example-demo (~{demo_name})))'
|
||||
)
|
||||
|
||||
# S-expression source
|
||||
example_sx = _example_code(detail["example"], "lisp")
|
||||
|
||||
# Server handler (s-expression)
|
||||
handler_sx = ""
|
||||
if "handler" in detail:
|
||||
handler_sx = (
|
||||
f' (h3 :class "text-lg font-semibold text-stone-700 mt-6"'
|
||||
f' "Server handler")'
|
||||
f' {_example_code(detail["handler"], "lisp")}'
|
||||
)
|
||||
|
||||
# Wire response placeholder (only for attrs with server interaction)
|
||||
wire_sx = ""
|
||||
if "handler" in detail:
|
||||
wire_id = slug.replace(":", "-").replace("*", "star")
|
||||
wire_sx = (
|
||||
f' (h3 :class "text-lg font-semibold text-stone-700 mt-6"'
|
||||
f' "Wire response")'
|
||||
f' (p :class "text-stone-500 text-sm mb-2"'
|
||||
f' "Trigger the demo to see the raw response the server sends.")'
|
||||
f' {_placeholder("ref-wire-" + wire_id)}'
|
||||
)
|
||||
|
||||
return (
|
||||
f'(~doc-page :title "{title}"'
|
||||
f' (p :class "text-stone-600 mb-6" "{escaped_desc}")'
|
||||
f' {demo_sx}'
|
||||
f' (h3 :class "text-lg font-semibold text-stone-700 mt-6"'
|
||||
f' "S-expression")'
|
||||
f' {example_sx}'
|
||||
f' {handler_sx}'
|
||||
f' {wire_sx})'
|
||||
)
|
||||
|
||||
|
||||
def _reference_attrs_sx() -> str:
|
||||
from content.pages import REQUEST_ATTRS, BEHAVIOR_ATTRS, SX_UNIQUE_ATTRS, HTMX_MISSING_ATTRS
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user