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
18 changes: 18 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE/marketplace_loop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
## New marketplace loop

**Loop:** `marketplace/loops/<name>.loop`
**What it does (one line):**

### Checklist — [the bar](../../marketplace/CONTRIBUTING.md)

- [ ] Parses — `node market/validate-marketplace.mjs` passes locally
- [ ] At least one **machine-checkable** `done when` (test / command / skill eval — not prose, not human-only)
- [ ] Reflect back-edge → has an `after N tries` ceiling
- [ ] Attribution header (`# original — …` or `# concept via … — <url>`)
- [ ] Project-specific commands marked `# TODO`
- [ ] No secrets, no credentials, no internal URLs

### Attribution

- Author / handle:
- If adapted: source concept + link:
32 changes: 32 additions & 0 deletions .github/workflows/marketplace.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Marketplace

# Validates every marketplace submission with the real parser: the bar is
# parse + a machine-checkable done-when + a thrash guard + attribution
# (market/validate-marketplace.mjs). A PR that fails here never reaches review.
on:
pull_request:
paths:
- "marketplace/**"
- "market/**"
push:
branches: [master]
paths:
- "marketplace/**"
- "market/**"

jobs:
validate:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build -w @loop-lang/parser # the validator imports the real parser
- run: node market/validate-marketplace.mjs
- run: node market/build-catalog.mjs # the catalog must still build from the tree
- run: node market/check-site.mjs
1 change: 1 addition & 0 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@
<li class="grp">Go deeper</li>
<li><a href="https://github.com/tickets-forge-dev/loop-lang/blob/master/docs/MANUAL.md">📖 Full manual →</a></li>
<li><a href="keywords/index.html">📖 Keyword reference →</a></li>
<li><a href="market/index.html">🛒 Loop market →</a></li>
<li><a href="playground.html">⚡ Playground →</a></li>
<li><a href="workshop.html">🛠️ Workshop →</a></li>
<li><a href="game.html">🎮 LoopFlow Lab →</a></li>
Expand Down
412 changes: 412 additions & 0 deletions docs/market/catalog.json

Large diffs are not rendered by default.

199 changes: 199 additions & 0 deletions docs/market/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/gyro-icon.svg" />
<title>Loop market — verified .loop workflows</title>
<meta name="description" content="A library of verified LoopFlow workflows: every loop parses, carries a machine-checkable done-when, and is reviewed as a pull request before it ships." />
<meta property="og:type" content="website" />
<meta property="og:title" content="Loop market — verified .loop workflows" />
<meta property="og:description" content="Copy-and-adapt loops with real verification: flake guards, judge panels, finds-nothing scans. Every entry CI-validated." />
<meta property="og:url" content="https://loopflow.live/market/" />
<meta property="og:image" content="https://loopflow.live/assets/og-card.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:image" content="https://loopflow.live/assets/og-card.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Ubuntu:wght@400;500;700&family=Ubuntu+Mono:wght@400;700&display=swap" rel="stylesheet" />
<style>
/* brand: tutorial tokens verbatim (design.md → brand) */
:root{
--bg:#0b0d12; --card:#0e1118; --ink:#eef2f7; --muted:#94a1b2; --line:rgba(255,255,255,.1);
--teal:#34e0c4; --orange:#ffb45a; --pink:#ff86c2; --blue:#84b6ff; --green:#9be58f;
--mono:"Ubuntu Mono",ui-monospace,monospace; --ui:"Ubuntu",system-ui,sans-serif;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--ui);line-height:1.55;-webkit-font-smoothing:antialiased}
a{color:var(--blue)}
button{font-family:var(--ui)}
:focus-visible{outline:2px solid var(--teal);outline-offset:2px;border-radius:4px}

header{display:flex;align-items:center;gap:12px;padding:16px 26px;border-bottom:1px solid var(--line);flex-wrap:wrap}
header svg{flex:none}
header h1{font-size:17px;margin:0;font-weight:700}
header .tag{font-size:12.5px;color:var(--muted)}
header .grow{flex:1}
.submit-btn{display:inline-block;background:var(--teal);color:#0b0d12;font-weight:700;font-size:13.5px;
padding:8px 14px;border-radius:9px;text-decoration:none}
.backlink{font-size:13px;margin-right:6px}

