Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 39 additions & 40 deletions assets/js/ai-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@
var CLAUDE_CODE_REPOSITORY = "nm-Team/Support";
var CLAUDE_CODE_BRANCH = "main";

function mdUrl(raw) {
var path = raw ? "/" + raw : "/index.md";
if (/\/$/.test(path)) {
path += "index.md";
}
return new URL(path, location.href).href;
// The page's own Markdown copy sits at its docs-relative source path.
// Anything that resolves away from this origin (a tampered data attribute,
// say) is refused instead of being opened or fetched.
function mdUrl(sourcePath) {
var url = new URL("/" + sourcePath.replace(/^\/+/, ""), location.href);
return url.origin === location.origin ? url : null;
}

function providerPrompt(url) {
Expand Down Expand Up @@ -106,6 +106,9 @@
}

function loadMarkdown(md) {
if (!md) {
return Promise.resolve(null);
}
return fetch(md)
.then(function (response) {
var contentType = response.headers.get("Content-Type") || "";
Expand Down Expand Up @@ -146,54 +149,50 @@
});
}

function wireMarkdown(link, md) {
var availability;

function checkAvailability() {
if (!availability) {
availability = fetch(md, { method: "HEAD" })
.then(function (response) {
var contentType = response.headers.get("Content-Type") || "";
return response.ok && contentType.indexOf("html") === -1;
})
.catch(function () {
return false;
});
}
return availability;
// Whether the Markdown copy is really served next to the rendered page.
function markdownAvailability(md) {
if (!md) {
return Promise.resolve(false);
}
return fetch(md, { method: "HEAD" })
.then(function (response) {
var contentType = response.headers.get("Content-Type") || "";
return response.ok && contentType.indexOf("html") === -1;
})
.catch(function () {
return false;
});
}

function wireMarkdown(link, md, isAvailable) {
link.href = md;
link.addEventListener("click", function (event) {
event.preventDefault();
var target = window.open("about:blank", "_blank");
if (target) {
target.opener = null;
if (isAvailable()) {
return;
}
checkAvailability().then(function (available) {
if (available && target) {
target.location.replace(md);
return;
}
if (target) {
target.close();
}
showMarkdownUnavailable();
});
event.preventDefault();
showMarkdownUnavailable();
});
}

function wireMenu(box) {
var trigger = box.querySelector(".ai-tools__trigger");
var copy = box.querySelector(".ai-tools__copy");
var menu = box.querySelector(".ai-tools__menu");
var raw = box.getAttribute("data-md-url") || "";
var sourcePath = box.getAttribute("data-md-path") || "";
var markdown = menu.querySelector('[data-ai="markdown"]');
var md = mdUrl(raw);
var prompt = providerPrompt(md);
var md = mdUrl(sourcePath);
var mdHref = md ? md.href : "";
var markdownAvailable = false;
var prompt = providerPrompt(mdHref);

wireCopy(copy, md);
wireMarkdown(markdown, md);
markdownAvailability(mdHref).then(function (available) {
markdownAvailable = available;
});
wireCopy(copy, mdHref);
wireMarkdown(markdown, mdHref, function () {
return markdownAvailable;
});
[
"perplexity",
"grok",
Expand Down
2 changes: 1 addition & 1 deletion overrides/partials/actions.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
{% set source_url = "https://github.com/nm-Team/Support/blob/main/docs/" ~ page.file.src_uri %}
<div
class="ai-tools"
data-md-url="{{ page.file.src_uri }}"
data-md-path="{{ page.file.src_uri }}"
data-page-title="{{ page.title | e }}"
>
<button class="ai-tools__copy" type="button">
Expand Down
34 changes: 22 additions & 12 deletions src/nmteam_support/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,18 +167,28 @@ def _write_markdown_copies(
entries: dict[str, DocEntry],
config: MkDocsConfig,
) -> None:
site_dir = Path(config.site_dir)
"""Write one Markdown copy per rendered page, at its docs-relative path.

Every page the site renders gets a copy so the article actions can point at
``page.file.src_uri`` without any URL-to-file guessing.
"""
copies = {
path: render_index_page(directory)
for path, directory in directories.items()
if path in catalog.pages or is_renderable(directory)
}
for path, source in catalog.pages.items():
directory = directories.get(path)
if directory is not None:
content = render_index_page(directory)
else:
entry = entries[path]
content = (
source.text
if should_hide_contributing_note(entry)
else render_doc_file(source.text, path)
)
if path in directories:
continue
entry = entries[path]
copies[path] = (
source.text
if should_hide_contributing_note(entry)
else render_doc_file(source.text, path)
)

site_dir = Path(config.site_dir)
for path, content in copies.items():
target = site_dir / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
target.write_text(content, encoding="utf-8", newline="\n")
78 changes: 78 additions & 0 deletions tests/test_lifecycle.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,29 @@
"""Real production lifecycle integration tests."""

import json
import threading
import urllib.request
from html.parser import HTMLParser
from pathlib import Path

from nmteam_support.cli import build_site
from nmteam_support.serve import create_server

REPO_ROOT = Path(__file__).resolve().parents[1]


class _ArticleActions(HTMLParser):
"""Collect the Markdown paths advertised by a rendered page."""

def __init__(self) -> None:
super().__init__()
self.md_paths: list[str] = []

def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
attributes = dict(attrs)
classes = (attributes.get("class") or "").split()
if tag == "div" and "ai-tools" in classes and attributes.get("data-md-path"):
self.md_paths.append(str(attributes["data-md-path"]))


def test_build_reads_docs_directly_and_writes_only_final_outputs(tmp_path, docs_dir):
Expand Down Expand Up @@ -41,3 +62,60 @@ def test_build_reads_docs_directly_and_writes_only_final_outputs(tmp_path, docs_
assert "帮助我们改进此文档" in (site / "nmbot-telegram" / "mcp.md").read_text(encoding="utf-8")
assert not (tmp_path / "cache").exists()
assert not (tmp_path / "generated").exists()


def test_every_rendered_page_advertises_a_served_markdown_copy(tmp_path, docs_dir):
config = tmp_path / "mkdocs.yml"
config.write_text(
"site_name: Test\n"
"site_url: https://docs.example.test\n"
"strict: true\n"
"docs_dir: docs\n"
"site_dir: site\n"
"theme:\n"
" name: material\n"
f" custom_dir: {REPO_ROOT / 'overrides'}\n"
"plugins:\n"
" - nmteam-support\n",
encoding="utf-8",
)

build_site(config)

site = tmp_path / "site"
advertised: dict[str, list[str]] = {}
for page in sorted(site.rglob("*.html")):
if page.name == "404.html":
continue
parser = _ArticleActions()
parser.feed(page.read_text(encoding="utf-8"))
advertised[page.relative_to(site).as_posix()] = parser.md_paths

# The home page hides the actions on purpose; every other page advertises one copy.
assert advertised.pop("index.html") == []
assert set(advertised) == {
"about/index.html",
"contact-us/forum/index.html",
"contact-us/index.html",
"nmbot-telegram/index.html",
"nmbot-telegram/mcp/index.html",
}
assert advertised["nmbot-telegram/index.html"] == ["nmbot-telegram/index.md"]

server = create_server(site, port=0)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
host, port = server.server_address[0], server.server_address[1]
for page, paths in advertised.items():
assert len(paths) == 1, page
with urllib.request.urlopen(f"http://{host}:{port}/{paths[0]}", timeout=5) as response:
assert response.status == 200, page
assert response.headers.get_content_type() == "text/plain", page
served = response.read().decode("utf-8")
assert served == (site / paths[0]).read_text(encoding="utf-8"), page
# Build output stays byte-identical across platforms, so copies use LF.
assert b"\r" not in (site / paths[0]).read_bytes(), page
finally:
server.shutdown()
server.server_close()