diff --git a/.agents/skills/frontend-aeo-wiki/SKILL.md b/.agents/skills/frontend-aeo-wiki/SKILL.md new file mode 100644 index 00000000..0f833b0b --- /dev/null +++ b/.agents/skills/frontend-aeo-wiki/SKILL.md @@ -0,0 +1,77 @@ +--- +name: frontend-aeo-wiki +description: Generate AEO wiki and answer-page drafts from the user-visible surface of a frontend repository, especially Next.js App Router apps. Use when turning frontend routes, pages, metadata, UI copy, README, or public docs into Kinic Wiki Markdown and /answers Markdown. +--- + +# Frontend AEO Wiki + +Use this skill when a frontend repository should become an AI-search-ready public wiki. + +## Scope + +Only document user-visible product behavior. + +Include: + +- Next.js App Router pages and layouts +- route metadata and page titles +- UI copy visible in components used by public pages +- README and public docs +- public API behavior only when surfaced in UI or public docs + +Exclude: + +- database schemas +- backend-only code and internal clients +- tests, fixtures, generated files, build scripts +- secrets, environment values, private config +- hidden admin surfaces +- claims about pricing, security, compliance, performance, competitors, or roadmap unless explicitly present in visible UI, metadata, README, or public docs + +## Workflow + +1. Detect the frontend framework. For now, support Next.js App Router first. +2. Build a source pack from visible routes, layouts, metadata, README, and docs. +3. Generate concise Wiki Markdown: + - `/Wiki/product/overview.md` + - `/Wiki/product/screens.md` + - `/Wiki/product/features.md` + - `/Wiki/product/faq.md` +4. Generate AEO answer Markdown under `/Wiki/aeo/...`. +5. Every answer must include frontmatter with `title`, `description`, `answer_summary`, `updated`, `index: true`, `entities`, and `sources`. +6. Every answer body must cite repo-relative source paths. +7. Reject or omit unsupported claims instead of inventing product promises. + +## Answer Contract + +Use this frontmatter shape: + +```md +--- +title: What is Example? +description: Example helps users understand the visible product value. +answer_summary: Example provides the visible product experience described in the frontend. +updated: 2026-05-07 +index: true +entities: + - Example +sources: + - README.md + - app/page.tsx +--- +``` + +The body should be short, factual, and grounded in `sources`. + +## Validation + +Before publishing, ensure: + +- slugs are unique +- required frontmatter exists +- `sources` is non-empty +- Markdown parses +- no secret patterns appear +- no unsupported claim category appears without a visible source + +If validation fails, do not publish generated pages. diff --git a/crates/vfs_cli_app/src/aeo_generate/collect.rs b/crates/vfs_cli_app/src/aeo_generate/collect.rs new file mode 100644 index 00000000..ac317561 --- /dev/null +++ b/crates/vfs_cli_app/src/aeo_generate/collect.rs @@ -0,0 +1,172 @@ +// Where: crates/vfs_cli_app/src/aeo_generate/collect.rs +// What: Collect and validate user-visible frontend source paths. +// Why: AEO generation must ignore implementation-only repository content. + +use crate::aeo_generate::types::{SourceEntry, SourceKind, ValidationReport}; +use anyhow::Result; +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +const MAX_SOURCE_BYTES: u64 = 512 * 1024; + +pub fn collect_frontend_sources(repo: &Path) -> Result> { + let mut sources = BTreeMap::::new(); + let readme = repo.join("README.md"); + if readme.is_file() { + sources.insert("README.md".to_string(), SourceKind::Readme); + } + collect_docs(repo, repo, &mut sources)?; + collect_next_app(repo, repo, &mut sources)?; + Ok(sources + .into_iter() + .map(|(path, kind)| SourceEntry { path, kind }) + .collect()) +} + +pub fn validate_source_pack(sources: &[SourceEntry], repo: &Path) -> ValidationReport { + let mut report = ValidationReport { + passed: true, + errors: Vec::new(), + warnings: Vec::new(), + }; + if !sources + .iter() + .any(|source| source.kind == SourceKind::NextAppPage) + { + report + .errors + .push("missing Next.js App Router page source".to_string()); + } + if sources.is_empty() { + report + .errors + .push("no frontend AEO sources found".to_string()); + } + for source in sources { + let path = repo.join(&source.path); + if let Ok(content) = fs::read_to_string(path) + && contains_secret_pattern(&content) + { + report + .errors + .push(format!("secret-like pattern found in {}", source.path)); + } + } + report.passed = report.errors.is_empty(); + report +} + +fn collect_docs( + repo: &Path, + root: &Path, + sources: &mut BTreeMap, +) -> Result<()> { + let docs = repo.join("docs"); + if !docs.is_dir() { + return Ok(()); + } + collect_files( + &docs, + root, + sources, + |relative| relative.ends_with(".md") || relative.ends_with(".mdx"), + SourceKind::PublicDoc, + ) +} + +fn collect_next_app( + repo: &Path, + root: &Path, + sources: &mut BTreeMap, +) -> Result<()> { + let app = repo.join("app"); + if !app.is_dir() { + return Ok(()); + } + collect_files( + &app, + root, + sources, + |relative| { + (relative.ends_with("/page.tsx") || relative == "app/page.tsx") + || (relative.ends_with("/layout.tsx") || relative == "app/layout.tsx") + }, + SourceKind::NextAppPage, + )?; + for (path, kind) in sources.iter_mut() { + if path.ends_with("/layout.tsx") || path == "app/layout.tsx" { + *kind = SourceKind::NextAppLayout; + } + } + Ok(()) +} + +fn collect_files( + dir: &Path, + root: &Path, + sources: &mut BTreeMap, + include: impl Fn(&str) -> bool + Copy, + kind: SourceKind, +) -> Result<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + if should_skip_name(&file_name) { + continue; + } + if path.is_dir() { + collect_files(&path, root, sources, include, kind)?; + continue; + } + if !path.is_file() || path.metadata()?.len() > MAX_SOURCE_BYTES { + continue; + } + let relative = repo_relative(root, &path)?; + if include(&relative) && !is_hidden_admin_surface(&relative) { + sources.insert(relative, kind); + } + } + Ok(()) +} + +fn should_skip_name(name: &str) -> bool { + name.starts_with('.') + || matches!( + name, + "node_modules" + | "target" + | ".next" + | "dist" + | "build" + | "coverage" + | "__tests__" + | "tests" + | "fixtures" + ) +} + +fn is_hidden_admin_surface(relative: &str) -> bool { + relative + .split('/') + .any(|segment| segment == "admin" || segment == "(admin)" || segment.starts_with("_")) +} + +fn repo_relative(root: &Path, path: &Path) -> Result { + Ok(path + .strip_prefix(root)? + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/")) +} + +fn contains_secret_pattern(content: &str) -> bool { + content.contains("-----BEGIN PRIVATE KEY-----") + || content.contains("sk-") + || content.contains("AKIA") + || content.contains("SECRET_KEY=") + || content.contains("NEXT_PUBLIC_") && content.contains("PRIVATE") +} diff --git a/crates/vfs_cli_app/src/aeo_generate/mod.rs b/crates/vfs_cli_app/src/aeo_generate/mod.rs new file mode 100644 index 00000000..35bf87b4 --- /dev/null +++ b/crates/vfs_cli_app/src/aeo_generate/mod.rs @@ -0,0 +1,176 @@ +// Where: crates/vfs_cli_app/src/aeo_generate/mod.rs +// What: Local dry-run generator for frontend-surface AEO wiki artifacts. +// Why: Repo push automation needs deterministic artifacts before server-side LLM publish exists. + +mod collect; +mod output; +mod types; + +pub use types::{AeoGenerateArgs, AeoGenerationReport}; + +use anyhow::{Context, Result, bail}; +use collect::{collect_frontend_sources, validate_source_pack}; +use output::{build_manifest, build_outputs, slugify, write_outputs}; +use std::fs; + +pub fn run_aeo_generate(args: AeoGenerateArgs) -> Result { + let repo = args + .repo + .canonicalize() + .with_context(|| format!("repo path does not exist: {}", args.repo.display()))?; + if !repo.is_dir() { + bail!("repo path is not a directory: {}", repo.display()); + } + let project_name = args.project_name.trim(); + if project_name.is_empty() { + bail!("project name must not be empty"); + } + let project_slug = slugify(project_name); + if project_slug.is_empty() { + bail!("project name must contain at least one alphanumeric character"); + } + + let sources = collect_frontend_sources(&repo)?; + let validation = validate_source_pack(&sources, &repo); + let outputs = build_outputs(project_name, &project_slug, &sources); + + fs::create_dir_all(args.out.join("wiki"))?; + fs::create_dir_all(args.out.join("answers"))?; + write_outputs(&args.out, project_name, &project_slug, &outputs)?; + + let manifest = build_manifest(project_name, &project_slug, &outputs); + fs::write( + args.out.join("manifest.json"), + serde_json::to_string_pretty(&manifest)? + "\n", + )?; + fs::write( + args.out.join("validation.json"), + serde_json::to_string_pretty(&validation)? + "\n", + )?; + + Ok(AeoGenerationReport { + project_name: project_name.to_string(), + project_slug, + framework: "nextjs_app_router".to_string(), + sources, + outputs, + validation, + }) +} + +#[cfg(test)] +mod tests { + use super::{AeoGenerateArgs, run_aeo_generate}; + use crate::aeo_generate::types::SourceKind; + use std::fs; + use tempfile::tempdir; + + #[test] + fn next_app_router_fixture_generates_expected_artifacts() { + let dir = tempdir().expect("tempdir should exist"); + let repo = dir.path().join("repo"); + fs::create_dir_all(repo.join("app/features")).expect("dirs should write"); + fs::create_dir_all(repo.join("docs")).expect("docs should write"); + fs::write(repo.join("README.md"), "# Demo\nVisible product.").expect("readme should write"); + fs::write( + repo.join("app/page.tsx"), + "export default function Page(){return
Demo home
}", + ) + .expect("page should write"); + fs::write( + repo.join("app/features/page.tsx"), + "export default function Page(){return
Features
}", + ) + .expect("feature page should write"); + fs::write(repo.join("docs/public.md"), "# Public docs").expect("doc should write"); + + let out = dir.path().join("out"); + let report = run_aeo_generate(AeoGenerateArgs { + repo, + out: out.clone(), + project_name: "Demo App".to_string(), + }) + .expect("generation should pass"); + + assert!(report.validation.passed); + assert!(out.join("wiki/overview.md").is_file()); + assert!(out.join("answers/what-is-demo-app.md").is_file()); + assert!(out.join("manifest.json").is_file()); + assert!( + report + .sources + .iter() + .any(|source| source.path == "app/page.tsx" + && source.kind == SourceKind::NextAppPage) + ); + assert!( + report + .sources + .iter() + .any(|source| source.path == "docs/public.md" + && source.kind == SourceKind::PublicDoc) + ); + } + + #[test] + fn generator_excludes_backend_tests_hidden_admin_and_env_files() { + let dir = tempdir().expect("tempdir should exist"); + let repo = dir.path().join("repo"); + fs::create_dir_all(repo.join("app/admin")).expect("admin dirs should write"); + fs::create_dir_all(repo.join("app")).expect("app dirs should write"); + fs::create_dir_all(repo.join("tests")).expect("tests dirs should write"); + fs::write( + repo.join("app/page.tsx"), + "export default function Page(){return null}", + ) + .expect("page should write"); + fs::write( + repo.join("app/admin/page.tsx"), + "export default function Page(){return null}", + ) + .expect("admin page should write"); + fs::write(repo.join("tests/page.test.tsx"), "test('x',()=>{})").expect("test should write"); + fs::write(repo.join(".env"), "SECRET_KEY=value").expect("env should write"); + + let report = run_aeo_generate(AeoGenerateArgs { + repo, + out: dir.path().join("out"), + project_name: "Demo".to_string(), + }) + .expect("generation should pass"); + + let paths = report + .sources + .iter() + .map(|source| source.path.as_str()) + .collect::>(); + assert!(paths.contains(&"app/page.tsx")); + assert!(!paths.contains(&"app/admin/page.tsx")); + assert!(!paths.contains(&"tests/page.test.tsx")); + assert!(!paths.contains(&".env")); + } + + #[test] + fn missing_page_source_fails_validation() { + let dir = tempdir().expect("tempdir should exist"); + let repo = dir.path().join("repo"); + fs::create_dir_all(&repo).expect("repo should write"); + fs::write(repo.join("README.md"), "# Demo").expect("readme should write"); + + let report = run_aeo_generate(AeoGenerateArgs { + repo, + out: dir.path().join("out"), + project_name: "Demo".to_string(), + }) + .expect("generation should produce validation report"); + + assert!(!report.validation.passed); + assert!( + report + .validation + .errors + .iter() + .any(|error| error.contains("missing Next.js App Router page source")) + ); + } +} diff --git a/crates/vfs_cli_app/src/aeo_generate/output.rs b/crates/vfs_cli_app/src/aeo_generate/output.rs new file mode 100644 index 00000000..c5b5e136 --- /dev/null +++ b/crates/vfs_cli_app/src/aeo_generate/output.rs @@ -0,0 +1,202 @@ +// Where: crates/vfs_cli_app/src/aeo_generate/output.rs +// What: Build and write frontend AEO dry-run artifacts. +// Why: Generated files need a stable shape for future publish automation. + +use crate::aeo_generate::types::{ + GeneratedOutput, GeneratedOutputKind, Manifest, ManifestAnswer, ManifestWikiPage, SourceEntry, +}; +use anyhow::{Result, anyhow}; +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +pub fn build_outputs( + project_name: &str, + project_slug: &str, + sources: &[SourceEntry], +) -> Vec { + let source_paths = sources + .iter() + .map(|source| source.path.clone()) + .collect::>(); + let wiki_pages = [ + ("wiki/overview.md", "Product overview"), + ("wiki/screens.md", "Visible screens"), + ("wiki/features.md", "Visible features"), + ("wiki/faq.md", "Product FAQ"), + ]; + let answers = [ + ( + format!("answers/what-is-{project_slug}.md"), + format!("what-is-{project_slug}"), + format!("What is {project_name}?"), + ), + ( + format!("answers/how-does-{project_slug}-work.md"), + format!("how-does-{project_slug}-work"), + format!("How does {project_name} work?"), + ), + ( + format!("answers/{project_slug}-features.md"), + format!("{project_slug}-features"), + format!("{project_name} features"), + ), + ]; + wiki_pages + .into_iter() + .map(|(path, title)| GeneratedOutput { + kind: GeneratedOutputKind::Wiki, + path: path.to_string(), + slug: None, + title: title.to_string(), + sources: source_paths.clone(), + }) + .chain( + answers + .into_iter() + .map(|(path, slug, title)| GeneratedOutput { + kind: GeneratedOutputKind::Answer, + path, + slug: Some(slug), + title, + sources: source_paths.clone(), + }), + ) + .collect() +} + +pub fn write_outputs( + out: &Path, + project_name: &str, + project_slug: &str, + outputs: &[GeneratedOutput], +) -> Result<()> { + let duplicate_slugs = duplicate_slugs(outputs); + if !duplicate_slugs.is_empty() { + return Err(anyhow!("duplicate generated slugs: {:?}", duplicate_slugs)); + } + for output in outputs { + let path = out.join(&output.path); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let content = match output.kind { + GeneratedOutputKind::Wiki => wiki_markdown(project_name, output), + GeneratedOutputKind::Answer => answer_markdown(project_name, project_slug, output), + }; + fs::write(path, content)?; + } + Ok(()) +} + +pub fn build_manifest( + project_name: &str, + project_slug: &str, + outputs: &[GeneratedOutput], +) -> Manifest { + Manifest { + project_name: project_name.to_string(), + project_slug: project_slug.to_string(), + framework: "nextjs_app_router".to_string(), + answers: outputs + .iter() + .filter(|output| output.kind == GeneratedOutputKind::Answer) + .map(|output| ManifestAnswer { + slug: output.slug.clone().expect("answer output should have slug"), + title: output.title.clone(), + path: output.path.clone(), + sources: output.sources.clone(), + }) + .collect(), + wiki_pages: outputs + .iter() + .filter(|output| output.kind == GeneratedOutputKind::Wiki) + .map(|output| ManifestWikiPage { + title: output.title.clone(), + path: output.path.clone(), + sources: output.sources.clone(), + }) + .collect(), + } +} + +pub fn slugify(value: &str) -> String { + let mut slug = String::new(); + let mut last_dash = false; + for ch in value.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + last_dash = false; + } else if !last_dash && !slug.is_empty() { + slug.push('-'); + last_dash = true; + } + } + if slug.ends_with('-') { + slug.pop(); + } + slug +} + +fn duplicate_slugs(outputs: &[GeneratedOutput]) -> Vec { + let mut seen = BTreeSet::new(); + let mut duplicates = BTreeSet::new(); + for slug in outputs.iter().filter_map(|output| output.slug.as_ref()) { + if !seen.insert(slug.clone()) { + duplicates.insert(slug.clone()); + } + } + duplicates.into_iter().collect() +} + +fn wiki_markdown(project_name: &str, output: &GeneratedOutput) -> String { + format!( + "# {}\n\n{} is described from user-visible frontend sources.\n\n## Sources\n{}\n", + output.title, + project_name, + markdown_source_list(&output.sources) + ) +} + +fn answer_markdown(project_name: &str, project_slug: &str, output: &GeneratedOutput) -> String { + let slug = output + .slug + .as_deref() + .expect("answer output should have slug"); + let subject = if slug == format!("what-is-{project_slug}") { + project_name.to_string() + } else { + format!("{project_name} ({slug})") + }; + format!( + "---\ntitle: {}\ndescription: {} answer page generated from visible frontend sources.\nanswer_summary: {} is summarized from visible frontend sources.\nupdated: 2026-05-07\nindex: true\nentities:\n - {}\nsources:\n{}\n---\n\n# {}\n\n{} is represented here using only user-visible frontend sources.\n\n## Sources\n{}\n", + output.title, + project_name, + project_name, + project_name, + frontmatter_source_list(&output.sources), + output.title, + subject, + markdown_source_list(&output.sources) + ) +} + +fn frontmatter_source_list(sources: &[String]) -> String { + sources + .iter() + .map(|source| format!(" - {source}")) + .collect::>() + .join("\n") +} + +fn markdown_source_list(sources: &[String]) -> String { + if sources.is_empty() { + return "- No source paths found.\n".to_string(); + } + sources + .iter() + .map(|source| format!("- `{source}`")) + .collect::>() + .join("\n") + + "\n" +} diff --git a/crates/vfs_cli_app/src/aeo_generate/types.rs b/crates/vfs_cli_app/src/aeo_generate/types.rs new file mode 100644 index 00000000..f674d583 --- /dev/null +++ b/crates/vfs_cli_app/src/aeo_generate/types.rs @@ -0,0 +1,85 @@ +// Where: crates/vfs_cli_app/src/aeo_generate/types.rs +// What: Shared data contracts for frontend AEO dry-run generation. +// Why: Manifest, validation, and CLI output need stable structured shapes. + +use serde::Serialize; +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct AeoGenerateArgs { + pub repo: PathBuf, + pub out: PathBuf, + pub project_name: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AeoGenerationReport { + pub project_name: String, + pub project_slug: String, + pub framework: String, + pub sources: Vec, + pub outputs: Vec, + pub validation: ValidationReport, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SourceEntry { + pub path: String, + pub kind: SourceKind, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SourceKind { + Readme, + PublicDoc, + NextAppPage, + NextAppLayout, +} + +#[derive(Debug, Clone, Serialize)] +pub struct GeneratedOutput { + pub kind: GeneratedOutputKind, + pub path: String, + pub slug: Option, + pub title: String, + pub sources: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum GeneratedOutputKind { + Wiki, + Answer, +} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct ValidationReport { + pub passed: bool, + pub errors: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Manifest { + pub project_name: String, + pub project_slug: String, + pub framework: String, + pub answers: Vec, + pub wiki_pages: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ManifestAnswer { + pub slug: String, + pub title: String, + pub path: String, + pub sources: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ManifestWikiPage { + pub title: String, + pub path: String, + pub sources: Vec, +} diff --git a/crates/vfs_cli_app/src/cli.rs b/crates/vfs_cli_app/src/cli.rs index 87398fda..a87c1c2c 100644 --- a/crates/vfs_cli_app/src/cli.rs +++ b/crates/vfs_cli_app/src/cli.rs @@ -256,6 +256,14 @@ pub enum Command { #[arg(long, default_value = DEFAULT_MIRROR_ROOT)] mirror_root: String, }, + AeoGenerate { + #[arg(long)] + repo: PathBuf, + #[arg(long)] + out: PathBuf, + #[arg(long)] + project_name: String, + }, } impl Command { @@ -515,4 +523,29 @@ mod tests { assert_eq!(limit, 9); assert!(!json); } + + #[test] + fn main_cli_parses_aeo_generate_command() { + let cli = Cli::parse_from([ + "vfs-cli", + "aeo-generate", + "--repo", + ".", + "--out", + "/tmp/aeo", + "--project-name", + "Demo App", + ]); + let Command::AeoGenerate { + repo, + out, + project_name, + } = cli.command + else { + panic!("expected aeo-generate command"); + }; + assert_eq!(repo, std::path::PathBuf::from(".")); + assert_eq!(out, std::path::PathBuf::from("/tmp/aeo")); + assert_eq!(project_name, "Demo App"); + } } diff --git a/crates/vfs_cli_app/src/lib.rs b/crates/vfs_cli_app/src/lib.rs index 660c227b..95f92692 100644 --- a/crates/vfs_cli_app/src/lib.rs +++ b/crates/vfs_cli_app/src/lib.rs @@ -1,6 +1,7 @@ // Where: crates/vfs_cli_app/src/lib.rs // What: Agent-facing CLI library for FS-first remote operations and local mirrors. // Why: The CLI now talks to the canister using node-oriented APIs and mirrors paths directly. +pub mod aeo_generate; #[cfg(test)] mod agent_tools_tests; pub mod beam_bench; diff --git a/crates/vfs_cli_app/src/main.rs b/crates/vfs_cli_app/src/main.rs index 2f38a38c..904b0144 100644 --- a/crates/vfs_cli_app/src/main.rs +++ b/crates/vfs_cli_app/src/main.rs @@ -4,13 +4,29 @@ use anyhow::Result; use clap::Parser; use vfs_cli::connection::resolve_connection; +use vfs_cli_app::aeo_generate::{AeoGenerateArgs, run_aeo_generate}; use vfs_cli_app::cli::Cli; +use vfs_cli_app::cli::Command; use vfs_cli_app::commands::run_command; use vfs_client::CanisterVfsClient; #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); + if let Command::AeoGenerate { + repo, + out, + project_name, + } = &cli.command + { + let report = run_aeo_generate(AeoGenerateArgs { + repo: repo.clone(), + out: out.clone(), + project_name: project_name.clone(), + })?; + println!("{}", serde_json::to_string_pretty(&report)?); + return Ok(()); + } let connection = resolve_connection(cli.connection.local, cli.connection.canister_id.clone())?; let client = CanisterVfsClient::new(&connection.replica_host, &connection.canister_id).await?; run_command(&client, cli).await diff --git a/docs/internal/FRONTEND_AEO_WIKI.md b/docs/internal/FRONTEND_AEO_WIKI.md new file mode 100644 index 00000000..bf93cbb2 --- /dev/null +++ b/docs/internal/FRONTEND_AEO_WIKI.md @@ -0,0 +1,71 @@ +# Frontend AEO Wiki + +Frontend AEO Wiki turns the user-visible surface of a frontend repo into Kinic Wiki pages and AEO answer pages. + +## Product Flow + +1. Developer connects a GitHub repository. +2. Developer pushes to `main`. +3. Kinic receives a push webhook. +4. Kinic analyzes the frontend surface. +5. Kinic-side LLM generation creates public Wiki and AEO answer drafts. +6. Mechanical validation gates publish. +7. Kinic publishes updated `/answers`, `sitemap.xml`, and `llms.txt`. + +The target experience is Vercel-like: connect a repo, push to `main`, and let Kinic keep the AI-search-ready public knowledge layer current. + +## Frontend Surface + +MVP source inputs: + +- Next.js App Router `app/**/page.tsx` +- Next.js App Router `app/**/layout.tsx` +- route metadata and visible UI copy +- root `README.md` +- public docs under `docs/**` + +Excluded inputs: + +- database schemas +- backend-only helpers and internal API clients +- tests and fixtures +- generated files and build scripts +- secrets, environment values, and private config +- hidden admin surfaces + +Generated content must not claim pricing, security, compliance, performance, competitor superiority, or roadmap details unless the claim is explicit in visible UI, metadata, README, or public docs. + +## Generated Output + +Local dry-run output uses this shape: + +```text +wiki/overview.md +wiki/screens.md +wiki/features.md +wiki/faq.md +answers/what-is-.md +answers/how-does--work.md +answers/-features.md +manifest.json +validation.json +``` + +Answer pages use the existing AEO frontmatter contract plus `sources`. + +## Publish Contract + +The current MVP only generates local artifacts. A later publish step can consume `manifest.json` to update Kinic Wiki nodes and derive `AeoPageConfig` allowlist entries. + +Publish must pass mechanical validation: + +- required frontmatter exists +- slug values are unique +- source paths are present +- Markdown parses +- no secret patterns are present +- generated claims are grounded in visible frontend or public docs +- `/w/*` remains `noindex` +- only `/answers/*` appears in `sitemap.xml` and `llms.txt` + +If validation fails, Kinic keeps the previous published version and stores the failed generation report. diff --git a/docs/internal/aeo-publish-mvp/README.md b/docs/internal/aeo-publish-mvp/README.md new file mode 100644 index 00000000..9304b51b --- /dev/null +++ b/docs/internal/aeo-publish-mvp/README.md @@ -0,0 +1,14 @@ +# AEO Publish MVP Investor Diagrams + +投資家向けに AEO Publish MVP の概念を説明する図を置く。 + +## 推奨順序 + +1. ![Search shift](search-shift.png) + 検索導線が「ページ訪問」から「AI回答」へ移る市場変化を示す。 + +2. ![Sitemap, llms.txt, and answers](sitemap-llms-answers.png) + `sitemap.xml`、`llms.txt`、`/answers/` の役割差を示す。 + +3. ![AEO publish MVP flow](aeo-publish-mvp-flow.png) + Kinic Wiki Canister から AI 検索に引用されるまでの公開フローを示す。 diff --git a/docs/internal/aeo-publish-mvp/aeo-publish-mvp-flow.png b/docs/internal/aeo-publish-mvp/aeo-publish-mvp-flow.png new file mode 100644 index 00000000..0c48bfad Binary files /dev/null and b/docs/internal/aeo-publish-mvp/aeo-publish-mvp-flow.png differ diff --git a/docs/internal/aeo-publish-mvp/search-shift.png b/docs/internal/aeo-publish-mvp/search-shift.png new file mode 100644 index 00000000..d73aef6c Binary files /dev/null and b/docs/internal/aeo-publish-mvp/search-shift.png differ diff --git a/docs/internal/aeo-publish-mvp/sitemap-llms-answers.png b/docs/internal/aeo-publish-mvp/sitemap-llms-answers.png new file mode 100644 index 00000000..f6c78800 Binary files /dev/null and b/docs/internal/aeo-publish-mvp/sitemap-llms-answers.png differ diff --git a/wikibrowser/README.md b/wikibrowser/README.md index d3441516..4873888e 100644 --- a/wikibrowser/README.md +++ b/wikibrowser/README.md @@ -128,6 +128,45 @@ The app is public read-only and accepts database IDs for the fixed canister. The Canister unreachable / API failures are shown as browser errors and are not treated as not-found states. The `//...` and `/dashboard/` URLs are App Router dynamic routes. Read and authenticated calls go directly from the browser to the configured IC gateway. +## AEO Publish MVP + +`/answers/` serves allowlisted AI-readable public memory pages. These pages run on the Next server with ISR and read Markdown from the configured Kinic Wiki canister. +The published answer page is rendered from canonical Markdown so crawlers can read stable answer HTML. +This MVP is Kinic's first AEO publish layer: question demand informs which allowlisted answer pages are added next, and the answer asset set expands without exposing arbitrary database browser routes. + +Environment: + +```bash +KINIC_AEO_CANISTER_ID= +NEXT_PUBLIC_SITE_URL=https://kinic.xyz +``` + +Rules: + +- `/answers/*` is indexable and rendered with the page body in the initial HTML. +- `//*` remains the arbitrary database browser and is marked `noindex`. +- `sitemap.xml` only lists `/answers/*`. +- `robots.txt` allows `OAI-SearchBot` and blocks arbitrary browser routes. +- The service boundary is the AEO page allowlist, `sitemap.xml`, `llms.txt`, `robots.txt`, and JSON-LD generated from `AeoPageConfig`. + +AEO Markdown must include limited frontmatter: + +```md +--- +title: What is Kinic? +description: Kinic is an AI memory for important information. +answer_summary: Kinic helps people browse and access important information in one organized place. +updated: 2026-05-07 +index: true +entities: + - Kinic + - AI memory +sources: + - README.md + - app/page.tsx +--- +``` + ## Troubleshooting - Local canister not found: `NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID` does not exist on `NEXT_PUBLIC_WIKI_IC_HOST`. For `http://127.0.0.1:8000`, start the local replica / icp local network and deploy the wiki canister into that state. @@ -145,10 +184,10 @@ Cloudflare settings: - Root Directory: `wikibrowser` - Install Command: `pnpm install --frozen-lockfile` - Build Command: `pnpm deploy` -- Build Variables: `NEXT_PUBLIC_WIKI_IC_HOST=https://icp0.io` and `NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID=` for Preview and Production +- Build Variables: `NEXT_PUBLIC_WIKI_IC_HOST=https://icp0.io`, `NEXT_PUBLIC_KINIC_WIKI_CANISTER_ID=`, `KINIC_AEO_CANISTER_ID=`, and `NEXT_PUBLIC_SITE_URL=` for Preview and Production - Runtime: Cloudflare Workers via `@opennextjs/cloudflare` -Both variables are public browser bundle values. Set them as Cloudflare build variables, not only runtime Worker variables, because Next.js inlines `NEXT_PUBLIC_*` values into the client bundle during build. +Set `NEXT_PUBLIC_*` values as Cloudflare build variables, not only runtime Worker variables, because Next.js inlines them into the client bundle during build. CLI deploy from this directory: diff --git a/wikibrowser/app/[databaseId]/[[...segments]]/page.tsx b/wikibrowser/app/[databaseId]/[[...segments]]/page.tsx index 30682ace..1987c7b1 100644 --- a/wikibrowser/app/[databaseId]/[[...segments]]/page.tsx +++ b/wikibrowser/app/[databaseId]/[[...segments]]/page.tsx @@ -1,6 +1,14 @@ import { Suspense } from "react"; +import type { Metadata } from "next"; import { WikiBrowser } from "@/components/wiki-browser"; +export const metadata: Metadata = { + robots: { + index: false, + follow: false + } +}; + export default function WikiDatabasePage() { return ( }> diff --git a/wikibrowser/app/answers/[slug]/page.tsx b/wikibrowser/app/answers/[slug]/page.tsx new file mode 100644 index 00000000..dcacd154 --- /dev/null +++ b/wikibrowser/app/answers/[slug]/page.tsx @@ -0,0 +1,62 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { AeoArticle } from "@/components/aeo-article"; +import { absoluteUrl } from "@/lib/aeo/site"; +import { loadAeoPage } from "@/lib/aeo/load-page"; + +export const runtime = "nodejs"; +export const revalidate = 3600; + +type PageProps = { + params: Promise<{ + slug: string; + }>; +}; + +export async function generateMetadata({ params }: PageProps): Promise { + const { slug } = await params; + const page = await loadAeoPage(slug); + if (!page) { + return {}; + } + const canonicalPath = page.parsed.frontmatter.canonical ?? page.config.canonicalPath; + const url = absoluteUrl(canonicalPath); + return { + title: page.parsed.frontmatter.title, + description: page.parsed.frontmatter.description, + alternates: { + canonical: url + }, + openGraph: { + type: "article", + title: page.parsed.frontmatter.title, + description: page.parsed.frontmatter.description, + url, + locale: page.config.locale + } + }; +} + +export default async function AnswerPage({ params }: PageProps) { + const { slug } = await params; + const page = await loadAeoPage(slug); + if (!page) { + notFound(); + } + const canonicalPath = page.parsed.frontmatter.canonical ?? page.config.canonicalPath; + const jsonLd = { + "@context": "https://schema.org", + "@type": "Article", + headline: page.parsed.frontmatter.title, + description: page.parsed.frontmatter.description, + dateModified: page.parsed.frontmatter.updated, + mainEntityOfPage: absoluteUrl(canonicalPath), + about: page.parsed.frontmatter.entities + }; + return ( + <> +