0.4.0.0.a3
- Added linkable and sharable commands - Added copy-buttons to command fields
This commit is contained in:
		
							parent
							
								
									66447865f5
								
							
						
					
					
						commit
						87bcc61a1a
					
				
							
								
								
									
										2
									
								
								bot.py
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								bot.py
									
									
									
									
									
								
							@ -9,7 +9,7 @@ from modules.common.boot_notice import post_boot_notice
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
# Version consists of:
 | 
					# Version consists of:
 | 
				
			||||||
# Major.Enhancement.Minor.Patch.Test  (Test is alphanumeric; doesn’t trigger auto update)
 | 
					# Major.Enhancement.Minor.Patch.Test  (Test is alphanumeric; doesn’t trigger auto update)
 | 
				
			||||||
VERSION = "0.4.0.0.a2"
 | 
					VERSION = "0.4.0.0.a3"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# ---------- Env loading ----------
 | 
					# ---------- Env loading ----------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -50,28 +50,6 @@ def _to_primitive(obj: Any, depth: int = 0) -> Any:
 | 
				
			|||||||
def _json_dumps_safe(payload: Any) -> bytes:
 | 
					def _json_dumps_safe(payload: Any) -> bytes:
 | 
				
			||||||
    return json.dumps(_to_primitive(payload), ensure_ascii=False, separators=(",", ":")).encode("utf-8")
 | 
					    return json.dumps(_to_primitive(payload), ensure_ascii=False, separators=(",", ":")).encode("utf-8")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# =============================
 | 
					 | 
				
			||||||
# File serving helpers
 | 
					 | 
				
			||||||
# =============================
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def _static_root() -> Path:
 | 
					 | 
				
			||||||
    return _project_root() / "assets" / "docs"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def _guess_mime(p: Path) -> str:
 | 
					 | 
				
			||||||
    ext = p.suffix.lower()
 | 
					 | 
				
			||||||
    return {
 | 
					 | 
				
			||||||
        ".svg": "image/svg+xml; charset=utf-8",
 | 
					 | 
				
			||||||
        ".png": "image/png",
 | 
					 | 
				
			||||||
        ".jpg": "image/jpeg",
 | 
					 | 
				
			||||||
        ".jpeg": "image/jpeg",
 | 
					 | 
				
			||||||
        ".webp": "image/webp",
 | 
					 | 
				
			||||||
        ".gif": "image/gif",
 | 
					 | 
				
			||||||
        ".css": "text/css; charset=utf-8",
 | 
					 | 
				
			||||||
        ".js": "application/javascript; charset=utf-8",
 | 
					 | 
				
			||||||
        ".json": "application/json; charset=utf-8",
 | 
					 | 
				
			||||||
        ".md": "text/markdown; charset=utf-8",
 | 
					 | 
				
			||||||
        ".txt": "text/plain; charset=utf-8",
 | 
					 | 
				
			||||||
    }.get(ext, "application/octet-stream")
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
# =============================
 | 
					# =============================
 | 
				
			||||||
# Version / uptime helpers
 | 
					# Version / uptime helpers
 | 
				
			||||||
@ -95,7 +73,6 @@ def _read_version_from_file() -> Optional[str]:
 | 
				
			|||||||
    return None
 | 
					    return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def _get_version_from_botpy() -> Optional[str]:
 | 
					def _get_version_from_botpy() -> Optional[str]:
 | 
				
			||||||
    # Secondary fallback: import
 | 
					 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        m = importlib.import_module("bot")
 | 
					        m = importlib.import_module("bot")
 | 
				
			||||||
        v = getattr(m, "VERSION", None)
 | 
					        v = getattr(m, "VERSION", None)
 | 
				
			||||||
@ -203,7 +180,7 @@ def _is_mod_command_slash(cmd: app_commands.Command) -> Tuple[bool, List[str]]:
 | 
				
			|||||||
        for chk in getattr(cmd, "checks", []) or []:
 | 
					        for chk in getattr(cmd, "checks", []) or []:
 | 
				
			||||||
            if _looks_like_mod_check(chk):
 | 
					            if _looks_like_mod_check(chk):
 | 
				
			||||||
                is_mod = True
 | 
					                is_mod = True
 | 
				
			||||||
        for chk in getattr(cmd, "_checks", []) or []:  # private best-effort
 | 
					        for chk in getattr(cmd, "_checks", []) or []:
 | 
				
			||||||
            if _looks_like_mod_check(chk):
 | 
					            if _looks_like_mod_check(chk):
 | 
				
			||||||
                is_mod = True
 | 
					                is_mod = True
 | 
				
			||||||
    except Exception:
 | 
					    except Exception:
 | 
				
			||||||
@ -248,6 +225,30 @@ def _command_usage_slash(cmd: app_commands.Command) -> str:
 | 
				
			|||||||
    except Exception:
 | 
					    except Exception:
 | 
				
			||||||
        return f"/{cmd.name}"
 | 
					        return f"/{cmd.name}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _iter_all_app_commands(bot: commands.Bot):
 | 
				
			||||||
 | 
					    """Yield (scope_tag, top_level_command) including global and per-guild trees."""
 | 
				
			||||||
 | 
					    out = []
 | 
				
			||||||
 | 
					    # Global
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        for cmd in bot.tree.get_commands():
 | 
				
			||||||
 | 
					            out.append(("", cmd))
 | 
				
			||||||
 | 
					    except Exception:
 | 
				
			||||||
 | 
					        pass
 | 
				
			||||||
 | 
					    # Per-guild
 | 
				
			||||||
 | 
					    for g in list(getattr(bot, "guilds", []) or []):
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            cmds = bot.tree.get_commands(guild=g)
 | 
				
			||||||
 | 
					        except TypeError:
 | 
				
			||||||
 | 
					            try:
 | 
				
			||||||
 | 
					                cmds = bot.tree.get_commands(guild=discord.Object(id=g.id))
 | 
				
			||||||
 | 
					            except Exception:
 | 
				
			||||||
 | 
					                cmds = []
 | 
				
			||||||
 | 
					        except Exception:
 | 
				
			||||||
 | 
					            cmds = []
 | 
				
			||||||
 | 
					        for cmd in cmds or []:
 | 
				
			||||||
 | 
					            out.append((str(g.id), cmd))
 | 
				
			||||||
 | 
					    return out
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def _gather_prefix_and_hybrid(bot: commands.Bot) -> List[Dict[str, Any]]:
 | 
					def _gather_prefix_and_hybrid(bot: commands.Bot) -> List[Dict[str, Any]]:
 | 
				
			||||||
    rows: List[Dict[str, Any]] = []
 | 
					    rows: List[Dict[str, Any]] = []
 | 
				
			||||||
    seen = set()
 | 
					    seen = set()
 | 
				
			||||||
