Add SX editor to post edit page, prevent sx_content clearing on save
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m5s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m5s
- Add sx_content to _post_to_edit_dict so edit page receives existing content - Add SX/Koenig editor tabs, sx-editor mount point, and SxEditor.mount init - Only pass sx_content to writer_update when form field is present (prevents accidental clearing when editing via Koenig-only path) - Add csrf_exempt to example API POST/DELETE/PUT demo endpoints - Add defpage infrastructure (pages.py, layouts.py) and sx docs page definitions - Add defhandler definitions for example API handlers (examples.sx) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ def _post_to_edit_dict(post) -> dict:
|
|||||||
d: dict = {}
|
d: dict = {}
|
||||||
for col in (
|
for col in (
|
||||||
"id", "slug", "title", "html", "plaintext", "lexical", "mobiledoc",
|
"id", "slug", "title", "html", "plaintext", "lexical", "mobiledoc",
|
||||||
|
"sx_content",
|
||||||
"feature_image", "feature_image_alt", "feature_image_caption",
|
"feature_image", "feature_image_alt", "feature_image_caption",
|
||||||
"excerpt", "custom_excerpt", "visibility", "status", "featured",
|
"excerpt", "custom_excerpt", "visibility", "status", "featured",
|
||||||
"is_page", "email_only", "canonical_url",
|
"is_page", "email_only", "canonical_url",
|
||||||
@@ -595,6 +596,10 @@ def register():
|
|||||||
effective_status = status
|
effective_status = status
|
||||||
|
|
||||||
sx_content_raw = form.get("sx_content", "").strip() or None
|
sx_content_raw = form.get("sx_content", "").strip() or None
|
||||||
|
# Build optional kwargs — only pass sx_content if the form field was present
|
||||||
|
extra_kw: dict = {}
|
||||||
|
if "sx_content" in form:
|
||||||
|
extra_kw["sx_content"] = sx_content_raw
|
||||||
try:
|
try:
|
||||||
post = await writer_update(
|
post = await writer_update(
|
||||||
g.s,
|
g.s,
|
||||||
@@ -606,7 +611,7 @@ def register():
|
|||||||
custom_excerpt=custom_excerpt or None,
|
custom_excerpt=custom_excerpt or None,
|
||||||
feature_image_caption=feature_image_caption or None,
|
feature_image_caption=feature_image_caption or None,
|
||||||
status=effective_status,
|
status=effective_status,
|
||||||
sx_content=sx_content_raw,
|
**extra_kw,
|
||||||
)
|
)
|
||||||
except OptimisticLockError:
|
except OptimisticLockError:
|
||||||
return redirect(
|
return redirect(
|
||||||
|
|||||||
@@ -1755,6 +1755,7 @@ def _post_edit_content_sx(ctx: dict) -> str:
|
|||||||
asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "")
|
asset_url_fn = current_app.jinja_env.globals.get("asset_url", lambda p: "")
|
||||||
editor_css = asset_url_fn("scripts/editor.css")
|
editor_css = asset_url_fn("scripts/editor.css")
|
||||||
editor_js = asset_url_fn("scripts/editor.js")
|
editor_js = asset_url_fn("scripts/editor.js")
|
||||||
|
sx_editor_js = asset_url_fn("scripts/sx-editor.js")
|
||||||
|
|
||||||
upload_image_url = qurl("blog.editor_api.upload_image")
|
upload_image_url = qurl("blog.editor_api.upload_image")
|
||||||
upload_media_url = qurl("blog.editor_api.upload_media")
|
upload_media_url = qurl("blog.editor_api.upload_media")
|
||||||
@@ -1773,6 +1774,7 @@ def _post_edit_content_sx(ctx: dict) -> str:
|
|||||||
updated_at = esc(ghost_post.get("updated_at") or "")
|
updated_at = esc(ghost_post.get("updated_at") or "")
|
||||||
status = ghost_post.get("status") or "draft"
|
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}}'
|
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 ""
|
||||||
|
|
||||||
already_emailed = bool(ghost_post and ghost_post.get("email") and (ghost_post["email"] if isinstance(ghost_post["email"], dict) else {}).get("status"))
|
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
|
# For ORM objects the email may be an object
|
||||||
@@ -1799,6 +1801,7 @@ def _post_edit_content_sx(ctx: dict) -> str:
|
|||||||
form_parts.append(f'<input type="hidden" name="csrf_token" value="{csrf}">')
|
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(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('<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-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)}">')
|
form_parts.append(f'<input type="hidden" id="feature-image-caption-input" name="feature_image_caption" value="{esc(feature_image_caption)}">')
|
||||||
|
|
||||||
@@ -1830,8 +1833,24 @@ def _post_edit_content_sx(ctx: dict) -> str:
|
|||||||
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>'
|
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 mount point
|
# Editor tabs: SX (primary) and Koenig (legacy)
|
||||||
form_parts.append('<div id="lexical-editor" class="relative w-full bg-transparent"></div>')
|
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
|
# Initial lexical JSON
|
||||||
form_parts.append(f'<script id="lexical-initial-data" type="application/json">{lexical_json}</script>')
|
form_parts.append(f'<script id="lexical-initial-data" type="application/json">{lexical_json}</script>')
|
||||||
@@ -1902,8 +1921,10 @@ def _post_edit_content_sx(ctx: dict) -> str:
|
|||||||
'</script>'
|
'</script>'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Editor CSS + styles
|
# Editor CSS + styles + SX editor styles
|
||||||
|
from shared.sx.helpers import sx_call
|
||||||
parts.append(f'<link rel="stylesheet" href="{esc(editor_css)}">')
|
parts.append(f'<link rel="stylesheet" href="{esc(editor_css)}">')
|
||||||
|
parts.append(sx_call("sx-editor-styles"))
|
||||||
parts.append(
|
parts.append(
|
||||||
'<style>'
|
'<style>'
|
||||||
'#lexical-editor { display: flow-root; }'
|
'#lexical-editor { display: flow-root; }'
|
||||||
@@ -1912,8 +1933,13 @@ def _post_edit_content_sx(ctx: dict) -> str:
|
|||||||
'</style>'
|
'</style>'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Editor JS + init
|
# 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(editor_js)}"></script>')
|
||||||
|
parts.append(f'<script src="{esc(sx_editor_js)}"></script>')
|
||||||
parts.append(
|
parts.append(
|
||||||
'<script>'
|
'<script>'
|
||||||
'(function() {'
|
'(function() {'
|
||||||
@@ -1991,6 +2017,17 @@ def _post_edit_content_sx(ctx: dict) -> str:
|
|||||||
f" unsplashApiKey: '{unsplash_key}',"
|
f" unsplashApiKey: '{unsplash_key}',"
|
||||||
f" snippetsUrl: '{snippets_url}',"
|
f" snippetsUrl: '{snippets_url}',"
|
||||||
' });'
|
' });'
|
||||||
|
" if (typeof SxEditor !== 'undefined') {"
|
||||||
|
" SxEditor.mount('sx-editor', {"
|
||||||
|
" initialSx: window.__SX_INITIAL__ || null,"
|
||||||
|
' csrfToken: csrfToken,'
|
||||||
|
' uploadUrls: uploadUrls,'
|
||||||
|
f" oembedUrl: '{oembed_url}',"
|
||||||
|
' onChange: function(sx) {'
|
||||||
|
" document.getElementById('sx-content-input').value = sx;"
|
||||||
|
' }'
|
||||||
|
' });'
|
||||||
|
' }'
|
||||||
" document.addEventListener('keydown', function(e) {"
|
" document.addEventListener('keydown', function(e) {"
|
||||||
" if ((e.ctrlKey || e.metaKey) && e.key === 's') {"
|
" if ((e.ctrlKey || e.metaKey) && e.key === 's') {"
|
||||||
" e.preventDefault(); document.getElementById('post-edit-form').requestSubmit();"
|
" e.preventDefault(); document.getElementById('post-edit-form').requestSubmit();"
|
||||||
|
|||||||
139
shared/sx/layouts.py
Normal file
139
shared/sx/layouts.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""
|
||||||
|
Named layout presets for defpage.
|
||||||
|
|
||||||
|
Each layout generates header rows for full-page and OOB rendering.
|
||||||
|
Layouts wrap existing helper functions from ``shared.sx.helpers`` so
|
||||||
|
defpage can reference them by name (e.g. ``:layout :root``).
|
||||||
|
|
||||||
|
Layouts are registered in ``_LAYOUT_REGISTRY`` and looked up by
|
||||||
|
``get_layout()`` at request time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Awaitable
|
||||||
|
|
||||||
|
from .helpers import (
|
||||||
|
root_header_sx, post_header_sx, post_admin_header_sx,
|
||||||
|
oob_header_sx, header_child_sx,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Layout protocol
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class Layout:
|
||||||
|
"""A named layout that generates header rows for full and OOB rendering."""
|
||||||
|
|
||||||
|
__slots__ = ("name", "_full_fn", "_oob_fn")
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
full_fn: Callable[..., str | Awaitable[str]],
|
||||||
|
oob_fn: Callable[..., str | Awaitable[str]],
|
||||||
|
):
|
||||||
|
self.name = name
|
||||||
|
self._full_fn = full_fn
|
||||||
|
self._oob_fn = oob_fn
|
||||||
|
|
||||||
|
async def full_headers(self, ctx: dict, **kwargs: Any) -> str:
|
||||||
|
result = self._full_fn(ctx, **kwargs)
|
||||||
|
if hasattr(result, "__await__"):
|
||||||
|
result = await result
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def oob_headers(self, ctx: dict, **kwargs: Any) -> str:
|
||||||
|
result = self._oob_fn(ctx, **kwargs)
|
||||||
|
if hasattr(result, "__await__"):
|
||||||
|
result = await result
|
||||||
|
return result
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Layout:{self.name}>"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Registry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_LAYOUT_REGISTRY: dict[str, Layout] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register_layout(layout: Layout) -> None:
|
||||||
|
"""Register a layout preset."""
|
||||||
|
_LAYOUT_REGISTRY[layout.name] = layout
|
||||||
|
|
||||||
|
|
||||||
|
def get_layout(name: str) -> Layout | None:
|
||||||
|
"""Look up a layout by name."""
|
||||||
|
return _LAYOUT_REGISTRY.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Built-in layouts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _root_full(ctx: dict, **kw: Any) -> str:
|
||||||
|
return root_header_sx(ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def _root_oob(ctx: dict, **kw: Any) -> str:
|
||||||
|
root_hdr = root_header_sx(ctx)
|
||||||
|
return oob_header_sx("root-header-child", "root-header-child", root_hdr)
|
||||||
|
|
||||||
|
|
||||||
|
def _post_full(ctx: dict, **kw: Any) -> str:
|
||||||
|
root_hdr = root_header_sx(ctx)
|
||||||
|
post_hdr = post_header_sx(ctx)
|
||||||
|
return "(<> " + root_hdr + " " + post_hdr + ")"
|
||||||
|
|
||||||
|
|
||||||
|
def _post_oob(ctx: dict, **kw: Any) -> str:
|
||||||
|
post_hdr = post_header_sx(ctx, oob=True)
|
||||||
|
return post_hdr
|
||||||
|
|
||||||
|
|
||||||
|
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 + ")"
|
||||||
|
|
||||||
|
|
||||||
|
def _post_admin_oob(ctx: dict, **kw: Any) -> str:
|
||||||
|
slug = ctx.get("post", {}).get("slug", "")
|
||||||
|
selected = kw.get("selected", "")
|
||||||
|
post_hdr = post_header_sx(ctx, oob=True)
|
||||||
|
admin_hdr = post_admin_header_sx(ctx, slug, selected=selected)
|
||||||
|
admin_oob = oob_header_sx("post-header-child", "post-admin-header-child", admin_hdr)
|
||||||
|
return "(<> " + post_hdr + " " + admin_oob + ")"
|
||||||
|
|
||||||
|
|
||||||
|
register_layout(Layout("root", _root_full, _root_oob))
|
||||||
|
register_layout(Layout("post", _post_full, _post_oob))
|
||||||
|
register_layout(Layout("post-admin", _post_admin_full, _post_admin_oob))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Callable layout — services register custom Python layout functions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_CUSTOM_LAYOUTS: dict[str, tuple] = {} # name → (full_fn, oob_fn)
|
||||||
|
|
||||||
|
|
||||||
|
def register_custom_layout(name: str,
|
||||||
|
full_fn: Callable[..., str | Awaitable[str]],
|
||||||
|
oob_fn: Callable[..., str | Awaitable[str]]) -> None:
|
||||||
|
"""Register a custom layout function.
|
||||||
|
|
||||||
|
Used by services with non-standard header patterns::
|
||||||
|
|
||||||
|
register_custom_layout("sx-section",
|
||||||
|
full_fn=my_full_headers,
|
||||||
|
oob_fn=my_oob_headers)
|
||||||
|
"""
|
||||||
|
register_layout(Layout(name, full_fn, oob_fn))
|
||||||
362
shared/sx/pages.py
Normal file
362
shared/sx/pages.py
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
"""
|
||||||
|
Declarative page registry and blueprint mounting.
|
||||||
|
|
||||||
|
Supports ``defpage`` s-expressions that define GET route handlers
|
||||||
|
in .sx files instead of Python. Each page is a self-contained
|
||||||
|
declaration with path, auth, layout, data, and content slots.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from shared.sx.pages import load_page_file, mount_pages
|
||||||
|
|
||||||
|
# Load page definitions from .sx files
|
||||||
|
load_page_file("blog/sx/pages/admin.sx", "blog")
|
||||||
|
|
||||||
|
# Mount page routes onto an existing blueprint
|
||||||
|
mount_pages(bp, "blog")
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .types import PageDef
|
||||||
|
|
||||||
|
logger = logging.getLogger("sx.pages")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Registry — service → page-name → PageDef
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PAGE_REGISTRY: dict[str, dict[str, PageDef]] = {}
|
||||||
|
_PAGE_HELPERS: dict[str, dict[str, Any]] = {} # service → name → callable
|
||||||
|
|
||||||
|
|
||||||
|
def register_page(service: str, page_def: PageDef) -> None:
|
||||||
|
"""Register a page definition for a service."""
|
||||||
|
if service not in _PAGE_REGISTRY:
|
||||||
|
_PAGE_REGISTRY[service] = {}
|
||||||
|
_PAGE_REGISTRY[service][page_def.name] = page_def
|
||||||
|
logger.debug("Registered page %s:%s path=%s", service, page_def.name, page_def.path)
|
||||||
|
|
||||||
|
|
||||||
|
def get_page(service: str, name: str) -> PageDef | None:
|
||||||
|
"""Look up a registered page by service and name."""
|
||||||
|
return _PAGE_REGISTRY.get(service, {}).get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_pages(service: str) -> dict[str, PageDef]:
|
||||||
|
"""Return all pages for a service."""
|
||||||
|
return dict(_PAGE_REGISTRY.get(service, {}))
|
||||||
|
|
||||||
|
|
||||||
|
def clear_pages(service: str | None = None) -> None:
|
||||||
|
"""Clear page registry. If service given, clear only that service."""
|
||||||
|
if service is None:
|
||||||
|
_PAGE_REGISTRY.clear()
|
||||||
|
else:
|
||||||
|
_PAGE_REGISTRY.pop(service, None)
|
||||||
|
|
||||||
|
|
||||||
|
def register_page_helpers(service: str, helpers: dict[str, Any]) -> None:
|
||||||
|
"""Register Python functions available in defpage content expressions.
|
||||||
|
|
||||||
|
These are injected into the evaluation environment when executing
|
||||||
|
defpage content, allowing defpage to call into Python::
|
||||||
|
|
||||||
|
register_page_helpers("sx", {
|
||||||
|
"docs-content": docs_content_sx,
|
||||||
|
"reference-content": reference_content_sx,
|
||||||
|
})
|
||||||
|
|
||||||
|
Then in .sx::
|
||||||
|
|
||||||
|
(defpage docs-page
|
||||||
|
:path "/docs/<slug>"
|
||||||
|
:auth :public
|
||||||
|
:content (docs-content slug))
|
||||||
|
"""
|
||||||
|
if service not in _PAGE_HELPERS:
|
||||||
|
_PAGE_HELPERS[service] = {}
|
||||||
|
_PAGE_HELPERS[service].update(helpers)
|
||||||
|
|
||||||
|
|
||||||
|
def get_page_helpers(service: str) -> dict[str, Any]:
|
||||||
|
"""Return registered page helpers for a service."""
|
||||||
|
return dict(_PAGE_HELPERS.get(service, {}))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Loading — parse .sx files and collect PageDef instances
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def load_page_file(filepath: str, service_name: str) -> list[PageDef]:
|
||||||
|
"""Parse an .sx file, evaluate it, and register any PageDef values."""
|
||||||
|
from .parser import parse_all
|
||||||
|
from .evaluator import _eval
|
||||||
|
from .jinja_bridge import get_component_env
|
||||||
|
|
||||||
|
with open(filepath, encoding="utf-8") as f:
|
||||||
|
source = f.read()
|
||||||
|
|
||||||
|
# Seed env with component definitions so pages can reference components
|
||||||
|
env = dict(get_component_env())
|
||||||
|
exprs = parse_all(source)
|
||||||
|
pages: list[PageDef] = []
|
||||||
|
|
||||||
|
for expr in exprs:
|
||||||
|
_eval(expr, env)
|
||||||
|
|
||||||
|
# Collect all PageDef values from the env
|
||||||
|
for key, val in env.items():
|
||||||
|
if isinstance(val, PageDef):
|
||||||
|
register_page(service_name, val)
|
||||||
|
pages.append(val)
|
||||||
|
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
def load_page_dir(directory: str, service_name: str) -> list[PageDef]:
|
||||||
|
"""Load all .sx files from a directory and register pages."""
|
||||||
|
import glob as glob_mod
|
||||||
|
pages: list[PageDef] = []
|
||||||
|
for filepath in sorted(glob_mod.glob(os.path.join(directory, "*.sx"))):
|
||||||
|
pages.extend(load_page_file(filepath, service_name))
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Page execution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _eval_slot(expr: Any, env: dict, ctx: Any,
|
||||||
|
async_eval_fn: Any, async_eval_to_sx_fn: Any) -> str:
|
||||||
|
"""Evaluate a page slot expression and return an sx source string.
|
||||||
|
|
||||||
|
If the expression evaluates to a plain string (e.g. from a Python content
|
||||||
|
builder), use it directly as sx source. If it evaluates to an AST/list,
|
||||||
|
serialize it to sx wire format via async_eval_to_sx.
|
||||||
|
"""
|
||||||
|
from .html import _RawHTML
|
||||||
|
from .parser import SxExpr
|
||||||
|
# First try async_eval to get the raw value
|
||||||
|
result = await async_eval_fn(expr, env, ctx)
|
||||||
|
# If it's already an sx source string, use as-is
|
||||||
|
if isinstance(result, str):
|
||||||
|
return result
|
||||||
|
if isinstance(result, _RawHTML):
|
||||||
|
return result.html
|
||||||
|
if isinstance(result, SxExpr):
|
||||||
|
return result.source
|
||||||
|
if result is None:
|
||||||
|
return ""
|
||||||
|
# For other types (lists, components rendered to HTML via _RawHTML, etc.),
|
||||||
|
# serialize to sx wire format
|
||||||
|
from .parser import serialize
|
||||||
|
return serialize(result)
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_page(
|
||||||
|
page_def: PageDef,
|
||||||
|
service_name: str,
|
||||||
|
url_params: dict[str, Any] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Execute a declarative page and return the full response string.
|
||||||
|
|
||||||
|
1. Build env from component env + page closure + URL params
|
||||||
|
2. If :data — async_eval(data_expr) → merge result dict into env
|
||||||
|
3. Render slots: async_eval_to_sx(content_expr) etc.
|
||||||
|
4. get_template_context() for header construction
|
||||||
|
5. Resolve layout → header rows
|
||||||
|
6. Branch: full_page_sx() vs oob_page_sx() based on is_htmx_request()
|
||||||
|
"""
|
||||||
|
from .jinja_bridge import get_component_env, _get_request_context
|
||||||
|
from .async_eval import async_eval, async_eval_to_sx
|
||||||
|
from .page import get_template_context
|
||||||
|
from .helpers import full_page_sx, oob_page_sx, sx_response
|
||||||
|
from .layouts import get_layout
|
||||||
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
|
if url_params is None:
|
||||||
|
url_params = {}
|
||||||
|
|
||||||
|
# Build environment
|
||||||
|
env = dict(get_component_env())
|
||||||
|
env.update(get_page_helpers(service_name))
|
||||||
|
env.update(page_def.closure)
|
||||||
|
|
||||||
|
# Inject URL params as kebab-case symbols
|
||||||
|
for key, val in url_params.items():
|
||||||
|
kebab = key.replace("_", "-")
|
||||||
|
env[kebab] = val
|
||||||
|
env[key] = val # also provide snake_case for convenience
|
||||||
|
|
||||||
|
# Get request context for I/O primitives
|
||||||
|
ctx = _get_request_context()
|
||||||
|
|
||||||
|
# Evaluate :data expression if present
|
||||||
|
if page_def.data_expr is not None:
|
||||||
|
data_result = await async_eval(page_def.data_expr, env, ctx)
|
||||||
|
if isinstance(data_result, dict):
|
||||||
|
env.update(data_result)
|
||||||
|
|
||||||
|
# Render content slot (required)
|
||||||
|
content_sx = await _eval_slot(page_def.content_expr, env, ctx, async_eval, async_eval_to_sx)
|
||||||
|
|
||||||
|
# Render optional slots
|
||||||
|
filter_sx = ""
|
||||||
|
if page_def.filter_expr is not None:
|
||||||
|
filter_sx = await _eval_slot(page_def.filter_expr, env, ctx, async_eval, async_eval_to_sx)
|
||||||
|
|
||||||
|
aside_sx = ""
|
||||||
|
if page_def.aside_expr is not None:
|
||||||
|
aside_sx = await _eval_slot(page_def.aside_expr, env, ctx, async_eval, async_eval_to_sx)
|
||||||
|
|
||||||
|
menu_sx = ""
|
||||||
|
if page_def.menu_expr is not None:
|
||||||
|
menu_sx = await _eval_slot(page_def.menu_expr, env, ctx, async_eval, async_eval_to_sx)
|
||||||
|
|
||||||
|
# Resolve layout → header rows
|
||||||
|
tctx = await get_template_context()
|
||||||
|
header_rows = ""
|
||||||
|
oob_headers = ""
|
||||||
|
layout_kwargs: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if page_def.layout is not None:
|
||||||
|
if isinstance(page_def.layout, str):
|
||||||
|
layout_name = page_def.layout
|
||||||
|
elif isinstance(page_def.layout, list):
|
||||||
|
# (:layout-name :key val ...) — unevaluated list from defpage
|
||||||
|
# Evaluate each value in the current env to resolve dynamic bindings
|
||||||
|
from .types import Keyword as SxKeyword, Symbol as SxSymbol
|
||||||
|
raw = page_def.layout
|
||||||
|
# First element is layout name (keyword or symbol or string)
|
||||||
|
first = raw[0]
|
||||||
|
if isinstance(first, SxKeyword):
|
||||||
|
layout_name = first.name
|
||||||
|
elif isinstance(first, SxSymbol):
|
||||||
|
layout_name = first.name
|
||||||
|
elif isinstance(first, str):
|
||||||
|
layout_name = first
|
||||||
|
else:
|
||||||
|
layout_name = str(first)
|
||||||
|
# Parse keyword args — evaluate values at request time
|
||||||
|
i = 1
|
||||||
|
while i < len(raw):
|
||||||
|
k = raw[i]
|
||||||
|
if isinstance(k, SxKeyword) and i + 1 < len(raw):
|
||||||
|
raw_val = raw[i + 1]
|
||||||
|
resolved = await async_eval(raw_val, env, ctx)
|
||||||
|
layout_kwargs[k.name.replace("-", "_")] = resolved
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
layout_name = str(page_def.layout)
|
||||||
|
|
||||||
|
layout = get_layout(layout_name)
|
||||||
|
if layout is not None:
|
||||||
|
header_rows = await layout.full_headers(tctx, **layout_kwargs)
|
||||||
|
oob_headers = await layout.oob_headers(tctx, **layout_kwargs)
|
||||||
|
|
||||||
|
# Branch on request type
|
||||||
|
is_htmx = is_htmx_request()
|
||||||
|
|
||||||
|
if is_htmx:
|
||||||
|
return sx_response(oob_page_sx(
|
||||||
|
oobs=oob_headers if oob_headers else "",
|
||||||
|
filter=filter_sx,
|
||||||
|
aside=aside_sx,
|
||||||
|
content=content_sx,
|
||||||
|
menu=menu_sx,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
return full_page_sx(
|
||||||
|
tctx,
|
||||||
|
header_rows=header_rows,
|
||||||
|
filter=filter_sx,
|
||||||
|
aside=aside_sx,
|
||||||
|
content=content_sx,
|
||||||
|
menu=menu_sx,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Blueprint mounting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def mount_pages(bp: Any, service_name: str) -> None:
|
||||||
|
"""Mount all registered PageDef routes onto a Quart Blueprint.
|
||||||
|
|
||||||
|
For each PageDef, adds a GET route with appropriate auth/cache
|
||||||
|
decorators. Coexists with existing Python routes on the same blueprint.
|
||||||
|
"""
|
||||||
|
from quart import make_response
|
||||||
|
|
||||||
|
pages = get_all_pages(service_name)
|
||||||
|
|
||||||
|
for page_def in pages.values():
|
||||||
|
_mount_one_page(bp, service_name, page_def)
|
||||||
|
|
||||||
|
|
||||||
|
def _mount_one_page(bp: Any, service_name: str, page_def: PageDef) -> None:
|
||||||
|
"""Mount a single PageDef as a GET route on the blueprint."""
|
||||||
|
from quart import make_response
|
||||||
|
|
||||||
|
# Build the view function
|
||||||
|
async def page_view(**kwargs: Any) -> Any:
|
||||||
|
# Re-fetch the page from registry to support hot-reload of content
|
||||||
|
current = get_page(service_name, page_def.name) or page_def
|
||||||
|
result = await execute_page(current, service_name, url_params=kwargs)
|
||||||
|
# If result is already a Response (from sx_response), return it
|
||||||
|
if hasattr(result, "status_code"):
|
||||||
|
return result
|
||||||
|
return await make_response(result, 200)
|
||||||
|
|
||||||
|
# Give the view function a unique name for Quart's routing
|
||||||
|
page_view.__name__ = f"defpage_{page_def.name.replace('-', '_')}"
|
||||||
|
page_view.__qualname__ = page_view.__name__
|
||||||
|
|
||||||
|
# Apply auth decorator
|
||||||
|
view_fn = _apply_auth(page_view, page_def.auth)
|
||||||
|
|
||||||
|
# Apply cache decorator
|
||||||
|
if page_def.cache:
|
||||||
|
view_fn = _apply_cache(view_fn, page_def.cache)
|
||||||
|
|
||||||
|
# Register the route
|
||||||
|
bp.add_url_rule(
|
||||||
|
page_def.path,
|
||||||
|
endpoint=page_view.__name__,
|
||||||
|
view_func=view_fn,
|
||||||
|
methods=["GET"],
|
||||||
|
)
|
||||||
|
logger.info("Mounted defpage %s:%s → GET %s", service_name, page_def.name, page_def.path)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_auth(fn: Any, auth: str | list) -> Any:
|
||||||
|
"""Wrap a view function with the appropriate auth decorator."""
|
||||||
|
if auth == "public":
|
||||||
|
return fn
|
||||||
|
if auth == "login":
|
||||||
|
from shared.browser.app.authz import require_login
|
||||||
|
return require_login(fn)
|
||||||
|
if auth == "admin":
|
||||||
|
from shared.browser.app.authz import require_admin
|
||||||
|
return require_admin(fn)
|
||||||
|
if isinstance(auth, list) and auth and auth[0] == "rights":
|
||||||
|
from shared.browser.app.authz import require_rights
|
||||||
|
return require_rights(*auth[1:])(fn)
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_cache(fn: Any, cache: dict) -> Any:
|
||||||
|
"""Wrap a view function with cache_page decorator."""
|
||||||
|
from shared.browser.app.redis_cacher import cache_page
|
||||||
|
ttl = cache.get("ttl", 0)
|
||||||
|
tag = cache.get("tag")
|
||||||
|
scope = cache.get("scope", "user")
|
||||||
|
return cache_page(ttl=ttl, tag=tag, scope=scope)(fn)
|
||||||
@@ -8,6 +8,7 @@ from datetime import datetime
|
|||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from quart import Blueprint, Response, make_response, request
|
from quart import Blueprint, Response, make_response, request
|
||||||
|
from shared.browser.app.csrf import csrf_exempt
|
||||||
|
|
||||||
|
|
||||||
def register(url_prefix: str = "/") -> Blueprint:
|
def register(url_prefix: str = "/") -> Blueprint:
|
||||||
@@ -145,6 +146,7 @@ def register(url_prefix: str = "/") -> Blueprint:
|
|||||||
oob_comp = _oob_code("click-comp", comp_text)
|
oob_comp = _oob_code("click-comp", comp_text)
|
||||||
return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})')
|
return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})')
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
@bp.post("/examples/api/form")
|
@bp.post("/examples/api/form")
|
||||||
async def api_form():
|
async def api_form():
|
||||||
from shared.sx.helpers import sx_response
|
from shared.sx.helpers import sx_response
|
||||||
@@ -175,6 +177,7 @@ def register(url_prefix: str = "/") -> Blueprint:
|
|||||||
oob_comp = _oob_code("poll-comp", comp_text)
|
oob_comp = _oob_code("poll-comp", comp_text)
|
||||||
return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})')
|
return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})')
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
@bp.delete("/examples/api/delete/<item_id>")
|
@bp.delete("/examples/api/delete/<item_id>")
|
||||||
async def api_delete(item_id: str):
|
async def api_delete(item_id: str):
|
||||||
from shared.sx.helpers import sx_response
|
from shared.sx.helpers import sx_response
|
||||||
@@ -200,6 +203,7 @@ def register(url_prefix: str = "/") -> Blueprint:
|
|||||||
oob_comp = _oob_code("edit-comp", comp_text)
|
oob_comp = _oob_code("edit-comp", comp_text)
|
||||||
return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})')
|
return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})')
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
@bp.post("/examples/api/edit")
|
@bp.post("/examples/api/edit")
|
||||||
async def api_edit_save():
|
async def api_edit_save():
|
||||||
from shared.sx.helpers import sx_response
|
from shared.sx.helpers import sx_response
|
||||||
@@ -295,6 +299,7 @@ def register(url_prefix: str = "/") -> Blueprint:
|
|||||||
|
|
||||||
_jobs: dict[str, int] = {}
|
_jobs: dict[str, int] = {}
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
@bp.post("/examples/api/progress/start")
|
@bp.post("/examples/api/progress/start")
|
||||||
async def api_progress_start():
|
async def api_progress_start():
|
||||||
from shared.sx.helpers import sx_response
|
from shared.sx.helpers import sx_response
|
||||||
@@ -373,6 +378,7 @@ def register(url_prefix: str = "/") -> Blueprint:
|
|||||||
oob_comp = _oob_code("validate-comp", comp_text)
|
oob_comp = _oob_code("validate-comp", comp_text)
|
||||||
return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})')
|
return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})')
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
@bp.post("/examples/api/validate/submit")
|
@bp.post("/examples/api/validate/submit")
|
||||||
async def api_validate_submit():
|
async def api_validate_submit():
|
||||||
from shared.sx.helpers import sx_response
|
from shared.sx.helpers import sx_response
|
||||||
@@ -402,6 +408,7 @@ def register(url_prefix: str = "/") -> Blueprint:
|
|||||||
|
|
||||||
# --- Reset on Submit ---
|
# --- Reset on Submit ---
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
@bp.post("/examples/api/reset-submit")
|
@bp.post("/examples/api/reset-submit")
|
||||||
async def api_reset_submit():
|
async def api_reset_submit():
|
||||||
from shared.sx.helpers import sx_response
|
from shared.sx.helpers import sx_response
|
||||||
@@ -442,6 +449,7 @@ def register(url_prefix: str = "/") -> Blueprint:
|
|||||||
oob_comp = _oob_code("editrow-comp", comp_text)
|
oob_comp = _oob_code("editrow-comp", comp_text)
|
||||||
return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})')
|
return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})')
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
@bp.post("/examples/api/editrow/<row_id>")
|
@bp.post("/examples/api/editrow/<row_id>")
|
||||||
async def api_editrow_save(row_id: str):
|
async def api_editrow_save(row_id: str):
|
||||||
from shared.sx.helpers import sx_response
|
from shared.sx.helpers import sx_response
|
||||||
@@ -488,6 +496,7 @@ def register(url_prefix: str = "/") -> Blueprint:
|
|||||||
_bulk_users[u["id"]] = dict(u)
|
_bulk_users[u["id"]] = dict(u)
|
||||||
return _bulk_users
|
return _bulk_users
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
@bp.post("/examples/api/bulk")
|
@bp.post("/examples/api/bulk")
|
||||||
async def api_bulk():
|
async def api_bulk():
|
||||||
from shared.sx.helpers import sx_response
|
from shared.sx.helpers import sx_response
|
||||||
@@ -517,6 +526,7 @@ def register(url_prefix: str = "/") -> Blueprint:
|
|||||||
|
|
||||||
_swap_count = {"n": 0}
|
_swap_count = {"n": 0}
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
@bp.post("/examples/api/swap-log")
|
@bp.post("/examples/api/swap-log")
|
||||||
async def api_swap_log():
|
async def api_swap_log():
|
||||||
from shared.sx.helpers import sx_response
|
from shared.sx.helpers import sx_response
|
||||||
@@ -682,6 +692,7 @@ def register(url_prefix: str = "/") -> Blueprint:
|
|||||||
oob_comp = _oob_code("pp-comp", comp_text)
|
oob_comp = _oob_code("pp-comp", comp_text)
|
||||||
return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})')
|
return sx_response(f'(<> {sx_src} {oob_wire} {oob_comp})')
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
@bp.put("/examples/api/putpatch")
|
@bp.put("/examples/api/putpatch")
|
||||||
async def api_pp_put():
|
async def api_pp_put():
|
||||||
from shared.sx.helpers import sx_response
|
from shared.sx.helpers import sx_response
|
||||||
@@ -712,6 +723,7 @@ def register(url_prefix: str = "/") -> Blueprint:
|
|||||||
|
|
||||||
# --- JSON Encoding ---
|
# --- JSON Encoding ---
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
@bp.post("/examples/api/json-echo")
|
@bp.post("/examples/api/json-echo")
|
||||||
async def api_json_echo():
|
async def api_json_echo():
|
||||||
from shared.sx.helpers import sx_response
|
from shared.sx.helpers import sx_response
|
||||||
|
|||||||
367
sx/sxc/handlers/examples.sx
Normal file
367
sx/sxc/handlers/examples.sx
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
;; SX example API handlers — defhandler definitions
|
||||||
|
;;
|
||||||
|
;; These serve the live demos on the Examples docs pages.
|
||||||
|
;; Each handler's source is displayed in the "Server handler" code block
|
||||||
|
;; on its corresponding example page (self-referencing via handler-source).
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Click to Load
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler click (&key)
|
||||||
|
(let ((now (format-time (now) "%H:%M:%S")))
|
||||||
|
(~click-result :time now)))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Form Submission
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler form (&key)
|
||||||
|
(let ((name (form-data "name")))
|
||||||
|
(~form-result :name name)))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Polling
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler poll (&key)
|
||||||
|
(let ((now (format-time (now) "%H:%M:%S"))
|
||||||
|
(count (inc-counter "poll" :max 10)))
|
||||||
|
(~poll-result :time now :count count)))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Delete Row
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler delete (&key item-id)
|
||||||
|
;; Empty response — outerHTML swap removes the row
|
||||||
|
"")
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Inline Edit
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler edit-form (&key)
|
||||||
|
(let ((value (request-arg "value")))
|
||||||
|
(~inline-edit-form :value value)))
|
||||||
|
|
||||||
|
(defhandler edit-save (&key)
|
||||||
|
(let ((value (form-data "value")))
|
||||||
|
(~inline-view :value value)))
|
||||||
|
|
||||||
|
(defhandler edit-cancel (&key)
|
||||||
|
(let ((value (request-arg "value")))
|
||||||
|
(~inline-view :value value)))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Out-of-Band Swaps
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler oob (&key)
|
||||||
|
(let ((now (format-time (now) "%H:%M:%S")))
|
||||||
|
(<>
|
||||||
|
(p :class "text-emerald-600 font-medium" "Box A updated!")
|
||||||
|
(p :class "text-sm text-stone-500" "at " now)
|
||||||
|
(div :id "oob-box-b" :sx-swap-oob "innerHTML"
|
||||||
|
(p :class "text-violet-600 font-medium" "Box B updated via OOB!")
|
||||||
|
(p :class "text-sm text-stone-500" "at " now)))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Lazy Loading
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler lazy (&key)
|
||||||
|
(let ((now (format-time (now) "%H:%M:%S")))
|
||||||
|
(~lazy-result :time now)))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Infinite Scroll
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler scroll (&key)
|
||||||
|
(let ((page (or (parse-int (request-arg "page")) 2))
|
||||||
|
(start (+ (* (- page 1) 5) 1))
|
||||||
|
(next (+ page 1)))
|
||||||
|
(<>
|
||||||
|
(map (fn (i)
|
||||||
|
(div :class "px-4 py-3 border-b border-stone-100 text-sm text-stone-700"
|
||||||
|
"Item " i " — loaded from page " page))
|
||||||
|
(range start (+ start 5)))
|
||||||
|
(if (<= next 6)
|
||||||
|
(div :id "scroll-sentinel"
|
||||||
|
:sx-get (str "/examples/api/scroll?page=" next)
|
||||||
|
:sx-trigger "intersect once"
|
||||||
|
:sx-target "#scroll-items"
|
||||||
|
:sx-swap "beforeend"
|
||||||
|
:class "p-3 text-center text-stone-400 text-sm"
|
||||||
|
"Loading more...")
|
||||||
|
(div :class "p-3 text-center text-stone-500 text-sm font-medium"
|
||||||
|
"All items loaded.")))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Progress Bar
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler progress-start (&key)
|
||||||
|
(let ((job-id (new-job)))
|
||||||
|
(~progress-status :percent 0 :job-id job-id)))
|
||||||
|
|
||||||
|
(defhandler progress-status (&key)
|
||||||
|
(let ((job-id (request-arg "job"))
|
||||||
|
(percent (advance-job job-id)))
|
||||||
|
(~progress-status :percent percent :job-id job-id)))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Active Search
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler search (&key)
|
||||||
|
(let ((q (request-arg "q"))
|
||||||
|
(results (filter-list LANGUAGES q)))
|
||||||
|
(~search-results :items results :query q)))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Inline Validation
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler validate (&key)
|
||||||
|
(let ((email (request-arg "email")))
|
||||||
|
(cond
|
||||||
|
((not email)
|
||||||
|
(~validation-error :message "Email is required"))
|
||||||
|
((not (contains? email "@"))
|
||||||
|
(~validation-error :message "Invalid email format"))
|
||||||
|
((contains? TAKEN_EMAILS (lower email))
|
||||||
|
(~validation-error
|
||||||
|
:message (str email " is already taken")))
|
||||||
|
(t (~validation-ok :email email)))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Value Select
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler values (&key)
|
||||||
|
(let ((cat (request-arg "category"))
|
||||||
|
(items (get VALUE_SELECT_DATA cat)))
|
||||||
|
(if (empty? items)
|
||||||
|
(option :value "" "No items")
|
||||||
|
(map (fn (i) (option :value i i)) items))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Reset on Submit
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler reset-submit (&key)
|
||||||
|
(let ((msg (or (form-data "message") "(empty)"))
|
||||||
|
(now (format-time (now) "%H:%M:%S")))
|
||||||
|
(~reset-message :message msg :time now)))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Edit Row
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler editrow-form (&key row-id)
|
||||||
|
(let ((row (get ROWS row-id)))
|
||||||
|
(~edit-row-form :id row-id
|
||||||
|
:name (get row "name")
|
||||||
|
:price (get row "price")
|
||||||
|
:stock (get row "stock"))))
|
||||||
|
|
||||||
|
(defhandler editrow-save (&key row-id)
|
||||||
|
(let ((name (form-data "name"))
|
||||||
|
(price (form-data "price"))
|
||||||
|
(stock (form-data "stock")))
|
||||||
|
(~edit-row-view :id row-id
|
||||||
|
:name name :price price :stock stock)))
|
||||||
|
|
||||||
|
(defhandler editrow-cancel (&key row-id)
|
||||||
|
(let ((row (get ROWS row-id)))
|
||||||
|
(~edit-row-view :id row-id
|
||||||
|
:name (get row "name")
|
||||||
|
:price (get row "price")
|
||||||
|
:stock (get row "stock"))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Bulk Update
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler bulk (&key)
|
||||||
|
(let ((action (request-arg "action"))
|
||||||
|
(ids (form-list "ids"))
|
||||||
|
(status (if (= action "activate")
|
||||||
|
"active" "inactive")))
|
||||||
|
(update-users ids :status status)
|
||||||
|
(map (fn (u)
|
||||||
|
(~bulk-row
|
||||||
|
:id (get u "id")
|
||||||
|
:name (get u "name")
|
||||||
|
:email (get u "email")
|
||||||
|
:status (get u "status")))
|
||||||
|
USERS)))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Swap Positions
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler swap-log (&key)
|
||||||
|
(let ((mode (request-arg "mode"))
|
||||||
|
(n (inc-counter "swap"))
|
||||||
|
(now (format-time (now) "%H:%M:%S")))
|
||||||
|
(<>
|
||||||
|
(div :class "px-3 py-2 text-sm text-stone-700"
|
||||||
|
"[" now "] " mode " (#" n ")")
|
||||||
|
(span :id "swap-counter"
|
||||||
|
:sx-swap-oob "innerHTML"
|
||||||
|
:class "self-center text-sm text-stone-500"
|
||||||
|
"Count: " n))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Select Filter (Dashboard)
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler dashboard (&key)
|
||||||
|
(let ((now (format-time (now) "%H:%M:%S")))
|
||||||
|
(<>
|
||||||
|
(div :id "dash-header" :class "p-3 bg-violet-50 rounded mb-3"
|
||||||
|
(h4 :class "font-semibold text-violet-800" "Dashboard Header")
|
||||||
|
(p :class "text-sm text-violet-600" "Generated at " now))
|
||||||
|
(div :id "dash-stats" :class "grid grid-cols-3 gap-3 mb-3"
|
||||||
|
(div :class "p-3 bg-emerald-50 rounded text-center"
|
||||||
|
(p :class "text-2xl font-bold text-emerald-700" "142")
|
||||||
|
(p :class "text-xs text-emerald-600" "Users"))
|
||||||
|
(div :class "p-3 bg-blue-50 rounded text-center"
|
||||||
|
(p :class "text-2xl font-bold text-blue-700" "89")
|
||||||
|
(p :class "text-xs text-blue-600" "Orders"))
|
||||||
|
(div :class "p-3 bg-amber-50 rounded text-center"
|
||||||
|
(p :class "text-2xl font-bold text-amber-700" "$4.2k")
|
||||||
|
(p :class "text-xs text-amber-600" "Revenue")))
|
||||||
|
(div :id "dash-footer" :class "p-3 bg-stone-50 rounded"
|
||||||
|
(p :class "text-sm text-stone-500" "Last updated: " now)))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Tabs
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler tabs (&key tab)
|
||||||
|
(let ((content (get TAB_CONTENT tab)))
|
||||||
|
(<> content
|
||||||
|
(div :id "tab-buttons"
|
||||||
|
:sx-swap-oob "innerHTML"
|
||||||
|
:class "flex border-b border-stone-200"
|
||||||
|
(map (fn (t)
|
||||||
|
(~tab-btn
|
||||||
|
:tab (first t)
|
||||||
|
:label (last t)
|
||||||
|
:active (if (= (first t) tab) "true" "false")))
|
||||||
|
TAB_LIST)))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Animations
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler animate (&key)
|
||||||
|
(let ((color (random-choice
|
||||||
|
"bg-violet-100" "bg-emerald-100"
|
||||||
|
"bg-blue-100" "bg-amber-100"))
|
||||||
|
(now (format-time (now) "%H:%M:%S")))
|
||||||
|
(~anim-result :color color :time now)))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Dialogs
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler dialog (&key)
|
||||||
|
(~dialog-modal
|
||||||
|
:title "Confirm Action"
|
||||||
|
:message "Are you sure you want to proceed?"))
|
||||||
|
|
||||||
|
(defhandler dialog-close (&key)
|
||||||
|
"")
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Keyboard Shortcuts
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler keyboard (&key)
|
||||||
|
(let ((key (request-arg "key"))
|
||||||
|
(actions {:s "Search panel activated"
|
||||||
|
:n "New item created"
|
||||||
|
:h "Help panel opened"})
|
||||||
|
(action (get actions key)))
|
||||||
|
(~kbd-result :key key :action action)))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; PUT / PATCH
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler pp-edit-all (&key)
|
||||||
|
(let ((p (get-profile)))
|
||||||
|
(~pp-form-full
|
||||||
|
:name (get p "name")
|
||||||
|
:email (get p "email")
|
||||||
|
:role (get p "role"))))
|
||||||
|
|
||||||
|
(defhandler put-profile (&key)
|
||||||
|
(let ((name (form-data "name"))
|
||||||
|
(email (form-data "email"))
|
||||||
|
(role (form-data "role")))
|
||||||
|
(~pp-view :name name :email email :role role)))
|
||||||
|
|
||||||
|
(defhandler pp-cancel (&key)
|
||||||
|
(let ((p (get-profile)))
|
||||||
|
(~pp-view
|
||||||
|
:name (get p "name")
|
||||||
|
:email (get p "email")
|
||||||
|
:role (get p "role"))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; JSON Encoding
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler json-echo (&key)
|
||||||
|
(let ((data (request-json))
|
||||||
|
(body (json-pretty data))
|
||||||
|
(ct (request-header "content-type")))
|
||||||
|
(~json-result :body body :content-type ct)))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Vals & Headers
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler echo-vals (&key)
|
||||||
|
(let ((vals (request-args)))
|
||||||
|
(~echo-result :label "values" :items vals)))
|
||||||
|
|
||||||
|
(defhandler echo-headers (&key)
|
||||||
|
(let ((headers (request-headers :prefix "X-")))
|
||||||
|
(~echo-result :label "headers" :items headers)))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Loading States
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler slow (&key)
|
||||||
|
(sleep 2000)
|
||||||
|
(let ((now (format-time (now) "%H:%M:%S")))
|
||||||
|
(~loading-result :time now)))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Request Abort (sync replace)
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler slow-search (&key)
|
||||||
|
(let ((delay (random-int 500 2000)))
|
||||||
|
(sleep delay)
|
||||||
|
(let ((q (request-arg "q")))
|
||||||
|
(~sync-result :query q :delay delay))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Retry
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defhandler flaky (&key)
|
||||||
|
(let ((n (inc-counter "flaky")))
|
||||||
|
(if (!= (mod n 3) 0)
|
||||||
|
(error 503)
|
||||||
|
(~retry-result :attempt n
|
||||||
|
:message "Success! The endpoint finally responded."))))
|
||||||
169
sx/sxc/pages/__init__.py
Normal file
169
sx/sxc/pages/__init__.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"""SX docs defpage setup — registers layouts and page helpers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def setup_sx_pages() -> None:
|
||||||
|
"""Register sx-specific layouts, page helpers, and load page definitions.
|
||||||
|
|
||||||
|
Called during app startup before mount_pages().
|
||||||
|
"""
|
||||||
|
_register_sx_layouts()
|
||||||
|
_register_sx_helpers()
|
||||||
|
_load_sx_page_files()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_sx_page_files() -> None:
|
||||||
|
"""Load defpage definitions from sx/sxc/pages/*.sx."""
|
||||||
|
import os
|
||||||
|
from shared.sx.pages import load_page_dir
|
||||||
|
pages_dir = os.path.dirname(__file__)
|
||||||
|
load_page_dir(pages_dir, "sx")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Layouts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _register_sx_layouts() -> None:
|
||||||
|
"""Register the sx docs layout presets."""
|
||||||
|
from shared.sx.layouts import register_custom_layout
|
||||||
|
|
||||||
|
register_custom_layout("sx", _sx_full_headers, _sx_oob_headers)
|
||||||
|
register_custom_layout("sx-section", _sx_section_full_headers, _sx_section_oob_headers)
|
||||||
|
|
||||||
|
|
||||||
|
def _sx_full_headers(ctx: dict, **kw: Any) -> str:
|
||||||
|
"""Full headers for sx home page: root + sx menu row."""
|
||||||
|
from shared.sx.helpers import root_header_sx
|
||||||
|
from sxc.sx_components import _sx_header_sx, _main_nav_sx
|
||||||
|
|
||||||
|
main_nav = _main_nav_sx(kw.get("section"))
|
||||||
|
root_hdr = root_header_sx(ctx)
|
||||||
|
sx_row = _sx_header_sx(main_nav)
|
||||||
|
return "(<> " + root_hdr + " " + sx_row + ")"
|
||||||
|
|
||||||
|
|
||||||
|
def _sx_oob_headers(ctx: dict, **kw: Any) -> str:
|
||||||
|
"""OOB headers for sx home page."""
|
||||||
|
from shared.sx.helpers import root_header_sx, oob_header_sx
|
||||||
|
from sxc.sx_components import _sx_header_sx, _main_nav_sx
|
||||||
|
|
||||||
|
root_hdr = root_header_sx(ctx)
|
||||||
|
main_nav = _main_nav_sx(kw.get("section"))
|
||||||
|
sx_row = _sx_header_sx(main_nav)
|
||||||
|
rows = "(<> " + root_hdr + " " + sx_row + ")"
|
||||||
|
return oob_header_sx("root-header-child", "sx-header-child", rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _sx_section_full_headers(ctx: dict, **kw: Any) -> str:
|
||||||
|
"""Full headers for sx section pages: root + sx row + sub row."""
|
||||||
|
from shared.sx.helpers import root_header_sx
|
||||||
|
from sxc.sx_components import (
|
||||||
|
_sx_header_sx, _main_nav_sx, _sub_row_sx,
|
||||||
|
)
|
||||||
|
|
||||||
|
section = kw.get("section", "")
|
||||||
|
sub_label = kw.get("sub_label", section)
|
||||||
|
sub_href = kw.get("sub_href", "/")
|
||||||
|
sub_nav = kw.get("sub_nav", "")
|
||||||
|
selected = kw.get("selected", "")
|
||||||
|
|
||||||
|
root_hdr = root_header_sx(ctx)
|
||||||
|
main_nav = _main_nav_sx(section)
|
||||||
|
sub_row = _sub_row_sx(sub_label, sub_href, sub_nav, selected)
|
||||||
|
sx_row = _sx_header_sx(main_nav, child=sub_row)
|
||||||
|
return "(<> " + root_hdr + " " + sx_row + ")"
|
||||||
|
|
||||||
|
|
||||||
|
def _sx_section_oob_headers(ctx: dict, **kw: Any) -> str:
|
||||||
|
"""OOB headers for sx section pages."""
|
||||||
|
from shared.sx.helpers import root_header_sx, oob_header_sx
|
||||||
|
from sxc.sx_components import (
|
||||||
|
_sx_header_sx, _main_nav_sx, _sub_row_sx,
|
||||||
|
)
|
||||||
|
|
||||||
|
section = kw.get("section", "")
|
||||||
|
sub_label = kw.get("sub_label", section)
|
||||||
|
sub_href = kw.get("sub_href", "/")
|
||||||
|
sub_nav = kw.get("sub_nav", "")
|
||||||
|
selected = kw.get("selected", "")
|
||||||
|
|
||||||
|
root_hdr = root_header_sx(ctx)
|
||||||
|
main_nav = _main_nav_sx(section)
|
||||||
|
sub_row = _sub_row_sx(sub_label, sub_href, sub_nav, selected)
|
||||||
|
sx_row = _sx_header_sx(main_nav, child=sub_row)
|
||||||
|
rows = "(<> " + root_hdr + " " + sx_row + ")"
|
||||||
|
return oob_header_sx("root-header-child", "sx-header-child", rows)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Page helpers — Python functions callable from defpage content expressions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _register_sx_helpers() -> None:
|
||||||
|
"""Register Python content builder functions as page helpers."""
|
||||||
|
from shared.sx.pages import register_page_helpers
|
||||||
|
from sxc.sx_components import (
|
||||||
|
_docs_content_sx, _reference_content_sx,
|
||||||
|
_protocol_content_sx, _examples_content_sx,
|
||||||
|
_essay_content_sx,
|
||||||
|
_docs_nav_sx, _reference_nav_sx,
|
||||||
|
_protocols_nav_sx, _examples_nav_sx, _essays_nav_sx,
|
||||||
|
)
|
||||||
|
from content.highlight import highlight as _highlight
|
||||||
|
from content.pages import (
|
||||||
|
DOCS_NAV, REFERENCE_NAV, PROTOCOLS_NAV,
|
||||||
|
EXAMPLES_NAV, ESSAYS_NAV,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _find_current(nav_list, slug, match_fn=None):
|
||||||
|
"""Find the current nav label for a slug."""
|
||||||
|
if match_fn:
|
||||||
|
return match_fn(nav_list, slug)
|
||||||
|
for label, href in nav_list:
|
||||||
|
if href.endswith(slug):
|
||||||
|
return label
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _home_content():
|
||||||
|
"""Build home page content (uses highlight for hero code block)."""
|
||||||
|
hero_code = _highlight(
|
||||||
|
'(div :class "p-4 bg-white rounded shadow"\n'
|
||||||
|
' (h1 :class "text-2xl font-bold" "Hello")\n'
|
||||||
|
' (button :sx-get "/api/data"\n'
|
||||||
|
' :sx-target "#result"\n'
|
||||||
|
' "Load data"))', "lisp")
|
||||||
|
return (
|
||||||
|
f'(div :id "main-content"'
|
||||||
|
f' (~sx-hero {hero_code})'
|
||||||
|
f' (~sx-philosophy)'
|
||||||
|
f' (~sx-how-it-works)'
|
||||||
|
f' (~sx-credits))'
|
||||||
|
)
|
||||||
|
|
||||||
|
register_page_helpers("sx", {
|
||||||
|
# Content builders
|
||||||
|
"home-content": _home_content,
|
||||||
|
"docs-content": _docs_content_sx,
|
||||||
|
"reference-content": _reference_content_sx,
|
||||||
|
"protocol-content": _protocol_content_sx,
|
||||||
|
"examples-content": _examples_content_sx,
|
||||||
|
"essay-content": _essay_content_sx,
|
||||||
|
"highlight": _highlight,
|
||||||
|
# Nav builders
|
||||||
|
"docs-nav": _docs_nav_sx,
|
||||||
|
"reference-nav": _reference_nav_sx,
|
||||||
|
"protocols-nav": _protocols_nav_sx,
|
||||||
|
"examples-nav": _examples_nav_sx,
|
||||||
|
"essays-nav": _essays_nav_sx,
|
||||||
|
# Nav data (for current label lookup)
|
||||||
|
"DOCS_NAV": DOCS_NAV,
|
||||||
|
"REFERENCE_NAV": REFERENCE_NAV,
|
||||||
|
"PROTOCOLS_NAV": PROTOCOLS_NAV,
|
||||||
|
"EXAMPLES_NAV": EXAMPLES_NAV,
|
||||||
|
"ESSAYS_NAV": ESSAYS_NAV,
|
||||||
|
# Utility
|
||||||
|
"find-current": _find_current,
|
||||||
|
})
|
||||||
98
sx/sxc/pages/docs.sx
Normal file
98
sx/sxc/pages/docs.sx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
;; SX docs app — declarative page definitions
|
||||||
|
;; These replace the GET route handlers in routes.py
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Home page
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defpage home
|
||||||
|
:path "/"
|
||||||
|
:auth :public
|
||||||
|
:layout :sx
|
||||||
|
:content (home-content))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Docs section
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defpage docs-page
|
||||||
|
:path "/docs/<slug>"
|
||||||
|
:auth :public
|
||||||
|
:layout (:sx-section
|
||||||
|
:section "Docs"
|
||||||
|
:sub-label "Docs"
|
||||||
|
:sub-href "/docs/introduction"
|
||||||
|
:sub-nav (docs-nav (find-current DOCS_NAV slug))
|
||||||
|
:selected (or (find-current DOCS_NAV slug) ""))
|
||||||
|
:content (docs-content slug))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Reference section
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defpage reference-index
|
||||||
|
:path "/reference/"
|
||||||
|
:auth :public
|
||||||
|
:layout (:sx-section
|
||||||
|
:section "Reference"
|
||||||
|
:sub-label "Reference"
|
||||||
|
:sub-href "/reference/"
|
||||||
|
:sub-nav (reference-nav "Attributes")
|
||||||
|
:selected "Attributes")
|
||||||
|
:content (reference-content ""))
|
||||||
|
|
||||||
|
(defpage reference-page
|
||||||
|
:path "/reference/<slug>"
|
||||||
|
:auth :public
|
||||||
|
:layout (:sx-section
|
||||||
|
:section "Reference"
|
||||||
|
:sub-label "Reference"
|
||||||
|
:sub-href "/reference/"
|
||||||
|
:sub-nav (reference-nav (find-current REFERENCE_NAV slug))
|
||||||
|
:selected (or (find-current REFERENCE_NAV slug) ""))
|
||||||
|
:content (reference-content slug))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Protocols section
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defpage protocol-page
|
||||||
|
:path "/protocols/<slug>"
|
||||||
|
:auth :public
|
||||||
|
:layout (:sx-section
|
||||||
|
:section "Protocols"
|
||||||
|
:sub-label "Protocols"
|
||||||
|
:sub-href "/protocols/wire-format"
|
||||||
|
:sub-nav (protocols-nav (find-current PROTOCOLS_NAV slug))
|
||||||
|
:selected (or (find-current PROTOCOLS_NAV slug) ""))
|
||||||
|
:content (protocol-content slug))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Examples section
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defpage examples-page
|
||||||
|
:path "/examples/<slug>"
|
||||||
|
:auth :public
|
||||||
|
:layout (:sx-section
|
||||||
|
:section "Examples"
|
||||||
|
:sub-label "Examples"
|
||||||
|
:sub-href "/examples/click-to-load"
|
||||||
|
:sub-nav (examples-nav (find-current EXAMPLES_NAV slug))
|
||||||
|
:selected (or (find-current EXAMPLES_NAV slug) ""))
|
||||||
|
:content (examples-content slug))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; Essays section
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defpage essay-page
|
||||||
|
:path "/essays/<slug>"
|
||||||
|
:auth :public
|
||||||
|
:layout (:sx-section
|
||||||
|
:section "Essays"
|
||||||
|
:sub-label "Essays"
|
||||||
|
:sub-href "/essays/sx-sucks"
|
||||||
|
:sub-nav (essays-nav (find-current ESSAYS_NAV slug))
|
||||||
|
:selected (or (find-current ESSAYS_NAV slug) ""))
|
||||||
|
:content (essay-content slug))
|
||||||
Reference in New Issue
Block a user