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
4 changes: 4 additions & 0 deletions apps/presentation/site/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ the dashboard Playwright dependency). The Pages workflow also runs it on
the complete artifact with `--with-docs`. It checks static content, route-specific
metadata, sitemap targets and public asset paths. Preview with `npm run preview`.

Run `npm run smoke:seo:bases` to build and check both the default root preview
and `/loopx/` publication, including rejection of a guide link under the wrong
base or a missing guide file.

## Google Analytics 4

Set the GitHub repository variable `LOOPX_GA_MEASUREMENT_ID` to the website's
Expand Down
1 change: 1 addition & 0 deletions apps/presentation/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"scripts": {
"build": "tsc --noEmit && node scripts/build.mjs",
"smoke:seo": "node scripts/seo-smoke.mjs",
"smoke:seo:bases": "node --test scripts/seo-smoke.test.mjs",
"dev": "vite --host 127.0.0.1",
"preview": "vite preview --host 127.0.0.1"
},
Expand Down
347 changes: 347 additions & 0 deletions apps/presentation/site/public/skill.md

Large diffs are not rendered by default.

23 changes: 22 additions & 1 deletion apps/presentation/site/scripts/seo-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ import { fileURLToPath } from "node:url";

const directory = resolve(process.argv[2] ?? fileURLToPath(new URL("../dist", import.meta.url)));
const origin = "https://loopx-project.github.io/loopx/";
const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url));
// Agent discovery must survive both Vite and the assembled Pages export as
// Markdown, with links to the existing workflow owners rather than copies.
const skill = await readFile(resolve(directory, "skill.md"), "utf8");
assert.equal(skill, await readFile(new URL("../public/skill.md", import.meta.url), "utf8"), "publish the skill unchanged");
assert.match(skill, /^---\nname: [a-z0-9-]+\ndescription: .+\n---\n/);
for (const [, href] of skill.matchAll(/\]\((https:\/\/[^)]+)\)/g)) {
const url = new URL(href);
if (url.hostname === "github.com" && url.pathname.startsWith("/loopx-project/loopx/")) {
const sourcePath = url.pathname.replace(/^\/loopx-project\/loopx\/(?:blob|tree)\/main\//, "");
if (sourcePath !== url.pathname) await access(resolve(repoRoot, sourcePath));
}
if (process.argv.includes("--with-docs") && href.startsWith(origin)) {
const path = url.pathname.slice(new URL(origin).pathname.length);
await access(resolve(directory, path, path.endsWith("/") || !path ? "index.html" : ""));
}
}
const titles = new Set();
for (const [path, subject] of [["", "long-running"], ["benchmarks/swe-marathon/", "SWE-Marathon"], ["benchmarks/lhtb/", "LHTB"]]) {
const html = await readFile(resolve(directory, path, "index.html"), "utf8");
Expand All @@ -18,6 +35,10 @@ for (const [path, subject] of [["", "long-running"], ["benchmarks/swe-marathon/"
assert.equal((html.match(/rel="canonical"/g) ?? []).length, 1);
assert(html.includes(`rel="canonical" href="${origin}${path}"`), `${path}: canonical matches route`);
if (!path) {
const footer = html.match(/<footer class="site-footer">(.*?)<\/footer>/s)?.[1] ?? "";
const basePath = html.match(/<script\b[^>]*\bsrc="([^"]*)site-assets\/[^"]+"/)?.[1];
assert(basePath !== undefined, "homepage contains the built application script");
assert(footer.includes(`href="${basePath}skill.md"`), "homepage footer discovers the skill under the built base");
const structured = JSON.parse(html.match(/<script type="application\/ld\+json">(.*?)<\/script>/s)?.[1] ?? "null");
assert.equal(structured?.["@type"], "SoftwareSourceCode");
assert.equal(structured?.codeRepository, "https://github.com/loopx-project/loopx");
Expand Down Expand Up @@ -47,4 +68,4 @@ if (process.argv.includes("--with-docs")) {
const index = await readFile(resolve(directory, "sitemap.xml"), "utf8");
for (const [, url] of index.matchAll(/<loc>(.*?)<\/loc>/g)) await access(resolve(directory, url.slice(origin.length)));
}
console.log(`SEO publication: three prerendered routes, ${locations.length} canonical sitemap targets, assets and metadata passed`);
console.log(`SEO publication: agent skill and owner links, three prerendered routes, ${locations.length} canonical sitemap targets, assets and metadata passed`);
47 changes: 47 additions & 0 deletions apps/presentation/site/scripts/seo-smoke.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
import { fileURLToPath } from "node:url";

const root = fileURLToPath(new URL("../", import.meta.url));
const env = { ...process.env };
delete env.LOOPX_SITE_BASE;
delete env.LOOPX_SITE_RESOLVE_DESKTOP_RELEASE;
const run = (script, args) => spawnSync(process.execPath, [
fileURLToPath(new URL(script, import.meta.url)), ...args,
], { cwd: root, env, encoding: "utf8", timeout: 120_000 });
const diagnostics = (result) => `${result.error ?? ""}\n${result.stdout}\n${result.stderr}`;

for (const { name, args, href, wrongHref } of [
{ name: "default root preview", args: [], href: "/skill.md", wrongHref: "/loopx/skill.md" },
{ name: "project publication", args: ["--base", "/loopx/"], href: "/loopx/skill.md", wrongHref: "/skill.md" },
]) {
test(`SEO validates the ${name} and rejects broken guide discovery`, async () => {
const directory = await mkdtemp(join(tmpdir(), "loopx-site-seo-"));
try {
const build = run("build.mjs", [...args, "--outDir", directory]);
assert.equal(build.status, 0, diagnostics(build));
const index = join(directory, "index.html");
const html = await readFile(index, "utf8");
assert(html.includes(`href="${href}"`), "the real build uses the documented guide URL");
const valid = run("seo-smoke.mjs", [directory]);
assert.equal(valid.status, 0, diagnostics(valid));

await writeFile(index, html.replace(`href="${href}"`, `href="${wrongHref}"`));
const wrongBase = run("seo-smoke.mjs", [directory]);
assert.notEqual(wrongBase.status, 0, "a guide link under the other base must fail");
assert.match(wrongBase.stderr, /homepage footer discovers the skill/);

await writeFile(index, html);
await rm(join(directory, "skill.md"));
const missing = run("seo-smoke.mjs", [directory]);
assert.notEqual(missing.status, 0, "a missing published guide must fail");
assert.match(missing.stderr, /ENOENT.*skill\.md/);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
}
1 change: 1 addition & 0 deletions apps/presentation/site/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1153,6 +1153,7 @@ export function App() {
<a href={`${basePath}blog/${language === "zh" ? "zh/" : ""}`}>Blog</a>
<a href={`${basePath}docs/guides/personal-workspace-user-guide/`}>Personal Workspace</a>
<a href={`${basePath}docs/`}>Docs</a>
<a href={`${basePath}skill.md`}>{language === "zh" ? "Agent 使用指南" : "Agent skill guide"}</a>
</nav>
</footer>

Expand Down
1 change: 1 addition & 0 deletions apps/presentation/site/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1561,6 +1561,7 @@ body[data-language="zh"] .hero h1 {

.site-footer nav {
display: flex;
flex-wrap: wrap;
justify-self: end;
gap: 22px;
}
Expand Down
12 changes: 11 additions & 1 deletion examples/dashboard-frontstage-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const staticServer = createServer(async (req, res) => {
if (!file.startsWith(resolve(exportSite) + "/")) { res.writeHead(403).end(); return; }
try {
const body = await readFile(file);
res.setHeader("Content-Type", ({ ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".json": "application/json", ".png": "image/png", ".svg": "image/svg+xml" })[extname(file)] ?? "application/octet-stream");
res.setHeader("Content-Type", ({ ".html": "text/html", ".md": "text/plain; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".json": "application/json", ".png": "image/png", ".svg": "image/svg+xml" })[extname(file)] ?? "application/octet-stream");
res.end(body);
} catch { res.writeHead(404).end(); }
});
Expand Down Expand Up @@ -264,6 +264,16 @@ try {
assert.ok(await page.locator("#explore .resource-card").first().evaluate((a) => a === document.activeElement));
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), "horizontal overflow");
await page.screenshot({ path: resolve(output, `explore-${lang}-${width}.png`) });
const skillLink = page.locator('.site-footer a[href="/loopx/skill.md"]');
assert.equal(await skillLink.innerText(), lang === "zh" ? "Agent 使用指南" : "Agent skill guide");
await skillLink.focus();
assert.ok(await skillLink.evaluate((a) => a === document.activeElement), "skill link accepts keyboard focus");
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), "footer fits narrow screens");
await page.screenshot({ path: resolve(output, `skill-footer-${lang}-${width}.png`) });
await Promise.all([page.waitForURL(`${publicOrigin}/loopx/skill.md`), skillLink.press("Enter")]);
assert.equal(await page.locator("body").innerText(), await readFile(resolve(exportSite, "skill.md"), "utf8"), "footer opens raw Markdown");
const missingSkill = await page.request.get(`${publicOrigin}/loopx/missing-skill.md`);
assert.equal(missingSkill.status(), 404, "missing Markdown must not masquerade as the homepage");
// Follow the actual research and case links; the guide is built by MkDocs later.
for (let i = process.env.LOOPX_PUBLIC_SITE_DIR ? 0 : 1; i < expected.length; i++) {
await page.goto(`${publicOrigin}/loopx/${expected[i]}`);
Expand Down
Loading