@ -282,7 +283,7 @@ def _gather_prefix_and_hybrid(bot: commands.Bot) -> List[Dict[str, Any]]:
 | 
				
			|||||||
            row = {
 | 
					            row = {
 | 
				
			||||||
                "type": ctype,
 | 
					                "type": ctype,
 | 
				
			||||||
                "name": cmd.qualified_name,
 | 
					                "name": cmd.qualified_name,
 | 
				
			||||||
                "display_name": cmd.qualified_name,  # bare name, no slash/prefix
 | 
					                "display_name": cmd.qualified_name,  # bare name
 | 
				
			||||||
                "help": (cmd.help or "").strip(),
 | 
					                "help": (cmd.help or "").strip(),
 | 
				
			||||||
                "brief": (cmd.brief or "").strip(),
 | 
					                "brief": (cmd.brief or "").strip(),
 | 
				
			||||||
                "usage": usage_prefix if not is_hybrid else None,
 | 
					                "usage": usage_prefix if not is_hybrid else None,
 | 
				
			||||||
@ -323,22 +324,18 @@ def _safe_extras(obj: Any) -> Optional[Dict[str, Any]]:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
 | 
					def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
 | 
				
			||||||
    rows: List[Dict[str, Any]] = []
 | 
					    rows: List[Dict[str, Any]] = []
 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Collect paths from all scopes (global + each guild)
 | 
					 | 
				
			||||||
    collected: List[Tuple[str, app_commands.Command, str]] = []
 | 
					    collected: List[Tuple[str, app_commands.Command, str]] = []
 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        for scope, top in _iter_all_app_commands(bot):
 | 
					        for scope, top in _iter_all_app_commands(bot):
 | 
				
			||||||
            # walk each top-level into leaves
 | 
					 | 
				
			||||||
            for path, leaf in _walk_app_tree(top, prefix=""):
 | 
					            for path, leaf in _walk_app_tree(top, prefix=""):
 | 
				
			||||||
                collected.append((scope, leaf, path))
 | 
					                collected.append((scope, leaf, path))
 | 
				
			||||||
    except Exception:
 | 
					    except Exception:
 | 
				
			||||||
        traceback.print_exc()
 | 
					        traceback.print_exc()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    # De-dupe by canonical path (e.g. "power/restart"), regardless of scope
 | 
					 | 
				
			||||||
    seen_paths = set()
 | 
					    seen_paths = set()
 | 
				
			||||||
    for scope, leaf, path in collected:
 | 
					    for scope, leaf, path in collected:
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            canon = path.lstrip("/")  # e.g. "power/restart"
 | 
					            canon = path.lstrip("/")  # power/restart
 | 
				
			||||||
            if canon in seen_paths:
 | 
					            if canon in seen_paths:
 | 
				
			||||||
                continue
 | 
					                continue
 | 
				
			||||||
            seen_paths.add(canon)
 | 
					            seen_paths.add(canon)
 | 
				
			||||||
@ -347,19 +344,14 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
 | 
				
			|||||||
            binding = getattr(leaf, "binding", None)
 | 
					            binding = getattr(leaf, "binding", None)
 | 
				
			||||||
            callback = getattr(leaf, "callback", None)
 | 
					            callback = getattr(leaf, "callback", None)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            # UI shows "power restart" (title), but usage keeps "/power restart ..."
 | 
					 | 
				
			||||||
            display = canon.replace("/", " ")
 | 
					            display = canon.replace("/", " ")
 | 
				
			||||||
            options = (
 | 
					            options = getattr(leaf, "options", None) or getattr(leaf, "parameters", None) or getattr(leaf, "_params", None)
 | 
				
			||||||
                getattr(leaf, "options", None)
 | 
					 | 
				
			||||||
                or getattr(leaf, "parameters", None)
 | 
					 | 
				
			||||||
                or getattr(leaf, "_params", None)
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
            usage_full = _command_usage_slash_like(display, options)
 | 
					            usage_full = _command_usage_slash_like(display, options)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            row = {
 | 
					            row = {
 | 
				
			||||||
                "type": "slash",
 | 
					                "type": "slash",
 | 
				
			||||||
                "name": "/" + canon,            # canonical with leading slash
 | 
					                "name": "/" + canon,
 | 
				
			||||||
                "display_name": "/" + display,  # shown without the leading slash in UI
 | 
					                "display_name": "/" + display,
 | 
				
			||||||
                "help": (getattr(leaf, "description", "") or "").strip(),
 | 
					                "help": (getattr(leaf, "description", "") or "").strip(),
 | 
				
			||||||
                "brief": "",
 | 
					                "brief": "",
 | 
				
			||||||
                "usage": usage_full,
 | 
					                "usage": usage_full,
 | 
				
			||||||
@ -376,7 +368,6 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
 | 
				
			|||||||
        except Exception:
 | 
					        except Exception:
 | 
				
			||||||
            traceback.print_exc()
 | 
					            traceback.print_exc()
 | 
				
			||||||
            continue
 | 
					            continue
 | 
				
			||||||
 | 
					 | 
				
			||||||
    return rows
 | 
					    return rows
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -399,7 +390,7 @@ def _row_key_candidates(row: Dict[str, Any]) -> List[str]:
 | 
				
			|||||||
    if c:
 | 
					    if c:
 | 
				
			||||||
        keys.append(f"{c}.{base}")
 | 
					        keys.append(f"{c}.{base}")
 | 
				
			||||||
    if row.get("type") == "slash":
 | 
					    if row.get("type") == "slash":
 | 
				
			||||||
        keys.append(str(row.get("name", "")).lstrip("/"))  # e.g., power/restart
 | 
					        keys.append(str(row.get("name", "")).lstrip("/"))
 | 
				
			||||||
    keys.append(base)
 | 
					    keys.append(base)
 | 
				
			||||||
    return keys
 | 
					    return keys
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -535,35 +526,6 @@ def _merge_hybrid_slash(rows: List[Dict[str, Any]]) -> None:
 | 
				
			|||||||
    for i in sorted(to_remove, reverse=True):
 | 
					    for i in sorted(to_remove, reverse=True):
 | 
				
			||||||
        rows.pop(i)
 | 
					        rows.pop(i)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# =============================
 | 
					 | 
				
			||||||
# Global commands helper
 | 
					 | 
				
			||||||
# =============================
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def _iter_all_app_commands(bot: commands.Bot):
 | 
					 | 
				
			||||||
    """Yield (path, app_commands.Command) for global and per-guild trees."""
 | 
					 | 
				
			||||||
    out = []
 | 
					 | 
				
			||||||
    # Global
 | 
					 | 
				
			||||||
    try:
 | 
					 | 
				
			||||||
        for cmd in bot.tree.get_commands():
 | 
					 | 
				
			||||||
            out.append(("", cmd))  # empty scope tag
 | 
					 | 
				
			||||||
    except Exception:
 | 
					 | 
				
			||||||
        pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    # Per-guild (guild-specific commands live here)
 | 
					 | 
				
			||||||
    for g in list(getattr(bot, "guilds", []) or []):
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            cmds = bot.tree.get_commands(guild=g)
 | 
					 | 
				
			||||||
        except TypeError:
 | 
					 | 
				
			||||||
            # older d.py variants accept Snowflake-like instead of Guild
 | 
					 | 
				
			||||||
            try:
 | 
					 | 
				
			||||||
                cmds = bot.tree.get_commands(guild=discord.Object(id=g.id))
 | 
					 | 
				
			||||||
            except Exception:
 | 
					 | 
				
			||||||
                cmds = []
 | 
					 | 
				
			||||||
        except Exception:
 | 
					 | 
				
			||||||
            cmds = []
 | 
					 | 
				
			||||||
        for cmd in cmds or []:
 | 
					 | 
				
			||||||
            out.append((str(g.id), cmd))  # scope tag = guild id as string
 | 
					 | 
				
			||||||
    return out
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
# =============================
 | 
					# =============================
 | 
				
			||||||
# Schema builder
 | 
					# Schema builder
 | 
				
			||||||
@ -608,6 +570,30 @@ def build_command_schema(bot: commands.Bot) -> Dict[str, Any]:
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# =============================
 | 
				
			||||||
 | 
					# Static asset serving
 | 
				
			||||||
 | 
					# =============================
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _static_root() -> Path:
 | 
				
			||||||
 | 
					    return _project_root() / "assets" / "docs"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _guess_mime(p: Path) -> str:
 | 
				
			||||||
 | 
					    ext = p.suffix.lower()
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					        ".svg": "image/svg+xml; charset=utf-8",
 | 
				
			||||||
 | 
					        ".png": "image/png",
 | 
				
			||||||
 | 
					        ".jpg": "image/jpeg",
 | 
				
			||||||
 | 
					        ".jpeg": "image/jpeg",
 | 
				
			||||||
 | 
					        ".webp": "image/webp",
 | 
				
			||||||
 | 
					        ".gif": "image/gif",
 | 
				
			||||||
 | 
					        ".css": "text/css; charset=utf-8",
 | 
				
			||||||
 | 
					        ".js": "application/javascript; charset=utf-8",
 | 
				
			||||||
 | 
					        ".json": "application/json; charset=utf-8",
 | 
				
			||||||
 | 
					        ".md": "text/markdown; charset=utf-8",
 | 
				
			||||||
 | 
					        ".txt": "text/plain; charset=utf-8",
 | 
				
			||||||
 | 
					    }.get(ext, "application/octet-stream")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# =============================
 | 
					# =============================
 | 
				
			||||||
# HTTP + UI
 | 
					# HTTP + UI
 | 
				
			||||||
# =============================
 | 
					# =============================
 | 
				
			||||||
@ -630,7 +616,7 @@ _HTML = """<!doctype html>
 | 
				
			|||||||
  .toolbar { margin-bottom:16px; position:sticky; top: var(--header-h); z-index:2; }
 | 
					  .toolbar { margin-bottom:16px; position:sticky; top: var(--header-h); z-index:2; }
 | 
				
			||||||
  .search { width:100%; padding:10px 12px; border-radius:8px; border:1px solid #1f2937; background:#0b1220; color:var(--fg); }
 | 
					  .search { width:100%; padding:10px 12px; border-radius:8px; border:1px solid #1f2937; background:#0b1220; color:var(--fg); }
 | 
				
			||||||
  .list { margin-top:12px; display:flex; flex-direction:column; gap:10px; transition: filter .2s ease; }
 | 
					  .list { margin-top:12px; display:flex; flex-direction:column; gap:10px; transition: filter .2s ease; }
 | 
				
			||||||
  .card { border:1px solid #233; border-radius:10px; padding:10px 12px; background:#0c1522; cursor:default; }
 | 
					  .card { border:1px solid #233; border-radius:10px; padding:10px 12px; background:#0c1522; cursor:default; scroll-margin-top: calc(var(--sticky-top) + 12px); }
 | 
				
			||||||
  .name { font-weight:600; display:flex; align-items:center; gap:8px; }
 | 
					  .name { font-weight:600; display:flex; align-items:center; gap:8px; }
 | 
				
			||||||
  .meta { font-size:12px; color:var(--muted); display:flex; gap:10px; flex-wrap:wrap; margin-top:4px; }
 | 
					  .meta { font-size:12px; color:var(--muted); display:flex; gap:10px; flex-wrap:wrap; margin-top:4px; }
 | 
				
			||||||
  .pill { display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; border:1px solid #2b4; }
 | 
					  .pill { display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; border:1px solid #2b4; }
 | 
				
			||||||
@ -638,25 +624,40 @@ _HTML = """<!doctype html>
 | 
				
			|||||||
  .pill.slash { border-color:#60a5fa; }
 | 
					  .pill.slash { border-color:#60a5fa; }
 | 
				
			||||||
  .pill.prefix { border-color:#f59e0b; }
 | 
					  .pill.prefix { border-color:#f59e0b; }
 | 
				
			||||||
  .pill.hybrid { border-color:#34d399; }
 | 
					  .pill.hybrid { border-color:#34d399; }
 | 
				
			||||||
  .usage { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:12px; background:#0a1220; border:1px dashed #1f2937; padding:6px 8px; border-radius:6px; margin-top:6px; word-break:break-word; }
 | 
					  .usage { position:relative; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:12px; background:#0a1220; border:1px dashed #1f2937; padding:6px 8px; border-radius:6px; margin-top:6px; word-break:break-word; }
 | 
				
			||||||
  .help { margin-top:6px; color:#cbd5e1; }
 | 
					  .help { margin-top:6px; color:#cbd5e1; }
 | 
				
			||||||
  .btn { padding:4px 8px; border:1px solid #334155; border-radius:8px; background:#0b1220; color:#e5e7eb; cursor:pointer; font-size:12px; }
 | 
					  .btn { padding:4px 8px; border:1px solid #334155; border-radius:8px; background:#0b1220; color:#e5e7eb; cursor:pointer; font-size:12px; }
 | 
				
			||||||
  .btn:hover { background:#0f172a; }
 | 
					  .btn:hover { background:#0f172a; }
 | 
				
			||||||
 | 
					  .btn-icon { width:28px; height:28px; display:inline-flex; align-items:center; justify-content:center; padding:0; }
 | 
				
			||||||
 | 
					  .btn-row { display:flex; gap:8px; align-items:center; }
 | 
				
			||||||
  .detailsbox { margin-top:12px; position: sticky; top: var(--sticky-top); min-height: 280px; z-index:1; transition: transform .25s ease; }
 | 
					  .detailsbox { margin-top:12px; position: sticky; top: var(--sticky-top); min-height: 280px; z-index:1; transition: transform .25s ease; }
 | 
				
			||||||
  .flag-emoji { height:1em; width:auto; vertical-align:-0.18em; border-radius:2px; display:inline-block; }
 | 
					  .flag-emoji { height:1em; width:auto; vertical-align:-0.18em; border-radius:2px; display:inline-block; }
 | 
				
			||||||
  footer { margin-top:16px; color:var(--muted); font-size:12px; text-align:center; }
 | 
					  footer { margin-top:16px; color:var(--muted); font-size:12px; text-align:center; }
 | 
				
			||||||
  footer .line { margin:4px 0; }
 | 
					  footer .line { margin:4px 0; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /* Blur veil between header and toolbar (gradually increases up to toolbar) */
 | 
					  /* Copy modal */
 | 
				
			||||||
 | 
					  #copyModal { position: fixed; inset: 0; display:none; align-items:center; justify-content:center; z-index: 10; }
 | 
				
			||||||
 | 
					  #copyModal.open { display:flex; }
 | 
				
			||||||
 | 
					  #copyModal .overlay { position:absolute; inset:0; background:rgba(0,0,0,.4); }
 | 
				
			||||||
 | 
					  #copyModal .sheet { position:relative; z-index:1; min-width: min(520px, 92vw); background:var(--panel); border:1px solid #1f2937; border-radius:12px; padding:16px; box-shadow: 0 20px 60px rgba(0,0,0,.45); }
 | 
				
			||||||
 | 
					  #copyModal .close { position:absolute; right:10px; top:10px; }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /* Copy button inside usage */
 | 
				
			||||||
 | 
					  .usage .copybtn { position:absolute; right:6px; top:6px; }
 | 
				
			||||||
 | 
					  @media (max-width: 900px) {
 | 
				
			||||||
 | 
					    .usage .copybtn { right:6px; top:6px; }
 | 
				
			||||||
 | 
					    .btn-icon { width:auto; padding:4px 10px; } /* larger tap target on mobile */
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /* Blur veil under the toolbar */
 | 
				
			||||||
  .veil {
 | 
					  .veil {
 | 
				
			||||||
    position: sticky;
 | 
					    position: sticky;
 | 
				
			||||||
    top: var(--header-h);
 | 
					    top: var(--header-h);
 | 
				
			||||||
    height: var(--veil-h, 16px);
 | 
					    height: var(--veil-h, 16px);
 | 
				
			||||||
    pointer-events: none;
 | 
					    pointer-events: none;
 | 
				
			||||||
    z-index: 2; /* just under toolbar */
 | 
					    z-index: 2;
 | 
				
			||||||
    backdrop-filter: blur(4px);
 | 
					    backdrop-filter: blur(4px);
 | 
				
			||||||
    -webkit-backdrop-filter: blur(4px);
 | 
					    -webkit-backdrop-filter: blur(4px);
 | 
				
			||||||
    /* Gradual mask: 0 blur visible at top, full blur at bottom near the toolbar */
 | 
					 | 
				
			||||||
    mask-image: linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,1));
 | 
					    mask-image: linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,1));
 | 
				
			||||||
    -webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,1));
 | 
					    -webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,1));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@ -722,9 +723,21 @@ _HTML = """<!doctype html>
 | 
				
			|||||||
  <footer id="footer">
 | 
					  <footer id="footer">
 | 
				
			||||||
    <div class="line" id="copyright"></div>
 | 
					    <div class="line" id="copyright"></div>
 | 
				
			||||||
    <div class="line" id="statusline">Uptime: — · Version: v—</div>
 | 
					    <div class="line" id="statusline">Uptime: — · Version: v—</div>
 | 
				
			||||||
    <div class="line" id="coffee"><a href="https://throne.com/ookamikuntv/item/39590391-c582-4c5d-8795-fe6f1925eaae">Buy me a ☕</a></div>
 | 
					 | 
				
			||||||
  </footer>
 | 
					  </footer>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  <!-- Copy/share modal -->
 | 
				
			||||||
 | 
					  <div id="copyModal" aria-hidden="true">
 | 
				
			||||||
 | 
					    <div class="overlay" data-close="1"></div>
 | 
				
			||||||
 | 
					    <div class="sheet">
 | 
				
			||||||
 | 
					      <button class="btn btn-icon close" title="Close" data-close="1">✕</button>
 | 
				
			||||||
 | 
					      <div id="copyText" style="word-break:break-all; font-family: ui-monospace, monospace;"></div>
 | 
				
			||||||
 | 
					      <div style="margin-top:10px" class="btn-row">
 | 
				
			||||||
 | 
					        <button id="copyAction" class="btn">Copy</button>
 | 
				
			||||||
 | 
					        <span id="copyHint" style="font-size:12px;color:#9ca3af">Tap outside to dismiss.</span>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <div id="backdrop"></div>
 | 
					  <div id="backdrop"></div>
 | 
				
			||||||
</main>
 | 
					</main>
 | 
				
			||||||
<script>
 | 
					<script>
 | 
				
			||||||
@ -732,13 +745,10 @@ function computeStickyTop() {
 | 
				
			|||||||
  const header = document.querySelector('header');
 | 
					  const header = document.querySelector('header');
 | 
				
			||||||
  const toolbar = document.getElementById('toolbar');
 | 
					  const toolbar = document.getElementById('toolbar');
 | 
				
			||||||
  const headerH = header ? header.offsetHeight : 0;
 | 
					  const headerH = header ? header.offsetHeight : 0;
 | 
				
			||||||
  // Set how much veil space we want between header bottom and toolbar top.
 | 
					  const veilH = 16;
 | 
				
			||||||
  const veilH = 16; // px
 | 
					 | 
				
			||||||
  // Toolbar sticks immediately under header
 | 
					 | 
				
			||||||
  document.documentElement.style.setProperty('--header-h', headerH + 'px');
 | 
					  document.documentElement.style.setProperty('--header-h', headerH + 'px');
 | 
				
			||||||
  // Details panel sticks under header + toolbar + small rhythm spacing
 | 
					 | 
				
			||||||
  const toolbarH = toolbar ? toolbar.offsetHeight : 0;
 | 
					  const toolbarH = toolbar ? toolbar.offsetHeight : 0;
 | 
				
			||||||
  const stickyTop = headerH + toolbarH + 8; // rhythm
 | 
					  const stickyTop = headerH + toolbarH + 8;
 | 
				
			||||||
  document.documentElement.style.setProperty('--sticky-top', stickyTop + 'px');
 | 
					  document.documentElement.style.setProperty('--sticky-top', stickyTop + 'px');
 | 
				
			||||||
  const veil = document.getElementById('veil');
 | 
					  const veil = document.getElementById('veil');
 | 
				
			||||||
  if (veil) {
 | 
					  if (veil) {
 | 
				
			||||||
@ -778,6 +788,64 @@ function renderMD(src) {
 | 
				
			|||||||
  return s;
 | 
					  return s;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// --------- Linking helpers ---------
 | 
				
			||||||
 | 
					function rowAnchor(r) {
 | 
				
			||||||
 | 
					  // Stable anchor: "<cog-or-nocog>-<path with slashes -> dashes>"
 | 
				
			||||||
 | 
					  const cog = (r.cog || 'nocog').toLowerCase();
 | 
				
			||||||
 | 
					  let base = (r.name || '').toLowerCase();
 | 
				
			||||||
 | 
					  base = base.replace(/^\\//, '');      // remove leading "/"
 | 
				
			||||||
 | 
					  base = base.replace(/\\s+/g, '-');    // spaces to dash
 | 
				
			||||||
 | 
					  base = base.replace(/\\//g, '-');     // slashes to dash
 | 
				
			||||||
 | 
					  return `${cog}-${base}`;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					function getFilterFromHash() {
 | 
				
			||||||
 | 
					  const h = (location.hash || '#user').slice(1);
 | 
				
			||||||
 | 
					  // support legacy "cmd=" in hash too
 | 
				
			||||||
 | 
					  const parts = h.split('&').map(s => s.trim());
 | 
				
			||||||
 | 
					  const filt = parts.find(p => p === 'user' || p === 'moderator' || p === 'all') || 'user';
 | 
				
			||||||
 | 
					  let cmd = null;
 | 
				
			||||||
 | 
					  const cmdPart = parts.find(p => p.startsWith('cmd='));
 | 
				
			||||||
 | 
					  if (cmdPart) cmd = cmdPart.slice(4);
 | 
				
			||||||
 | 
					  // also check query
 | 
				
			||||||
 | 
					  const sp = new URLSearchParams(location.search);
 | 
				
			||||||
 | 
					  const qcmd = sp.get('cmd');
 | 
				
			||||||
 | 
					  if (!cmd && qcmd) cmd = qcmd;
 | 
				
			||||||
 | 
					  return { filter: filt, cmd };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					function buildLink(anchor) {
 | 
				
			||||||
 | 
					  const url = new URL(window.location.href);
 | 
				
			||||||
 | 
					  url.searchParams.set('cmd', anchor);
 | 
				
			||||||
 | 
					  url.hash = location.hash || '#user';
 | 
				
			||||||
 | 
					  return url.toString();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					function replaceURLFor(anchor) {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const url = new URL(window.location.href);
 | 
				
			||||||
 | 
					    url.searchParams.set('cmd', anchor);
 | 
				
			||||||
 | 
					    history.replaceState(null, '', url.toString());
 | 
				
			||||||
 | 
					  } catch {}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					async function copyText(s) {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    await navigator.clipboard.writeText(s);
 | 
				
			||||||
 | 
					    return true;
 | 
				
			||||||
 | 
					  } catch {
 | 
				
			||||||
 | 
					    return false;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					function openCopyModal(text) {
 | 
				
			||||||
 | 
					  const modal = document.getElementById('copyModal');
 | 
				
			||||||
 | 
					  const sheet = modal.querySelector('.sheet');
 | 
				
			||||||
 | 
					  document.getElementById('copyText').textContent = text;
 | 
				
			||||||
 | 
					  const action = document.getElementById('copyAction');
 | 
				
			||||||
 | 
					  const closeables = modal.querySelectorAll('[data-close]');
 | 
				
			||||||
 | 
					  const close = () => { modal.classList.remove('open'); action.onclick = null; closeables.forEach(el => el.onclick = null); };
 | 
				
			||||||
 | 
					  closeables.forEach(el => el.onclick = close);
 | 
				
			||||||
 | 
					  action.onclick = async () => { const ok = await copyText(text); if (ok) close(); };
 | 
				
			||||||
 | 
					  modal.classList.add('open');
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// --------- App ---------
 | 
				
			||||||
(function() {
 | 
					(function() {
 | 
				
			||||||
  if (!location.hash) location.hash = '#user';
 | 
					  if (!location.hash) location.hash = '#user';
 | 
				
			||||||
  let data = (window.__DATA__ || null);
 | 
					  let data = (window.__DATA__ || null);
 | 
				
			||||||
@ -790,32 +858,67 @@ function renderMD(src) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  function shownName(r) {
 | 
					  function shownName(r) {
 | 
				
			||||||
    const n = (r.display_name || r.name || '');
 | 
					    const n = (r.display_name || r.name || '');
 | 
				
			||||||
    return n.replace(/^\\//, ''); // strip leading '/'
 | 
					    return n.replace(/^\\//, '');
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  function helpSansMod(r) {
 | 
					  function helpSansMod(r) {
 | 
				
			||||||
    return (r.help || '').replace(/^\\s*\\[MOD\\]\\s*/i, '');
 | 
					    return (r.help || '').replace(/^\\s*\\[MOD\\]\\s*/i, '');
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  function moduleSansPrefix(r) {
 | 
					  function moduleSansPrefix(r) {
 | 
				
			||||||
    const m = r.module || '';
 | 
					    const m = r.module || '';
 | 
				
			||||||
    return m.replace(/^modules?\\./, ''); // 'modules.' or 'module.'
 | 
					    return m.replace(/^modules?\\./, '');
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async function shareFor(r) {
 | 
				
			||||||
 | 
					    const anchor = rowAnchor(r);
 | 
				
			||||||
 | 
					    const url = buildLink(anchor);
 | 
				
			||||||
 | 
					    const ok = await copyText(url);
 | 
				
			||||||
 | 
					    if (!ok) openCopyModal(url);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  function usageBlockHTML(text) {
 | 
				
			||||||
 | 
					    if (!text) return '';
 | 
				
			||||||
 | 
					    const esc = String(text).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
 | 
				
			||||||
 | 
					    return `
 | 
				
			||||||
 | 
					      <div class="usage">
 | 
				
			||||||
 | 
					        ${esc}
 | 
				
			||||||
 | 
					        <button class="btn btn-icon copybtn" title="Copy usage" data-copy="${esc}">📋</button>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    `;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  function wireUsageCopy(container) {
 | 
				
			||||||
 | 
					    container.querySelectorAll('[data-copy]').forEach(btn => {
 | 
				
			||||||
 | 
					      btn.addEventListener('click', async (ev) => {
 | 
				
			||||||
 | 
					        ev.stopPropagation();
 | 
				
			||||||
 | 
					        const t = btn.getAttribute('data-copy').replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&');
 | 
				
			||||||
 | 
					        const ok = await copyText(t);
 | 
				
			||||||
 | 
					        if (!ok) openCopyModal(t);
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  function openDetails(r) {
 | 
					  function openDetails(r) {
 | 
				
			||||||
 | 
					    const anchor = rowAnchor(r);
 | 
				
			||||||
 | 
					    replaceURLFor(anchor); // keep URL synced
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let html = `
 | 
					    let html = `
 | 
				
			||||||
      <div class="name" style="margin-bottom:6px">
 | 
					      <div class="name" style="margin-bottom:6px">
 | 
				
			||||||
        <span class="pill ${r.type}">${r.type}</span>
 | 
					        <span class="pill ${r.type}">${r.type}</span>
 | 
				
			||||||
        ${r.moderator_only ? '<span class="pill mod">mod</span>' : ''}
 | 
					        ${r.moderator_only ? '<span class="pill mod">mod</span>' : ''}
 | 
				
			||||||
        <span>${shownName(r)}</span>
 | 
					        <span>${shownName(r)}</span>
 | 
				
			||||||
        <span style="flex:1"></span>
 | 
					        <span style="flex:1"></span>
 | 
				
			||||||
        <button class="btn" id="closeDetails" style="display:none">Close</button>
 | 
					        <div class="btn-row">
 | 
				
			||||||
 | 
					          <button class="btn btn-icon" title="Copy link" id="shareDetails">🔗</button>
 | 
				
			||||||
 | 
					          <button class="btn" id="closeDetails" style="display:none">Close</button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <div class="meta">
 | 
					      <div class="meta">
 | 
				
			||||||
        ${r.cog ? `<span>cog: ${r.cog}</span>` : '' }
 | 
					        ${r.cog ? `<span>cog: ${r.cog}</span>` : '' }
 | 
				
			||||||
        ${r.module ? `<span>module: ${moduleSansPrefix(r)}</span>` : '' }
 | 
					        ${r.module ? `<span>module: ${moduleSansPrefix(r)}</span>` : '' }
 | 
				
			||||||
        ${r.required_permissions && r.required_permissions.length ? `<span>perms: ${r.required_permissions.join(', ')}</span>` : '' }
 | 
					        ${r.required_permissions && r.required_permissions.length ? `<span>perms: ${r.required_permissions.join(', ')}</span>` : '' }
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      ${r.usage_prefix ? `<div class="usage">${r.usage_prefix}</div>` : ''}
 | 
					      ${usageBlockHTML(r.usage_prefix)}
 | 
				
			||||||
      ${r.usage_slash ? `<div class="usage">${r.usage_slash}</div>` : ''}
 | 
					      ${usageBlockHTML(r.usage_slash)}
 | 
				
			||||||
      ${helpSansMod(r) ? `<div class="help">${helpSansMod(r)}</div>` : ''}
 | 
					      ${helpSansMod(r) ? `<div class="help">${helpSansMod(r)}</div>` : ''}
 | 
				
			||||||
    `;
 | 
					    `;
 | 
				
			||||||
    if (r.details_md) {
 | 
					    if (r.details_md) {
 | 
				
			||||||
@ -830,6 +933,11 @@ function renderMD(src) {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
    detailsEl.innerHTML = html;
 | 
					    detailsEl.innerHTML = html;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Wire share + copy buttons
 | 
				
			||||||
 | 
					    const shareBtn = document.getElementById('shareDetails');
 | 
				
			||||||
 | 
					    if (shareBtn) shareBtn.onclick = (e) => { e.stopPropagation(); shareFor(r); };
 | 
				
			||||||
 | 
					    wireUsageCopy(detailsEl);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Mobile sheet open
 | 
					    // Mobile sheet open
 | 
				
			||||||
    if (window.matchMedia('(max-width: 900px)').matches) {
 | 
					    if (window.matchMedia('(max-width: 900px)').matches) {
 | 
				
			||||||
      detailsEl.classList.add('open');
 | 
					      detailsEl.classList.add('open');
 | 
				
			||||||
@ -850,32 +958,32 @@ function renderMD(src) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  function card(r) {
 | 
					  function card(r) {
 | 
				
			||||||
    const c = document.createElement('div');
 | 
					    const c = document.createElement('div');
 | 
				
			||||||
 | 
					    const anchor = rowAnchor(r);
 | 
				
			||||||
    c.className = 'card';
 | 
					    c.className = 'card';
 | 
				
			||||||
 | 
					    c.id = 'card-' + anchor;
 | 
				
			||||||
 | 
					    c.dataset.anchor = anchor;
 | 
				
			||||||
    c.dataset.search = [shownName(r), helpSansMod(r), r.brief, r.usage, r.usage_prefix, r.usage_slash, r.cog, r.module, r.details_md || ""].join(' ').toLowerCase();
 | 
					    c.dataset.search = [shownName(r), helpSansMod(r), r.brief, r.usage, r.usage_prefix, r.usage_slash, r.cog, r.module, r.details_md || ""].join(' ').toLowerCase();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const usageBlock = (() => {
 | 
					    const usageHTML = (r.type === 'hybrid')
 | 
				
			||||||
      if (r.type === 'hybrid') {
 | 
					      ? `${usageBlockHTML(r.usage_prefix)}${usageBlockHTML(r.usage_slash)}`
 | 
				
			||||||
        return `
 | 
					      : `${usageBlockHTML(r.usage)}`;
 | 
				
			||||||
          ${r.usage_prefix ? `<div class="usage">${r.usage_prefix}</div>` : ''}
 | 
					 | 
				
			||||||
          ${r.usage_slash ? `<div class="usage">${r.usage_slash}</div>` : ''}
 | 
					 | 
				
			||||||
        `;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      return r.usage ? `<div class="usage">${r.usage}</div>` : '';
 | 
					 | 
				
			||||||
    })();
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    c.innerHTML = `
 | 
					    c.innerHTML = `
 | 
				
			||||||
      <div class="name">
 | 
					      <div class="name">
 | 
				
			||||||
        <span class="pill ${r.type}">${r.type}</span>
 | 
					        <span class="pill ${r.type}">${r.type}</span>
 | 
				
			||||||
        ${r.moderator_only ? '<span class="pill mod">mod</span>' : ''}
 | 
					        ${r.moderator_only ? '<span class="pill mod">mod</span>' : ''}
 | 
				
			||||||
        <span>${shownName(r)}</span>
 | 
					        <span>${shownName(r)}</span>
 | 
				
			||||||
        <button class="btn" data-details="1">Details</button>
 | 
					        <div class="btn-row">
 | 
				
			||||||
 | 
					          <button class="btn btn-icon" title="Copy link" data-share="1">🔗</button>
 | 
				
			||||||
 | 
					          <button class="btn" data-details="1">Details</button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      <div class="meta">
 | 
					      <div class="meta">
 | 
				
			||||||
        ${r.cog ? `<span>cog: ${r.cog}</span>` : '' }
 | 
					        ${r.cog ? `<span>cog: ${r.cog}</span>` : '' }
 | 
				
			||||||
        ${r.module ? `<span>module: ${moduleSansPrefix(r)}</span>` : '' }
 | 
					        ${r.module ? `<span>module: ${moduleSansPrefix(r)}</span>` : '' }
 | 
				
			||||||
        ${r.required_permissions && r.required_permissions.length ? `<span>perms: ${r.required_permissions.join(', ')}</span>` : '' }
 | 
					        ${r.required_permissions && r.required_permissions.length ? `<span>perms: ${r.required_permissions.join(', ')}</span>` : '' }
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
      ${usageBlock}
 | 
					      ${usageHTML}
 | 
				
			||||||
      ${helpSansMod(r) ? `<div class="help">${helpSansMod(r)}</div>` : ''}
 | 
					      ${helpSansMod(r) ? `<div class="help">${helpSansMod(r)}</div>` : ''}
 | 
				
			||||||
    `;
 | 
					    `;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -883,7 +991,14 @@ function renderMD(src) {
 | 
				
			|||||||
      ev.stopPropagation();
 | 
					      ev.stopPropagation();
 | 
				
			||||||
      openDetails(r);
 | 
					      openDetails(r);
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					    c.querySelector('[data-share]').addEventListener('click', async (ev) => {
 | 
				
			||||||
 | 
					      ev.stopPropagation();
 | 
				
			||||||
 | 
					      await shareFor(r);
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
    c.addEventListener('click', () => openDetails(r));
 | 
					    c.addEventListener('click', () => openDetails(r));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Wire usage copy buttons within card
 | 
				
			||||||
 | 
					    wireUsageCopy(c);
 | 
				
			||||||
    return c;
 | 
					    return c;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -892,7 +1007,7 @@ function renderMD(src) {
 | 
				
			|||||||
    rows.forEach(r => target.appendChild(card(r)));
 | 
					    rows.forEach(r => target.appendChild(card(r)));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  function applyFilter() {
 | 
					  function applyFilter(selectAnchorIfPresent=true) {
 | 
				
			||||||
    if (!data) return;
 | 
					    if (!data) return;
 | 
				
			||||||
    const all = data.all || [];
 | 
					    const all = data.all || [];
 | 
				
			||||||
    const mods = (data.sections && data.sections.moderator) || [];
 | 
					    const mods = (data.sections && data.sections.moderator) || [];
 | 
				
			||||||
@ -903,14 +1018,24 @@ function renderMD(src) {
 | 
				
			|||||||
    countsEl.textContent = ct;
 | 
					    countsEl.textContent = ct;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const q = (qEl.value || '').toLowerCase();
 | 
					    const q = (qEl.value || '').toLowerCase();
 | 
				
			||||||
    const hash = (location.hash || '#user').slice(1);
 | 
					    const { filter, cmd } = getFilterFromHash();
 | 
				
			||||||
    const src = hash === 'moderator' ? mods : (hash === 'all' ? all : users);
 | 
					    const src = filter === 'moderator' ? mods : (filter === 'all' ? all : users);
 | 
				
			||||||
    const rows = !q ? src : src.filter(r => (
 | 
					    const rows = !q ? src : src.filter(r => (
 | 
				
			||||||
      [shownName(r), helpSansMod(r), r.brief, r.usage, r.usage_prefix, r.usage_slash, r.cog, r.module, r.details_md || ""]
 | 
					      [shownName(r), helpSansMod(r), r.brief, r.usage, r.usage_prefix, r.usage_slash, r.cog, r.module, r.details_md || ""]
 | 
				
			||||||
        .join(' ').toLowerCase().includes(q)
 | 
					        .join(' ').toLowerCase().includes(q)
 | 
				
			||||||
    ));
 | 
					    ));
 | 
				
			||||||
    render(listEl, rows);
 | 
					    render(listEl, rows);
 | 
				
			||||||
    if (rows.length) openDetails(rows[0]);  // auto-select first
 | 
					
 | 
				
			||||||
 | 
					    // Auto-select first or anchor target
 | 
				
			||||||
 | 
					    let sel = rows[0];
 | 
				
			||||||
 | 
					    if (selectAnchorIfPresent && cmd) {
 | 
				
			||||||
 | 
					      const hit = rows.find(r => rowAnchor(r) === cmd);
 | 
				
			||||||
 | 
					      if (hit) sel = hit;
 | 
				
			||||||
 | 
					      // Also scroll the specific card into view smoothly
 | 
				
			||||||
 | 
					      const cardEl = document.getElementById('card-' + cmd);
 | 
				
			||||||
 | 
					      if (cardEl) cardEl.scrollIntoView({behavior:'smooth', block:'start'});
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    if (sel) openDetails(sel);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async function boot() {
 | 
					  async function boot() {
 | 
				
			||||||
@ -921,9 +1046,9 @@ function renderMD(src) {
 | 
				
			|||||||
        if (!res.ok) throw new Error('HTTP ' + res.status);
 | 
					        if (!res.ok) throw new Error('HTTP ' + res.status);
 | 
				
			||||||
        data = await res.json();
 | 
					        data = await res.json();
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      window.addEventListener('hashchange', applyFilter);
 | 
					      window.addEventListener('hashchange', () => applyFilter(false));
 | 
				
			||||||
      qEl.addEventListener('input', applyFilter);
 | 
					      qEl.addEventListener('input', () => applyFilter(false));
 | 
				
			||||||
      applyFilter();
 | 
					      applyFilter(true);
 | 
				
			||||||
    } catch (e) {
 | 
					    } catch (e) {
 | 
				
			||||||
      document.getElementById('alerts').textContent = 'Failed to load.';
 | 
					      document.getElementById('alerts').textContent = 'Failed to load.';
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
@ -988,33 +1113,17 @@ class _DocsHandler(BaseHTTPRequestHandler):
 | 
				
			|||||||
                return
 | 
					                return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            path = urlparse(self.path).path
 | 
					            path = urlparse(self.path).path
 | 
				
			||||||
            if path == "/":
 | 
					 | 
				
			||||||
                self._set()
 | 
					 | 
				
			||||||
                html = _HTML.replace("__TITLE__", self.title)
 | 
					 | 
				
			||||||
                try:
 | 
					 | 
				
			||||||
                    schema = build_command_schema(self.bot)
 | 
					 | 
				
			||||||
                    inline = json.dumps(_to_primitive(schema), ensure_ascii=False, separators=(",", ":"))
 | 
					 | 
				
			||||||
                    html = html.replace("</head>", f"<script>window.__DATA__={inline};</script></head>")
 | 
					 | 
				
			||||||
                except Exception:
 | 
					 | 
				
			||||||
                    traceback.print_exc()
 | 
					 | 
				
			||||||
                self.wfile.write(html.encode("utf-8"))
 | 
					 | 
				
			||||||
                return
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
            path = urlparse(self.path).path
 | 
					            # Serve static assets from /assets/docs/*
 | 
				
			||||||
 | 
					 | 
				
			||||||
            # Static assets: /assets/docs/...
 | 
					 | 
				
			||||||
            if path.startswith("/assets/docs/"):
 | 
					            if path.startswith("/assets/docs/"):
 | 
				
			||||||
                try:
 | 
					                try:
 | 
				
			||||||
                    root = _static_root().resolve()
 | 
					                    root = _static_root().resolve()
 | 
				
			||||||
                    rel = path[len("/assets/docs/"):]
 | 
					                    rel = path[len("/assets/docs/"):]
 | 
				
			||||||
                    fs_path = (root / rel).resolve()
 | 
					                    fs_path = (root / rel).resolve()
 | 
				
			||||||
                    # Prevent path traversal
 | 
					 | 
				
			||||||
                    try:
 | 
					                    try:
 | 
				
			||||||
                        # Python 3.10+: Path.is_relative_to
 | 
					 | 
				
			||||||
                        if not fs_path.is_relative_to(root):
 | 
					                        if not fs_path.is_relative_to(root):
 | 
				
			||||||
                            raise ValueError("outside root")
 | 
					                            raise ValueError("outside root")
 | 
				
			||||||
                    except AttributeError:
 | 
					                    except AttributeError:
 | 
				
			||||||
                        # Fallback for very old Python (not needed on 3.10+)
 | 
					 | 
				
			||||||
                        if str(root) not in str(fs_path):
 | 
					                        if str(root) not in str(fs_path):
 | 
				
			||||||
                            raise ValueError("outside root")
 | 
					                            raise ValueError("outside root")
 | 
				
			||||||
                    if fs_path.is_file():
 | 
					                    if fs_path.is_file():
 | 
				
			||||||
@ -1035,6 +1144,18 @@ class _DocsHandler(BaseHTTPRequestHandler):
 | 
				
			|||||||
                    self.wfile.write(b"internal error")
 | 
					                    self.wfile.write(b"internal error")
 | 
				
			||||||
                    return
 | 
					                    return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            if path == "/":
 | 
				
			||||||
 | 
					                self._set()
 | 
				
			||||||
 | 
					                html = _HTML.replace("__TITLE__", self.title)
 | 
				
			||||||
 | 
					                try:
 | 
				
			||||||
 | 
					                    schema = build_command_schema(self.bot)
 | 
				
			||||||
 | 
					                    inline = json.dumps(_to_primitive(schema), ensure_ascii=False, separators=(",", ":"))
 | 
				
			||||||
 | 
					                    html = html.replace("</head>", f"<script>window.__DATA__={inline};</script></head>")
 | 
				
			||||||
 | 
					                except Exception:
 | 
				
			||||||
 | 
					                    traceback.print_exc()
 | 
				
			||||||
 | 
					                self.wfile.write(html.encode("utf-8"))
 | 
				
			||||||
 | 
					                return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if path == "/api/status":
 | 
					            if path == "/api/status":
 | 
				
			||||||
                payload = _status_payload(self.bot)
 | 
					                payload = _status_payload(self.bot)
 | 
				
			||||||
                self._set(200, "application/json; charset=utf-8")
 | 
					                self._set(200, "application/json; charset=utf-8")
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user