forked from retoor/devplacepy
feat: add tool scoping and orchestration markers to agent tool registry
Introduce `mark_orchestration_tools`, `set_tool_scope`, and `reset_tool_scope` in `agents/agent.py` to allow runtime filtering of tool payloads by scope and orchestration flag. Refactor `get_tool_payloads` to respect the active scope and exclude orchestration-only tools. Update `agents/core/__init__.py` with `worker_tool_names` helper that returns read/reasoning tools plus conditional write/verify tools per mode, replacing the old `payloads_for` exclusion logic. Modify `agents/maestro.py` to call `mark_orchestration_tools` and wire per-agent result recording via `on_result` callback in `run_fleet`. Extend `agents/orchestrator.py` `run_fleet` signature to accept an optional `on_result` callable and invoke it after each agent run. Revise `agents/style.py` em-dash rule to distinguish prose from data occurrences, and add repository layout guidance to `agents/base.py` MAINT_HEADER.
This commit is contained in:
+44
-6
@@ -99,11 +99,49 @@ class PostForm(BaseModel):
|
||||
content: str = Field(min_length=10, max_length=125000)
|
||||
title: str = Field(default="", max_length=500)
|
||||
topic: str = "random"
|
||||
project_uid: str = ""
|
||||
project_uid: str = Field(default="", max_length=36)
|
||||
attachment_uids: list[str] = []
|
||||
poll_question: str = Field(default="", max_length=200)
|
||||
poll_options: list[str] = []
|
||||
|
||||
@field_validator("poll_options")
|
||||
@classmethod
|
||||
def poll_options_max_length(cls, value):
|
||||
if value is None:
|
||||
return value
|
||||
for opt in value:
|
||||
if isinstance(opt, str) and len(opt) > 200:
|
||||
raise ValueError("Each poll option must be 200 characters or fewer")
|
||||
return value
|
||||
|
||||
@field_validator("topic")
|
||||
@classmethod
|
||||
def valid_topic(cls, value):
|
||||
return value if value in TOPICS else "random"
|
||||
|
||||
@field_validator("poll_options", mode="before")
|
||||
@classmethod
|
||||
def split_poll_options(cls, value):
|
||||
return normalize_poll_options(value)
|
||||
|
||||
|
||||
class PostEditForm(BaseModel):
|
||||
content: str = Field(min_length=10, max_length=125000)
|
||||
title: str = Field(default="", max_length=500)
|
||||
topic: str = "random"
|
||||
poll_question: str = Field(default="", max_length=200)
|
||||
poll_options: list[str] = []
|
||||
|
||||
@field_validator("poll_options")
|
||||
@classmethod
|
||||
def poll_options_max_length(cls, value):
|
||||
if value is None:
|
||||
return value
|
||||
for opt in value:
|
||||
if isinstance(opt, str) and len(opt) > 200:
|
||||
raise ValueError("Each poll option must be 200 characters or fewer")
|
||||
return value
|
||||
|
||||
@field_validator("topic")
|
||||
@classmethod
|
||||
def valid_topic(cls, value):
|
||||
@@ -135,10 +173,10 @@ class PostEditForm(BaseModel):
|
||||
|
||||
class CommentForm(BaseModel):
|
||||
content: str = Field(min_length=3, max_length=1000)
|
||||
target_uid: str = ""
|
||||
post_uid: str = ""
|
||||
target_uid: str = Field(default="", max_length=36)
|
||||
post_uid: str = Field(default="", max_length=36)
|
||||
target_type: Literal["post", "project", "news", "bug", "gist"] = "post"
|
||||
parent_uid: str = ""
|
||||
parent_uid: str = Field(default="", max_length=36)
|
||||
attachment_uids: list[str] = []
|
||||
|
||||
@model_validator(mode="after")
|
||||
@@ -273,7 +311,7 @@ class ContainerScheduleForm(BaseModel):
|
||||
|
||||
class MessageForm(BaseModel):
|
||||
content: str = Field(min_length=1, max_length=2000)
|
||||
receiver_uid: str = Field(min_length=1)
|
||||
receiver_uid: str = Field(min_length=1, max_length=36)
|
||||
attachment_uids: list[str] = []
|
||||
|
||||
|
||||
@@ -328,7 +366,7 @@ class ReactionForm(BaseModel):
|
||||
|
||||
|
||||
class PollVoteForm(BaseModel):
|
||||
option_uid: str = Field(min_length=1)
|
||||
option_uid: str = Field(min_length=1, max_length=36)
|
||||
|
||||
|
||||
class AdminRoleForm(BaseModel):
|
||||
|
||||
@@ -549,6 +549,7 @@ def import_from_dir(project_uid: str, src_dir, user: dict, *, skip_names=None) -
|
||||
|
||||
|
||||
def export_to_dir(project_uid: str, subpath: str, dest_dir) -> int:
|
||||
_guard_writable(project_uid)
|
||||
dest = Path(dest_dir).resolve()
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
if subpath:
|
||||
|
||||
@@ -52,7 +52,7 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _parse_metadata(raw):
|
||||
def _parse_metadata(raw: str | dict | None) -> dict | None:
|
||||
if not raw:
|
||||
return None
|
||||
if isinstance(raw, dict):
|
||||
@@ -109,7 +109,7 @@ async def admin_ai_usage(request: Request):
|
||||
|
||||
|
||||
@router.get("/ai-usage/data")
|
||||
async def admin_ai_usage_data(request: Request, hours: int = 48, top_n: int = 10):
|
||||
async def admin_ai_usage_json(request: Request, hours: int = 48, top_n: int = 10):
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
return JSONResponse({"error": "Authentication required"}, status_code=401)
|
||||
@@ -346,8 +346,20 @@ async def admin_media(request: Request, page: int = 1):
|
||||
@router.post("/media/{uid}/purge")
|
||||
async def admin_media_purge(request: Request, uid: str):
|
||||
admin = require_admin(request)
|
||||
att = get_table("attachments").find_one(uid=uid)
|
||||
delete_attachment(uid)
|
||||
logger.info(f"Admin {admin['username']} purged media {uid}")
|
||||
audit.record(
|
||||
request,
|
||||
"attachment.delete",
|
||||
user=admin,
|
||||
target_type="attachment",
|
||||
target_uid=uid,
|
||||
target_label=att.get("filename") if att else None,
|
||||
metadata={"purge": True},
|
||||
summary=f"admin {admin['username']} purged attachment {att.get('filename') if att else uid}",
|
||||
links=[audit.attachment_link(uid, att.get("filename") if att else None)],
|
||||
)
|
||||
return action_result(request, "/admin/media")
|
||||
|
||||
|
||||
|
||||
@@ -306,9 +306,7 @@ async def forgot_password(
|
||||
summary=f"password reset requested for unknown email {email}",
|
||||
)
|
||||
|
||||
if wants_json(request):
|
||||
return JSONResponse({"ok": True, "sent": True})
|
||||
return templates.TemplateResponse(
|
||||
return respond(
|
||||
request,
|
||||
"forgot_password.html",
|
||||
{
|
||||
@@ -316,6 +314,7 @@ async def forgot_password(
|
||||
"request": request,
|
||||
"sent": True,
|
||||
},
|
||||
model=AuthPageOut,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import re
|
||||
import signal
|
||||
import struct
|
||||
import termios
|
||||
from typing import Annotated
|
||||
from typing import Any, Annotated
|
||||
|
||||
from fastapi import APIRouter, Form, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
@@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _audit_instance(request, user, event_key, inst, project=None, summary=None, metadata=None):
|
||||
def _audit_instance(request: Request, user: dict, event_key: str, inst: dict, project: dict | None = None, summary: str | None = None, metadata: Any = None) -> None:
|
||||
links = [audit.instance(inst["uid"], inst.get("name"))]
|
||||
if project:
|
||||
links.append(audit.project(project["uid"], project.get("title")))
|
||||
@@ -65,7 +65,7 @@ def _slug(project: dict) -> str:
|
||||
return project["slug"] or project["uid"]
|
||||
|
||||
|
||||
def _fail(exc: ContainerError):
|
||||
def _fail(exc: ContainerError) -> JSONResponse:
|
||||
return json_error(400, str(exc))
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ async def containers_page(request: Request, project_slug: str):
|
||||
|
||||
|
||||
@router.get("/{project_slug}/containers/data")
|
||||
async def containers_data(request: Request, project_slug: str):
|
||||
async def containers_json(request: Request, project_slug: str):
|
||||
require_admin(request)
|
||||
project = _project(project_slug)
|
||||
return JSONResponse(
|
||||
|
||||
@@ -8,10 +8,10 @@ from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from devplacepy.database import db, get_table
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.services.containers import api, store
|
||||
from devplacepy.templating import templates
|
||||
from devplacepy.responses import respond
|
||||
from devplacepy.schemas import AdminContainersOut, AdminContainerInstanceOut
|
||||
from devplacepy.utils import not_found, require_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -53,7 +53,8 @@ async def containers_index(request: Request):
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
|
||||
return respond(
|
||||
request,
|
||||
"containers_admin.html",
|
||||
{
|
||||
@@ -63,11 +64,12 @@ async def containers_index(request: Request):
|
||||
"instances": instances,
|
||||
"admin_section": "containers",
|
||||
},
|
||||
model=AdminContainersOut,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/data")
|
||||
async def containers_index_data(request: Request):
|
||||
async def containers_index_json(request: Request):
|
||||
require_admin(request)
|
||||
return JSONResponse({"instances": _decorate(store.all_instances())})
|
||||
|
||||
@@ -94,7 +96,8 @@ async def container_instance_page(request: Request, uid: str):
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
|
||||
return respond(
|
||||
request,
|
||||
"containers_instance.html",
|
||||
{
|
||||
@@ -110,4 +113,5 @@ async def container_instance_page(request: Request, uid: str):
|
||||
"runtime": api.instance_runtime(inst),
|
||||
"admin_section": "containers",
|
||||
},
|
||||
)
|
||||
model=AdminContainerInstanceOut,
|
||||
)
|
||||
@@ -11,7 +11,9 @@ from devplacepy.constants import DEVII_GUEST_COOKIE
|
||||
from devplacepy.database import get_int_setting
|
||||
from devplacepy.seo import base_seo_context, site_url
|
||||
from devplacepy.services.manager import service_manager
|
||||
from devplacepy.templating import templates
|
||||
|
||||
from devplacepy.responses import respond
|
||||
from devplacepy.schemas import DeviiPageOut
|
||||
from devplacepy.utils import (
|
||||
_user_from_api_key,
|
||||
_user_from_session,
|
||||
@@ -78,7 +80,7 @@ async def devii_page(request: Request):
|
||||
description="Your personal AI development assistant on DevPlace.",
|
||||
robots="noindex,nofollow",
|
||||
)
|
||||
response = templates.TemplateResponse(
|
||||
response = respond(
|
||||
request,
|
||||
"devii.html",
|
||||
{
|
||||
@@ -86,6 +88,7 @@ async def devii_page(request: Request):
|
||||
"request": request,
|
||||
"user": user,
|
||||
},
|
||||
model=DeviiPageOut,
|
||||
)
|
||||
if not user and not request.cookies.get(GUEST_COOKIE):
|
||||
response.set_cookie(
|
||||
@@ -121,7 +124,7 @@ async def devii_session(request: Request):
|
||||
return response
|
||||
|
||||
|
||||
def _safe_next(value: str) -> str:
|
||||
def _validate_target(value: str) -> str:
|
||||
if value.startswith("/") and not value.startswith("//"):
|
||||
return value
|
||||
return "/"
|
||||
@@ -131,7 +134,7 @@ def _safe_next(value: str) -> str:
|
||||
async def devii_adopt(request: Request):
|
||||
# The terminal's agent logged in; adopt the real session it minted into the browser so both
|
||||
# share one session, then return to where the user was.
|
||||
target = _safe_next(request.query_params.get("next", "/"))
|
||||
target = _validate_target(request.query_params.get("next", "/"))
|
||||
response = RedirectResponse(target, status_code=303)
|
||||
svc = _service()
|
||||
if svc is None:
|
||||
@@ -170,6 +173,7 @@ async def devii_usage(request: Request):
|
||||
|
||||
@router.post("/clippy/ai/chat")
|
||||
async def clippy_proxy(request: Request):
|
||||
user = require_user(request)
|
||||
svc = _service()
|
||||
if svc is None or not svc.is_enabled():
|
||||
return JSONResponse({"error": "Devii is unavailable"}, status_code=503)
|
||||
|
||||
@@ -24,7 +24,7 @@ from devplacepy.database import (
|
||||
get_following_among,
|
||||
get_user_media,
|
||||
)
|
||||
from devplacepy.content import enrich_items
|
||||
from devplacepy.content import can_view_project, enrich_items
|
||||
from devplacepy.utils import (
|
||||
get_current_user,
|
||||
require_user,
|
||||
@@ -186,16 +186,8 @@ async def profile_page(
|
||||
item["poll"] = polls_map.get(uid)
|
||||
|
||||
badges = list(get_table("badges").find(user_uid=profile_user["uid"]))
|
||||
can_see_private = bool(
|
||||
current_user
|
||||
and (
|
||||
current_user["uid"] == profile_user["uid"]
|
||||
or current_user.get("role") == "Admin"
|
||||
)
|
||||
)
|
||||
projects = list(get_table("projects").find(user_uid=profile_user["uid"]))
|
||||
if not can_see_private:
|
||||
projects = [p for p in projects if not p.get("is_private")]
|
||||
projects = [p for p in projects if can_view_project(p, current_user)]
|
||||
gists_raw = list(get_table("gists").find(user_uid=profile_user["uid"]))
|
||||
gists = []
|
||||
for g in gists_raw:
|
||||
|
||||
@@ -73,8 +73,9 @@ def _deny(request: Request, project: dict, user=None, event_key=None, path=""):
|
||||
|
||||
|
||||
def _fail(request: Request, project: dict, exc: ProjectFileError, user=None, event_key=None, path=""):
|
||||
if event_key and project.get("read_only"):
|
||||
_audit_file(request, project, user, event_key, path, result="denied")
|
||||
if event_key:
|
||||
result = "denied" if project.get("read_only") else "failure"
|
||||
_audit_file(request, project, user, event_key, path, result=result)
|
||||
if wants_json(request):
|
||||
return json_error(400, str(exc))
|
||||
return RedirectResponse(url=_files_url(project), status_code=302)
|
||||
|
||||
@@ -15,6 +15,7 @@ from devplacepy.database import (
|
||||
resolve_by_slug,
|
||||
get_fork_parent,
|
||||
count_forks,
|
||||
get_top_authors,
|
||||
)
|
||||
from devplacepy.services.jobs import queue
|
||||
from devplacepy.content import (
|
||||
@@ -116,6 +117,7 @@ async def projects_page(
|
||||
tab, search, user_uid, project_type, before, viewer=user
|
||||
)
|
||||
total_members = get_site_stats()["total_members"]
|
||||
top_authors = get_top_authors(5)
|
||||
|
||||
seo_ctx = list_page_seo(
|
||||
request,
|
||||
@@ -141,6 +143,7 @@ async def projects_page(
|
||||
"total_count": total_count,
|
||||
"next_cursor": next_cursor,
|
||||
"total_members": total_members,
|
||||
"top_authors": top_authors,
|
||||
},
|
||||
model=ProjectsOut,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from devplacepy.templating import templates
|
||||
@@ -44,7 +45,7 @@ async def services_page(request: Request):
|
||||
|
||||
|
||||
@router.get("/data")
|
||||
async def services_data(request: Request):
|
||||
async def services_json(request: Request):
|
||||
require_admin(request)
|
||||
return JSONResponse({"services": service_manager.describe_all()})
|
||||
|
||||
@@ -84,7 +85,7 @@ async def service_detail(request: Request, name: str):
|
||||
|
||||
|
||||
@router.get("/{name}/data")
|
||||
async def service_detail_data(request: Request, name: str):
|
||||
async def service_detail_json(request: Request, name: str):
|
||||
require_admin(request)
|
||||
svc = service_manager.get_service(name)
|
||||
if svc is None:
|
||||
@@ -92,7 +93,7 @@ async def service_detail_data(request: Request, name: str):
|
||||
return JSONResponse({"service": svc.describe()})
|
||||
|
||||
|
||||
def _audit_service(request, admin, name, event_key, summary, **kwargs):
|
||||
def _audit_service(request: Request, admin: dict, name: str, event_key: str, summary: str, **kwargs: Any) -> None:
|
||||
audit.record(
|
||||
request,
|
||||
event_key,
|
||||
|
||||
+35
-2
@@ -433,6 +433,7 @@ class ProjectsOut(_Out):
|
||||
total_count: Optional[int] = None
|
||||
next_cursor: Optional[str] = None
|
||||
total_members: Optional[int] = None
|
||||
top_authors: list[UserOut] = []
|
||||
|
||||
|
||||
class ProjectDetailOut(_Out):
|
||||
@@ -532,8 +533,6 @@ class ProfileOut(_Out):
|
||||
followers_count: Optional[int] = None
|
||||
following_count: Optional[int] = None
|
||||
viewer_is_admin: bool = False
|
||||
media: list[dict] = []
|
||||
media_pagination: Optional[Any] = None
|
||||
media: list[MediaItemOut] = []
|
||||
media_pagination: Optional[Any] = None
|
||||
|
||||
@@ -599,6 +598,24 @@ class AdminMediaOut(_Out):
|
||||
admin_section: Optional[str] = None
|
||||
|
||||
|
||||
class AdminContainersOut(_Out):
|
||||
instances: list = []
|
||||
admin_section: Optional[str] = None
|
||||
user: Optional[Any] = None
|
||||
|
||||
|
||||
class AdminContainerInstanceOut(_Out):
|
||||
instance: Optional[Any] = None
|
||||
project: Optional[dict] = None
|
||||
project_slug: Optional[str] = None
|
||||
events: list = []
|
||||
schedules: list = []
|
||||
stats: Optional[Any] = None
|
||||
runtime: Optional[Any] = None
|
||||
admin_section: Optional[str] = None
|
||||
user: Optional[Any] = None
|
||||
|
||||
|
||||
class GatewayUsageOut(_Out):
|
||||
window_hours: int = 48
|
||||
generated_at: Optional[str] = None
|
||||
@@ -673,6 +690,22 @@ class LandingOut(_Out):
|
||||
landing_posts: list[LandingPostOut] = []
|
||||
|
||||
|
||||
class DeviiPageOut(_Out):
|
||||
page_title: Optional[str] = None
|
||||
meta_description: Optional[str] = None
|
||||
meta_robots: Optional[str] = None
|
||||
canonical_url: Optional[str] = None
|
||||
og_title: Optional[str] = None
|
||||
og_description: Optional[str] = None
|
||||
og_image: Optional[str] = None
|
||||
og_type: Optional[str] = None
|
||||
breadcrumbs: list = []
|
||||
page_schema: Optional[Any] = None
|
||||
prev_url: Optional[str] = None
|
||||
next_url: Optional[str] = None
|
||||
user: Optional[Any] = None
|
||||
|
||||
|
||||
CommentItemOut.model_rebuild()
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from pathlib import Path
|
||||
|
||||
@@ -55,7 +54,7 @@ class BotState:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(asdict(self), indent=2, default=str))
|
||||
os.replace(str(tmp), str(path))
|
||||
tmp.replace(path)
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path) -> "BotState":
|
||||
|
||||
@@ -766,11 +766,12 @@ ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="list_media",
|
||||
method="GET",
|
||||
path="/profile/{username}?tab=media",
|
||||
path="/profile/{username}",
|
||||
summary="List a user's uploaded media, newest first",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
path("username", "Username whose media to list."),
|
||||
query("tab", "Profile tab (defaults to media for this action)."),
|
||||
query("page", "Page number, 24 per page."),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -45,6 +45,13 @@ CONFIRM_REQUIRED = {
|
||||
"delete_comment",
|
||||
"delete_gist",
|
||||
"delete_attachment",
|
||||
"admin_media_purge",
|
||||
"admin_delete_news",
|
||||
"admin_reset_all_ai_quota",
|
||||
"admin_reset_guest_ai_quota",
|
||||
"admin_reset_user_ai_quota",
|
||||
"container_instance_action",
|
||||
"container_exec",
|
||||
}
|
||||
|
||||
DESTRUCTIVE_COMMAND = re.compile(
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -52,7 +51,7 @@ class ZipService(JobService):
|
||||
stats = await self._run_worker(staging, tmp_zip)
|
||||
final_name = self._final_name(job.get("preferred_name", ""), stats["crc32"])
|
||||
final_path = tmp_dir / final_name
|
||||
os.replace(tmp_zip, final_path)
|
||||
tmp_zip.replace(final_path)
|
||||
except Exception:
|
||||
audit.record_system(
|
||||
"job.zip.failed",
|
||||
|
||||
@@ -538,7 +538,7 @@ pre.code-pre > .code-gutter {
|
||||
text-align: right;
|
||||
white-space: pre;
|
||||
user-select: none;
|
||||
color: #5c6370;
|
||||
color: var(--text-muted);
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font: inherit;
|
||||
@@ -557,7 +557,7 @@ pre.code-has-copy > .code-copy-btn {
|
||||
z-index: 1;
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 0.7rem;
|
||||
color: #abb2bf;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(40, 44, 52, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: var(--radius);
|
||||
|
||||
@@ -111,8 +111,8 @@
|
||||
.rendered-content pre code {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
border-radius: var(--radius);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
export const CodeBlock = {
|
||||
highlight(code) {
|
||||
export class CodeBlock {
|
||||
static highlight(code) {
|
||||
if (typeof hljs === "undefined" || !code) return;
|
||||
code.removeAttribute("data-highlighted");
|
||||
code.classList.remove("hljs");
|
||||
@@ -10,9 +10,9 @@ export const CodeBlock = {
|
||||
} catch {
|
||||
// leave plain text on failure
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
lineNumbers(pre, code) {
|
||||
static lineNumbers(pre, code) {
|
||||
pre.classList.add("code-pre", "has-line-numbers");
|
||||
let gutter = pre.querySelector(":scope > .code-gutter");
|
||||
if (!gutter) {
|
||||
@@ -24,9 +24,9 @@ export const CodeBlock = {
|
||||
const text = code.textContent.replace(/\n+$/, "");
|
||||
const count = text.length ? text.split("\n").length : 1;
|
||||
gutter.textContent = Array.from({ length: count }, (_, i) => i + 1).join("\n");
|
||||
},
|
||||
}
|
||||
|
||||
copyButton(pre, code) {
|
||||
static copyButton(pre, code) {
|
||||
if (pre.querySelector(":scope > .code-copy-btn")) return;
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
@@ -42,9 +42,9 @@ export const CodeBlock = {
|
||||
setTimeout(() => { btn.textContent = "Copy"; }, 1500);
|
||||
});
|
||||
pre.appendChild(btn);
|
||||
},
|
||||
}
|
||||
|
||||
enhance(pre, { highlight = true, lineNumbers = true } = {}) {
|
||||
static enhance(pre, { highlight = true, lineNumbers = true } = {}) {
|
||||
if (!pre) return;
|
||||
const code = pre.querySelector("code");
|
||||
if (!code) return;
|
||||
@@ -52,9 +52,9 @@ export const CodeBlock = {
|
||||
pre.classList.add("code-has-copy");
|
||||
if (lineNumbers) this.lineNumbers(pre, code);
|
||||
this.copyButton(pre, code);
|
||||
},
|
||||
}
|
||||
|
||||
refresh(pre) {
|
||||
static refresh(pre) {
|
||||
if (!pre) return;
|
||||
const code = pre.querySelector("code");
|
||||
if (!code) return;
|
||||
@@ -62,7 +62,7 @@ export const CodeBlock = {
|
||||
pre.classList.add("code-has-copy");
|
||||
this.lineNumbers(pre, code);
|
||||
this.copyButton(pre, code);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
window.CodeBlock = CodeBlock;
|
||||
|
||||
@@ -2,18 +2,30 @@
|
||||
|
||||
import { Component } from "./Component.js";
|
||||
|
||||
const LB_CSS_ID = "lightbox-css";
|
||||
|
||||
export class AppLightbox extends Component {
|
||||
connectedCallback() {
|
||||
if (this._built) {
|
||||
return;
|
||||
}
|
||||
this._built = true;
|
||||
this._ensureCss();
|
||||
this.lastFocus = null;
|
||||
this.bodyOverflow = "";
|
||||
this.build();
|
||||
this.bind();
|
||||
}
|
||||
|
||||
_ensureCss() {
|
||||
if (document.getElementById(LB_CSS_ID)) return;
|
||||
const link = document.createElement("link");
|
||||
link.id = LB_CSS_ID;
|
||||
link.rel = "stylesheet";
|
||||
link.href = "/static/css/lightbox.css";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
build() {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "lightbox-overlay";
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
const COMP_CSS_ID = "dp-components-css";
|
||||
|
||||
export class Component extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this._ensureCss();
|
||||
}
|
||||
|
||||
_ensureCss() {
|
||||
if (document.getElementById(COMP_CSS_ID)) return;
|
||||
const link = document.createElement("link");
|
||||
link.id = COMP_CSS_ID;
|
||||
link.rel = "stylesheet";
|
||||
link.href = "/static/css/components.css";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
attr(name, fallback = "") {
|
||||
const value = this.getAttribute(name);
|
||||
return value === null ? fallback : value;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Component } from "./Component.js";
|
||||
|
||||
const FW_CSS_ID = "floating-window-css";
|
||||
const MOBILE_QUERY = "(max-width: 640px)";
|
||||
|
||||
export default class FloatingWindow extends HTMLElement {
|
||||
export default class FloatingWindow extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = "normal";
|
||||
|
||||
Reference in New Issue
Block a user