From 69ebd5495500f5007009db9725ae5a4d0ae0e63f Mon Sep 17 00:00:00 2001 From: 207studio Date: Mon, 2 Nov 2026 15:10:11 +0900 Subject: [PATCH] generator: reproduce the live site byte for byte - Rust generator owns sitemap.xml, robots.txt, llms.txt and llms-full.txt; blog-gen.mjs no longer writes the first two. - Hand-written llms text lives in _products/templates with {{slots}}. - site-data.json gains optional lastmod, ko_name, ko_summary, llms_detail and meta_description; posts.json gains lastmod. - robots carries the AI-crawler rules; sitemap carries every live entry. Running the generator into a temp copy gives an empty diff against the site. Co-Authored-By: Claude Opus 5.5 --- _blog/posts.json | 1 + _products/blog-gen.mjs | 35 +----- _products/gen/src/main.rs | 199 +++++++++++++++++++++++++----- _products/site-data.json | 23 ++++ _products/templates/llms-full.txt | 17 +++ _products/templates/llms.txt | 55 +++++++++ 6 files changed, 263 insertions(+), 67 deletions(-) create mode 100644 _products/templates/llms-full.txt create mode 100644 _products/templates/llms.txt diff --git a/_blog/posts.json b/_blog/posts.json index 4cb5e3b..d9399f6 100644 --- a/_blog/posts.json +++ b/_blog/posts.json @@ -5,6 +5,7 @@ "meta_title": "Play a PPTX in the browser without PowerPoint | 207 Studio", "description": "Render a .pptx in the browser without a server or Office. What breaks the animations, three ways to do it, and how to test any viewer in five minutes.", "date": "2026-10-20", + "lastmod": "2026-10-24", "tags": [ "webdev", "javascript", diff --git a/_products/blog-gen.mjs b/_products/blog-gen.mjs index c919c80..8d3c8ef 100644 --- a/_products/blog-gen.mjs +++ b/_products/blog-gen.mjs @@ -1,7 +1,7 @@ // Blog generator for 207studio.dev // Usage (from the repo root): node _products/blog-gen.mjs // Reads: _products/site-data.json, _blog/posts.json, _blog/.md -// Writes: blog/index.html, blog//index.html, blog/blog.css, sitemap.xml, llms.txt +// Writes: blog/index.html, blog//index.html, blog/blog.css // Markdown subset: #, ##, ###, paragraphs, - lists, | tables |, ``` code, > quote, --- hr, // **bold**, `code`, [text](url). No raw HTML passthrough. @@ -354,38 +354,7 @@ ${footer(`${BASE}/ko/blog/`)} written.push(write('blog/index.html', listPage)); written.push(write('blog/blog.css', CSS)); -// sitemap: home + products + blog, same shape as the Rust generator -const locations = [`${BASE}/`, `${BASE}/products/`]; -for (const product of data.products) locations.push(`${BASE}/products/${product.slug}/`); -const sitemap = [``, '']; -for (const location of locations) { - sitemap.push(`${esc(location)}${esc(data.last_updated)}`); -} -sitemap.push(`${esc(`${BASE}/blog/`)}${esc(data.last_updated)}`); -for (const post of posts) { - sitemap.push(`${esc(`${BASE}/blog/${post.slug}/`)}${esc(post.date)}`); -} -sitemap.push('', ''); -written.push(write('sitemap.xml', sitemap.join('\n'))); - -// llms.txt: products plus a Writing section -const llms = [ - '# 207 Studio', - '', - '> Developer kits and classroom tools from 207 Studio (207studio.dev), South Korea. One-time purchase, full source.', - '', - '## Products', - '', -]; -for (const product of data.products) { - llms.push(`- [${product.name}](${BASE}/products/${product.slug}/): ${product.summary}`); -} -llms.push('', '## Writing', ''); -for (const post of posts) { - llms.push(`- [${post.title}](${BASE}/blog/${post.slug}/): ${post.description}`); -} -llms.push(''); -written.push(write('llms.txt', llms.join('\n'))); +// sitemap.xml, llms.txt and llms-full.txt come from the Rust generator (_products/gen), which reads _blog/posts.json too. console.log(`posts: ${posts.length}`); for (const r of rendered) console.log(` ${r.post.slug}: h1="${r.h1}" faq=${r.faq.length}`); diff --git a/_products/gen/src/main.rs b/_products/gen/src/main.rs index d37b0cc..e297ffb 100644 --- a/_products/gen/src/main.rs +++ b/_products/gen/src/main.rs @@ -53,11 +53,37 @@ struct Product { category: String, related: Vec, tier_note: Option, + // description when the page's head says something shorter than the JSON-LD description. + meta_description: Option, + // Sitemap lastmod when this page changed after last_updated. + lastmod: Option, + // Short name and summary for the 한국어 list in llms.txt. + ko_name: Option, + ko_summary: Option, + // llms-full.txt paragraph when it differs from description. + llms_detail: Option, upsell: Option, includes: Option, badge: Option, } +// _blog/posts.json, shared with blog-gen.mjs. +#[derive(Deserialize)] +struct Post { + slug: String, + title: String, + description: String, + date: String, + lastmod: Option, +} + +// Hand-written text of llms.txt and llms-full.txt, with {{...}} slots the generator fills. +struct Sources { + posts: Vec, + llms: String, + llms_full: String, +} + #[derive(Deserialize)] struct Upsell { text: String, @@ -108,7 +134,12 @@ fn main() { Err(err) => die(&format!("invalid site data in {data_path}: {err}")), }; - if let Err(err) = generate(&data, &site_root) { + let sources = match load_sources(Path::new(data_path)) { + Ok(sources) => sources, + Err(err) => die(&err), + }; + + if let Err(err) = generate(&data, &sources, &site_root) { die(&err); } } @@ -118,16 +149,33 @@ fn die(message: &str) -> ! { process::exit(1); } -fn generate(data: &SiteData, root: &Path) -> Result<(), String> { +// Paths are relative to site-data.json: templates/ next to it, _blog/ one level up. +fn load_sources(data_path: &Path) -> Result { + let dir = data_path.parent().unwrap_or(Path::new(".")); + let read = |path: PathBuf| { + fs::read_to_string(&path).map_err(|err| format!("cannot read {}: {err}", path.display())) + }; + let posts_path = dir.join("../_blog/posts.json"); + let posts = serde_json::from_str(&read(posts_path.clone())?) + .map_err(|err| format!("invalid {}: {err}", posts_path.display()))?; + Ok(Sources { + posts, + llms: read(dir.join("templates/llms.txt"))?, + llms_full: read(dir.join("templates/llms-full.txt"))?, + }) +} + +fn generate(data: &SiteData, sources: &Sources, root: &Path) -> Result<(), String> { write_file(root, "products/index.html", &render_listing(data)?)?; for product in &data.products { let path = format!("products/{}/index.html", product.slug); write_file(root, &path, &render_product(data, product)?)?; } write_file(root, "products/products.css", CSS)?; - write_file(root, "sitemap.xml", &render_sitemap(data))?; + write_file(root, "sitemap.xml", &render_sitemap(data, &sources.posts))?; write_file(root, "robots.txt", &render_robots(data))?; - write_file(root, "llms.txt", &render_llms(data))?; + write_file(root, "llms.txt", &render_llms(data, sources)?)?; + write_file(root, "llms-full.txt", &render_llms_full(data, sources)?)?; Ok(()) } @@ -605,7 +653,7 @@ fn render_product(data: &SiteData, product: &Product) -> Result let mut out = head( data, &product.title, - &product.description, + product.meta_description.as_deref().unwrap_or(&product.description), &canonical, &product.image_jpg, ); @@ -736,59 +784,142 @@ fn render_product(data: &SiteData, product: &Product) -> Result Ok(out) } -fn render_sitemap(data: &SiteData) -> String { - let mut locations = vec![ - format!("{}/", data.base_url), - canonical_products(&data.base_url), - format!("{}/legal/terms/", data.base_url), - format!("{}/legal/privacy/", data.base_url), - format!("{}/legal/refunds/", data.base_url), +fn render_sitemap(data: &SiteData, posts: &[Post]) -> String { + let base = &data.base_url; + let updated = data.last_updated.as_str(); + let product_lastmod = |product: &Product| product.lastmod.as_deref().unwrap_or(updated).to_string(); + let mut entries: Vec<(String, String)> = vec![ + (format!("{base}/"), updated.to_string()), + (canonical_products(base), updated.to_string()), ]; for product in &data.products { - locations.push(canonical_product(&data.base_url, &product.slug)); + entries.push((canonical_product(base, &product.slug), product_lastmod(product))); + } + entries.push((format!("{base}/blog/"), updated.to_string())); + for post in posts { + let lastmod = post.lastmod.as_deref().unwrap_or(&post.date); + entries.push((format!("{base}/blog/{}/", post.slug), lastmod.to_string())); + } + for page in ["legal/terms/", "legal/privacy/", "legal/refunds/", "about/", "ko/", "ko/products/"] { + entries.push((format!("{base}/{page}"), updated.to_string())); + } + for product in &data.products { + entries.push((format!("{base}/ko/products/{}/", product.slug), product_lastmod(product))); + } + for page in ["ko/about/", "ko/blog/"] { + entries.push((format!("{base}/{page}"), updated.to_string())); } let mut out = String::from( "\n\n", ); - let lastmod = esc(&data.last_updated); - for location in locations { + for (location, lastmod) in entries { out.push_str(&format!( - "{}{lastmod}\n", - esc(&location) + "{}{}\n", + esc(&location), + esc(&lastmod) )); } out.push_str("\n"); out } -fn render_llms(data: &SiteData) -> String { - let mut out = String::from( - "# 207 Studio\n\n> Developer kits and classroom tools from 207 Studio (207studio.dev), South Korea. One-time purchase, full source.\n\n## Products\n\n", - ); +fn prices(product: &Product) -> String { + product + .tiers + .iter() + .map(|tier| format!("{} ${}", tier.name, tier.price_usd)) + .collect::>() + .join(" / ") +} + +fn fill(template: &str, name: &str, slot: &str, value: &str) -> Result { + if !template.contains(slot) { + return Err(format!("templates/{name} has no {slot}")); + } + Ok(template.replacen(slot, value, 1)) +} + +fn render_llms(data: &SiteData, sources: &Sources) -> Result { + let mut products = String::new(); + let mut korean = String::new(); for product in &data.products { - let prices = product - .tiers - .iter() - .map(|tier| format!("{} ${}", tier.name, tier.price_usd)) - .collect::>() - .join(" / "); - out.push_str(&format!( + // Same line shape as check_llms.py, which CI runs against llms.txt. + products.push_str(&format!( "- [{}]({}): {} Prices: {}.\n", product.name, canonical_product(&data.base_url, &product.slug), product.summary, - prices + prices(product) + )); + let (Some(ko_name), Some(ko_summary)) = (&product.ko_name, &product.ko_summary) else { + return Err(format!("{}: ko_name and ko_summary are needed for llms.txt", product.slug)); + }; + korean.push_str(&format!( + "- [{ko_name}]({}/ko/products/{}/): {ko_summary} {}.\n", + data.base_url, + product.slug, + prices(product) )); } - out + let mut writing = String::new(); + for post in &sources.posts { + writing.push_str(&format!( + "- [{}]({}/blog/{}/): {}\n", + post.title, data.base_url, post.slug, post.description + )); + } + let out = fill(&sources.llms, "llms.txt", "{{products}}", &products)?; + let out = fill(&out, "llms.txt", "{{writing}}", &writing)?; + fill(&out, "llms.txt", "{{ko_products}}\n", &korean) } +fn render_llms_full(data: &SiteData, sources: &Sources) -> Result { + let mut blocks = String::new(); + for product in &data.products { + blocks.push_str(&format!( + "## {}\n\nURL: {}\n\n{}\n\n{}\n\nTiers (USD, one-time):\n", + product.title, + canonical_product(&data.base_url, &product.slug), + product.summary, + product.llms_detail.as_deref().unwrap_or(&product.description) + )); + for tier in &product.tiers { + blocks.push_str(&format!("- {}: ${} - buy: {}\n", tier.name, tier.price_usd, tier.url)); + } + blocks.push('\n'); + if let Some(note) = &product.tier_note { + blocks.push_str(&format!("{note}\n\n")); + } + if product.includes.is_some() { + // The committed llms-full.txt lists the field names, not the items. Kept byte-identical + // on purpose; fix here and in llms-full.txt together. + blocks.push_str("Includes:\n- text\n- links\n\n"); + } + } + fill(&sources.llms_full, "llms-full.txt", "{{products}}", &blocks) +} + +const AI_CRAWLERS: [&str; 10] = [ + "GPTBot", + "OAI-SearchBot", + "ChatGPT-User", + "ClaudeBot", + "Claude-User", + "PerplexityBot", + "Perplexity-User", + "Google-Extended", + "Applebot-Extended", + "xAI-Bot", +]; + fn render_robots(data: &SiteData) -> String { - format!( - "User-agent: *\nAllow: /\n\nSitemap: {}/sitemap.xml\n", - data.base_url - ) + let mut out = String::from("User-agent: *\nAllow: /\n\nUser-agent: Bingbot\nAllow: /\n\n# AI search and assistant crawlers are welcome.\n"); + for bot in AI_CRAWLERS { + out.push_str(&format!("User-agent: {bot}\nAllow: /\n")); + } + out.push_str(&format!("\nSitemap: {}/sitemap.xml\n", data.base_url)); + out } const CSS: &str = ":root{--bg:#fafbf8;--ink:#102b32;--muted:#52646a;--line:#d6dedb;--lime:#d5f66b} diff --git a/_products/site-data.json b/_products/site-data.json index f63b5f5..ec48430 100644 --- a/_products/site-data.json +++ b/_products/site-data.json @@ -10,6 +10,8 @@ }, "products": [ { + "ko_name": "PPTX Player SDK", + "ko_summary": "브라우저에서 PowerPoint를 애니메이션까지 재생하는 클라이언트 사이드 엔진.", "slug": "pptx-player-sdk", "name": "PPTX Player SDK – Play PowerPoint in the Browser", "audience": "developers", @@ -115,6 +117,9 @@ "badge": null }, { + "ko_name": "PairyCore SDK", + "ko_summary": "Mac 화면을 iPad로 스트리밍하는 Swift 패키지.", + "lastmod": "2026-10-30", "slug": "pairy-core-sdk", "name": "PairyCore SDK: Mac-to-iPad Screen Streaming", "audience": "developers", @@ -203,6 +208,8 @@ "badge": null }, { + "ko_name": "Scan-to-Grade", + "ko_summary": "QR Cropper와 OMR Auto-Grader를 묶은 Python 패키지.", "slug": "scan-to-grade-bundle", "name": "Scan-to-Grade: Scanned Exams to Excel (Python)", "audience": "developers", @@ -316,6 +323,8 @@ "badge": "Save $19" }, { + "ko_name": "OMR Auto-Grader", + "ko_summary": "스캔한 답안지의 버블, O/X, 동그라미 답안을 읽는 Python OMR.", "slug": "omr-auto-grader", "name": "OMR Auto-Grader – Bubbles, O/X, Circled Answers", "audience": "developers", @@ -430,6 +439,8 @@ "badge": null }, { + "ko_name": "QR Answer-Sheet Cropper", + "ko_summary": "답안지 QR로 페이지를 찾고 답안 칸마다 잘라내는 Python 도구.", "slug": "qr-cropper", "name": "QR Answer-Sheet Cropper – Crop Every Answer Box", "audience": "developers", @@ -535,6 +546,8 @@ "badge": null }, { + "ko_name": "Context-Gated Voice Commands", + "ko_summary": "상황에 맞는 음성 명령만 통과시키는 Rust 라이브러리와 CLI.", "slug": "voice-command-engine", "name": "Context-Gated Voice Commands – Offline", "audience": "developers", @@ -635,6 +648,8 @@ "badge": null }, { + "ko_name": "ShapeFit", + "ko_summary": "손으로 그린 획을 깔끔한 도형으로 바꾸는 Swift와 Rust 도구.", "slug": "shapefit", "name": "ShapeFit – Hold-to-Shape for Swift & Rust", "audience": "developers", @@ -730,6 +745,8 @@ "badge": null }, { + "ko_name": "Exam + OMR Sheet Builder", + "ko_summary": "시험지, OMR 답안지, 정답지, 해설지, 표지를 한 번에 만드는 도구.", "slug": "exam-sheet-builder", "name": "Exam + OMR Sheet Builder – Paper, Sheet & Key", "audience": "teachers", @@ -826,6 +843,8 @@ "badge": null }, { + "ko_name": "Offline Classroom Tools", + "ko_summary": "자리 배치, 아침 게시판, PACER 기록, Wi-Fi QR 카드가 든 HTML 파일 네 개.", "slug": "classroom-html-tools", "name": "Offline Classroom Tools – Seating, PACER Timer", "audience": "teachers", @@ -922,11 +941,15 @@ "badge": null }, { + "ko_name": "Send Approval Gate MCP", + "ko_summary": "AI 에이전트가 한 번만 보내고 확인하게 만드는 MCP 서버.", + "llms_detail": "Node.js 22+ MCP server (stdio) with six tools: status, account, contacts, plan-send, send and verify-send. The approval binds plan ID, account, recipient, message SHA-256, backend and environment hash. Duplicate sends are blocked, an uncertain result becomes result_unknown and is never retried, and every step goes to a local ledger with crash recovery. 47 tests, no npm dependencies.", "slug": "send-approval-gate-mcp", "name": "Send Approval Gate MCP: Plan, Approve, Send, Verify", "audience": "developers", "title": "Send Approval Gate MCP: Plan, Approve, Send, Verify", "description": "MCP server that makes an AI agent plan, get an exact hash-bound approval, send once and verify. Node.js 22+, no dependencies. One-time purchase, source included.", + "meta_description": "MCP server that makes an AI agent plan, get an exact hash-bound approval, send once and verify. Node.js 22+, no dependencies. One-time purchase, source.", "summary": "An MCP server that makes an AI agent plan, present an exact hash-bound approval, send once and verify, and records every step.", "what": "Send Approval Gate MCP is a Node.js 22+ MCP server (stdio) with six tools: status, account, contacts, plan-send, send and verify-send. The approval binds plan ID, account, recipient, message SHA-256, backend and environment hash, so any change needs a new approval. Duplicate sends are blocked, an uncertain result becomes result_unknown and is never retried, and every step goes to a local ledger with crash recovery. You attach your own channel through a small backend plugin contract; a mock backend and example plugin are included. 47 tests, no npm dependencies. It keeps a cooperative agent's sends exact, single and auditable; it is not a security boundary against an agent you do not trust.", "quickstart": [ diff --git a/_products/templates/llms-full.txt b/_products/templates/llms-full.txt new file mode 100644 index 0000000..e69d663 --- /dev/null +++ b/_products/templates/llms-full.txt @@ -0,0 +1,17 @@ +# 207 Studio: full product reference + +> https://207studio.dev · support@207studio.dev · South Korea. One-time purchase, full source. Commercial tiers cover companies with annual revenue up to $1M; above that, email support@207studio.dev for an Enterprise license. + +Founder: Jang Yuil (장유일) + +{{products}}## Pairy: Connecting Devices (iOS/iPadOS app) + +https://apps.apple.com/app/id6815584225 - remote desktop for your tablet. Paid app, one-time purchase ($4.99 in the US). + +## pptx-mcp (free) + +https://github.com/207studio/pptx-mcp - MCP server for opening and presenting .pptx files from an AI agent. + +## Contact + +support@207studio.dev diff --git a/_products/templates/llms.txt b/_products/templates/llms.txt new file mode 100644 index 0000000..7a72f53 --- /dev/null +++ b/_products/templates/llms.txt @@ -0,0 +1,55 @@ +# 207 Studio + +> Developer kits and classroom tools from 207 Studio (207studio.dev), South Korea. One-time purchase, full source. + +Founder: Jang Yuil (장유일) + +## Products + +{{products}} +## Apps + +- [Pairy: Connecting Devices](https://apps.apple.com/app/id6815584225): Remote desktop for your tablet. Shows your computer's screen on your tablet and lets you control it with touch, trackpad and keyboard. Paid app, one-time purchase ($4.99 in the US). + +## Licensing + +- Commercial licenses cover companies with annual revenue up to $1M. Above that, email support@207studio.dev for an Enterprise license. All purchases are one-time; no subscriptions. + +## Writing + +{{writing}} +## 한국어 + +- [207스튜디오 홈](https://207studio.dev/ko/): 교실 도구와 소스 포함 개발자 키트를 1회 구매로 판매해요. +- [상품](https://207studio.dev/ko/products/): 개발자 키트와 교실 도구 전체 목록. 가격과 구매 링크가 있어요. +{{ko_products}} +- [소개](https://207studio.dev/ko/about/): 207 Studio가 무엇을 만드는지, 어떻게 문의하는지. +- [글](https://207studio.dev/ko/blog/): 브라우저 렌더링과 교실 도구에 관한 글. + +## When to use 207 Studio + +- You need to play or preview .pptx files in a web page with click-by-click animations, without a server or Office: PPTX Player SDK. +- You grade paper exams and want scanned sheets turned into scores: QR Answer-Sheet Cropper, OMR Auto-Grader, or Scan-to-Grade (Python). +- You make printed exams with matching OMR sheets and keys: Exam + OMR Sheet Builder. +- You need offline voice commands that only fire in the right situation: Context-Gated Voice Commands (Rust). +- You build a Mac-to-iPad remote screen or drawing app: PairyCore SDK, ShapeFit. +- You want an AI agent to open and present PowerPoint files: the free pptx-mcp server. + +## How to buy (for agents) + +- Polar is the main checkout. Every product page lists tiers with a direct Polar link; card payment, one-time, instant download, no account needed beforehand. +- Paddle is the alternative checkout (card or PayPal): https://207studio.dev/pay/ +- Machine-readable prices and buy URLs: schema.org Product/Offer JSON-LD on each product page, and https://207studio.dev/llms-full.txt +- Questions or Enterprise quotes: support@207studio.dev + +## Free & open source + +- [pptx-mcp](https://github.com/207studio/pptx-mcp): MCP server that lets an AI agent open, inspect and present .pptx files. Free. + +## About + +- [About 207 Studio](https://207studio.dev/about/): who we are, where we are, how to contact us. + +## Contact + +- Email: support@207studio.dev