.bar{max-width:1240px;margin:26px auto 6px;padding:0 26px}
.bar .hint{color:var(--muted);font-size:13.5px;margin:0 0 14px}
.bar .hint b{color:var(--teal)}
.controls{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
#q{flex:1;min-width:220px;background:var(--card);border:1px solid var(--line);border-radius:10px;
color:var(--ink);font-family:var(--ui);font-size:14px;padding:10px 14px}
.chips{display:flex;gap:8px;flex-wrap:wrap}
.chip{background:var(--card);border:1px solid var(--line);color:var(--muted);font-size:12.5px;
padding:7px 12px;border-radius:999px;cursor:pointer}
.chip[aria-pressed="true"]{color:#0b0d12;background:var(--teal);border-color:var(--teal);font-weight:700}
#count{color:var(--muted);font-size:12.5px;margin:10px 2px 0}

/* layout: auto-fill grid, 360px min (design.md → layout) */
.grid{max-width:1240px;margin:16px auto 60px;padding:0 26px;display:grid;
grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:16px}
@media (max-width:720px){.grid{grid-template-columns:1fr}}

/* card anatomy (design.md → card) */
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:18px;display:flex;flex-direction:column;gap:9px}
.card .top{display:flex;align-items:center;gap:8px}
.badge{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:#0b0d12;
padding:3px 9px;border-radius:999px}
.b-engineering{background:var(--teal)} .b-evaluation{background:var(--orange)}
.b-design{background:var(--pink)} .b-operations{background:var(--blue)} .b-content{background:var(--green)}
.glyph{color:var(--muted);font-family:var(--mono);font-size:15px;margin-left:auto}
.card h2{font-size:18px;margin:0;font-weight:700;line-height:1.3}
.goal{color:var(--muted);font-size:13.5px;margin:0;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.dw{font-family:var(--mono);font-size:13px;color:var(--teal);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:0}
.dw::before{content:"done when ";color:var(--muted)}
.foot{display:flex;align-items:center;gap:8px;margin-top:auto;padding-top:6px}
.author{font-size:12px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.author a{color:var(--muted)}
.btn{background:transparent;border:1px solid var(--line);color:var(--ink);font-size:12.5px;
padding:6px 12px;border-radius:8px;cursor:pointer}
.btn:hover{border-color:var(--teal)}
.btn.copied{color:var(--teal);border-color:var(--teal)}

/* detail: inline expand (design.md → detail view) */
.src{display:none;margin-top:4px}
.card.open .src{display:block}
.src pre{background:#0b0e14;border:1px solid var(--line);border-radius:10px;padding:14px;overflow:auto;
font-family:var(--mono);font-size:12.5px;line-height:1.6;color:#c9d4e0;margin:0;max-height:420px}
.src .meta{font-size:12px;color:var(--muted);margin:8px 2px 0}

/* states (design.md → states) */
.empty{max-width:1240px;margin:40px auto;padding:0 26px;color:var(--muted);display:none}
.empty button{margin-left:8px}

.cta{max-width:1240px;margin:0 auto 70px;padding:26px;border:1px solid rgba(52,224,196,.35);border-radius:14px;
background:linear-gradient(180deg,rgba(52,224,196,.07),transparent)}
.cta h2{margin:0 0 6px;font-size:17px}
.cta p{margin:0 0 14px;color:var(--muted);font-size:14px}
</style>
</head>
<body>
<header>
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="8.5" stroke="#34e0c4" stroke-width="3.6" stroke-linecap="round" stroke-dasharray="42.2 11.2"/></svg>
<h1>Loop market</h1>
<span class="tag">verified .loop workflows — every entry parses, checks itself, and shipped through a reviewed PR</span>
<span class="grow"></span>
<a class="backlink" href="../index.html">← the tutorial</a>
<a class="submit-btn" href="https://github.com/tickets-forge-dev/loop-lang/new/master/marketplace/loops" rel="noopener">+ Submit your loop</a>
</header>

<div class="bar">
<p class="hint">The bar for every loop here: <b>parses</b> + <b>a machine-checkable done when</b> + <b>attribution</b> — CI enforces it on every submission.</p>
<div class="controls">
<input id="q" type="search" placeholder="Search loops — title, goal, author…" aria-label="Search loops" />
<div class="chips" id="chips" role="group" aria-label="Filter by category"></div>
</div>
<div id="count" aria-live="polite"></div>
</div>

<div class="grid" id="grid"></div>
<div class="empty" id="empty">No loops match — <button class="btn" id="reset">clear the filters</button></div>

<div class="cta">
<h2>Built a loop worth sharing?</h2>
<p>Uploads are pull requests: add a <code>.loop</code> under <code>marketplace/loops/</code>, CI runs the same validator you see above, a maintainer reviews, merge = published. Attribution required, prose-only verification rejected.</p>
<a class="submit-btn" href="https://github.com/tickets-forge-dev/loop-lang/blob/master/marketplace/CONTRIBUTING.md" rel="noopener">Read the bar → CONTRIBUTING.md</a>
</div>

<script>
const CATS = ["all","engineering","evaluation","design","operations","content"];
let DATA = [], active = "all", query = "";

const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;" }[c]));

function render() {
const grid = document.getElementById("grid");
const hits = DATA.filter((e) =>
(active === "all" || e.category === active) &&
(!query || (e.title + " " + e.goal + " " + e.author + " " + e.keywords).toLowerCase().includes(query)));
document.getElementById("count").textContent = `${hits.length} loop${hits.length === 1 ? "" : "s"}`;
document.getElementById("empty").style.display = hits.length ? "none" : "block";
grid.innerHTML = hits.map((e, i) => `
<article class="card" data-i="${DATA.indexOf(e)}">
<div class="top">
<span class="badge b-${e.category}">${e.category}</span>
<span class="glyph" title="${e.shape}">${e.glyph} ${e.shape}</span>
</div>
<h2>${esc(e.title)}</h2>
<p class="goal">${esc(e.goal)}</p>
<p class="dw">${esc(e.doneWhen)}</p>
<div class="foot">
<span class="author">concept via ${e.source ? `<a href="${esc(e.source)}" rel="noopener">${esc(e.author)}</a>` : esc(e.author)}</span>
<button class="btn copy" aria-label="Copy ${esc(e.title)} source">Copy</button>
<button class="btn view" aria-expanded="false" aria-label="View ${esc(e.title)} source">View</button>
</div>
<div class="src"><pre>${esc(e.text)}</pre>
<p class="meta">${e.concept ? esc(e.concept) : ""}</p>
</div>
</article>`).join("");
}

document.getElementById("grid").addEventListener("click", async (ev) => {
const btn = ev.target.closest("button"); if (!btn) return;
const card = btn.closest(".card");
const entry = DATA[+card.dataset.i];
if (btn.classList.contains("copy")) {
await navigator.clipboard.writeText(entry.text);
btn.textContent = "✓ copied"; btn.classList.add("copied");
setTimeout(() => { btn.textContent = "Copy"; btn.classList.remove("copied"); }, 1200);
} else if (btn.classList.contains("view")) {
const open = card.classList.toggle("open");
btn.setAttribute("aria-expanded", String(open));
btn.textContent = open ? "Hide" : "View";
}
});

document.getElementById("q").addEventListener("input", (e) => { query = e.target.value.trim().toLowerCase(); render(); });
document.getElementById("reset").addEventListener("click", () => {
query = ""; active = "all"; document.getElementById("q").value = "";
document.querySelectorAll(".chip").forEach((c) => c.setAttribute("aria-pressed", String(c.dataset.cat === "all")));
render();
});

fetch("catalog.json").then((r) => r.json()).then((cat) => {
DATA = cat.entries;
// sort: category → title (design.md → discovery)
DATA.sort((a, b) => a.category.localeCompare(b.category) || a.title.localeCompare(b.title));
const chips = document.getElementById("chips");
chips.innerHTML = CATS.map((c) => {
const n = c === "all" ? DATA.length : DATA.filter((e) => e.category === c).length;
return `<button class="chip" data-cat="${c}" aria-pressed="${c === "all"}">${c} (${n})</button>`;
}).join("");
chips.addEventListener("click", (ev) => {
const chip = ev.target.closest(".chip"); if (!chip) return;
active = chip.dataset.cat;
document.querySelectorAll(".chip").forEach((c) => c.setAttribute("aria-pressed", String(c === chip)));
render();
});
render();
}).catch(() => {
document.getElementById("count").innerHTML = 'Could not load the catalog — <a href="javascript:location.reload()">retry</a>';
});
</script>
</body>
</html>
66 changes: 66 additions & 0 deletions market/build-catalog.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Build docs/market/catalog.json from marketplace/loops/*.loop.
// The validator gates entry: a loop that fails the bar never reaches the catalog —
// which is why the market UI needs no "broken loop" state (design.md → states).
// Metadata (category/author/source/concept) joins from market/curated.yaml by slug;
// loops not in curated.yaml (future submissions) fall back to their attribution header.
import { readdirSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { parse } from "@loop-lang/parser";

// 1. the bar
execFileSync("node", [new URL("./validate-marketplace.mjs", import.meta.url).pathname], { stdio: "inherit" });

// 2. curated metadata by slug
const curated = {};
{
const text = readFileSync(new URL("./curated.yaml", import.meta.url), "utf8");
let cur = null;
for (const line of text.split("\n")) {
const m = line.match(/^ - slug: (.+)$/);
if (m) { cur = { slug: m[1].trim() }; curated[cur.slug] = cur; continue; }
const kv = line.match(/^ (\w+): "?(.*?)"?$/);
if (kv && cur) cur[kv[1]] = kv[2];
}
}

const GLYPH = { loop: "↻", pipeline: "▶", flow: "→" };
const dir = new URL("../marketplace/loops/", import.meta.url).pathname;
const entries = [];

for (const f of readdirSync(dir).filter((x) => x.endsWith(".loop")).sort()) {
const src = readFileSync(join(dir, f), "utf8");
const slug = f.replace(/\.loop$/, "");
const spec = parse(src);
const def = spec.definitions[0];
const loop = def.kind === "pipeline" ? def.stages[0].loop : def;

// first machine-checkable predicate, reconstructed as display text
const pred = (loop.doneWhen ?? []).find((p) => p.type !== "human");
let doneWhen = "";
if (pred?.type === "test") doneWhen = `the test "${pred.target}" passes${pred.runs > 1 ? ` ${pred.runs} times` : ""}`;
else if (pred?.type === "command") doneWhen = `"${pred.command}" ${pred.expect === "empty" ? "finds nothing" : "passes"}${pred.runs > 1 ? ` ${pred.runs} times` : ""}`;
else if (pred?.type === "skill") doneWhen = `the skill "${pred.skill}" ${pred.minScore ? `scores ${pred.minScore}+` : "approves"}${pred.judges > 1 ? ` by ${pred.judges} judges` : ""}`;

const meta = curated[slug] ?? {};
const header = src.match(/^# concept via (.+?) — (https:\/\/\S+)/m);
entries.push({
slug,
title: def.name ?? slug,
shape: def.kind,
glyph: GLYPH[def.kind] ?? "↻",
goal: loop.goal ?? "",
doneWhen,
category: meta.category ?? "engineering",
author: meta.author ?? header?.[1] ?? "community",
source: meta.source ?? header?.[2] ?? "",
concept: meta.concept ?? "",
keywords: [slug.replace(/-/g, " "), meta.category ?? "", def.kind].join(" "),
text: src,
});
}

const outDir = new URL("../docs/market/", import.meta.url).pathname;
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, "catalog.json"), JSON.stringify({ built: "loop-market", count: entries.length, entries }, null, 1));
console.log(`✓ catalog.json — ${entries.length} loops`);
30 changes: 30 additions & 0 deletions market/check-curated.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Validates market/curated.yaml — the curate stage's done-when.
// 20–30 entries; every entry carries slug/title/category/author/source/concept/verify_as;
// slugs unique; categories from the known set; source URLs well-formed.
import { readFileSync } from "node:fs";

const text = readFileSync(new URL("./curated.yaml", import.meta.url), "utf8");
const entries = [];
let cur = null;
for (const line of text.split("\n")) {
const m = line.match(/^ - slug: (.+)$/);
if (m) { cur = { slug: m[1].trim() }; entries.push(cur); continue; }
const kv = line.match(/^ (\w+): "?(.*?)"?$/);
if (kv && cur) cur[kv[1]] = kv[2];
}

const REQUIRED = ["slug", "title", "category", "author", "source", "concept", "verify_as"];
const CATS = new Set(["engineering", "evaluation", "operations", "content", "design"]);
const fail = (msg) => { console.error("✗ " + msg); process.exitCode = 1; };

if (entries.length < 20 || entries.length > 30) fail(`expected 20–30 entries, found ${entries.length}`);
const seen = new Set();
for (const e of entries) {
for (const k of REQUIRED) if (!e[k]) fail(`${e.slug ?? "?"}: missing ${k}`);
if (seen.has(e.slug)) fail(`duplicate slug ${e.slug}`);
seen.add(e.slug);
if (e.category && !CATS.has(e.category)) fail(`${e.slug}: unknown category ${e.category}`);
if (e.source && !/^https:\/\//.test(e.source)) fail(`${e.slug}: source is not an https URL`);
}
if (process.exitCode) process.exit(1);
console.log(`✓ curated.yaml valid — ${entries.length} entries, all attributed`);
15 changes: 15 additions & 0 deletions market/check-design.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// design.loop's done-when: market/design.md must carry a Decision + Rationale
// for every UI/UX axis the loop names.
import { readFileSync } from "node:fs";

const AXES = ["layout", "card", "discovery", "detail view", "copy action", "submit", "states", "brand", "a11y"];
const md = readFileSync(new URL("./design.md", import.meta.url), "utf8");

let failed = 0;
for (const axis of AXES) {
const section = md.split(new RegExp(`^## ${axis}$`, "m"))[1]?.split(/^## /m)[0] ?? "";
if (!/\*\*Decision:\*\*/.test(section)) { console.error(`✗ ${axis}: no Decision`); failed++; }
if (!/\*\*Rationale:\*\*/.test(section)) { console.error(`✗ ${axis}: no Rationale`); failed++; }
}
if (failed) process.exit(1);
console.log(`✓ design.md — a decision and rationale for all ${AXES.length} axes`);
Loading
Loading