use std::collections::{HashMap, HashSet};
use std::env;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use axum::extract::State;
use axum::response::{Html, IntoResponse};
use axum::routing::{get, post};
use axum::{Json, Router};
use mdforge::forge::HtmlRenderer;
use mdforge::{ArgType, ArgValue, BlockNode, Diagnostic, EvalContext, Forge, InlineExt};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Clone)]
struct AppState {
forge: Forge,
eval_ctx: EvalContext,
openai_api_key: Option<String>,
openai_model: String,
}
#[derive(Debug, Deserialize)]
struct GenerateRequest {
prompt: String,
pantry: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct RenderRequest {
markdown: String,
pantry: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct RepairRequest {
prompt: String,
markdown: String,
diagnostics: Vec<DiagnosticView>,
pantry: Vec<String>,
}
#[derive(Debug, Serialize)]
struct ApiResponse {
ok: bool,
markdown: String,
html: String,
diagnostics: Vec<DiagnosticView>,
attempts: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct DiagnosticView {
code: String,
message: String,
suggestion: Option<String>,
}
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
let state = Arc::new(AppState {
forge: recipe_forge(),
eval_ctx: recipe_eval_ctx(),
openai_api_key: env::var("OPENAI_API_KEY").ok(),
openai_model: env::var("OPENAI_MODEL").unwrap_or_else(|_| "gpt-4.1-mini".to_string()),
});
let app = Router::new()
.route("/", get(index))
.route("/api/generate", post(generate_recipe))
.route("/api/repair", post(repair_recipe))
.route("/api/render", post(render_recipe))
.route("/api/signature", get(signature))
.with_state(state);
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
println!("Recipe Lab を起動しました: http://{addr}");
let listener = tokio::net::TcpListener::bind(addr)
.await
.expect("bind demo server");
axum::serve(listener, app).await.expect("serve demo server");
}
async fn index() -> impl IntoResponse {
Html(INDEX_HTML)
}
async fn signature(State(state): State<Arc<AppState>>) -> Json<Value> {
let ingredients = recipe_ingredients()
.into_iter()
.map(|id| json!({"id": id, "name": ingredient_label(id)}))
.collect::<Vec<_>>();
Json(json!({
"signature": state.forge.signature(),
"ingredients": ingredients,
"initial_pantry": random_pantry()
}))
}
async fn generate_recipe(
State(state): State<Arc<AppState>>,
Json(payload): Json<GenerateRequest>,
) -> Json<ApiResponse> {
let Some(api_key) = &state.openai_api_key else {
return Json(ApiResponse {
ok: false,
markdown: String::new(),
html: String::new(),
diagnostics: vec![DiagnosticView {
code: "MissingOpenAIKey".to_string(),
message:
"OPENAI_API_KEY が設定されていません。Markdownの手入力プレビューは利用できます。"
.to_string(),
suggestion: Some(
".env に OPENAI_API_KEY=... を設定してデモを再起動してください。".to_string(),
),
}],
attempts: vec![],
});
};
match generate_with_feedback(&state, api_key, &payload.prompt, &payload.pantry).await {
Ok(response) => Json(response),
Err(err) => Json(ApiResponse {
ok: false,
markdown: String::new(),
html: String::new(),
diagnostics: vec![DiagnosticView {
code: "OpenAIRequestFailed".to_string(),
message: err,
suggestion: None,
}],
attempts: vec![],
}),
}
}
async fn render_recipe(
State(state): State<Arc<AppState>>,
Json(payload): Json<RenderRequest>,
) -> Json<ApiResponse> {
Json(render_markdown(
&state,
payload.markdown,
&payload.pantry,
false,
))
}
async fn repair_recipe(
State(state): State<Arc<AppState>>,
Json(payload): Json<RepairRequest>,
) -> Json<ApiResponse> {
let Some(api_key) = &state.openai_api_key else {
return Json(ApiResponse {
ok: false,
markdown: payload.markdown,
html: String::new(),
diagnostics: vec![DiagnosticView {
code: "MissingOpenAIKey".to_string(),
message: "AI修復には OPENAI_API_KEY が必要です。".to_string(),
suggestion: Some(
".env に OPENAI_API_KEY を設定して再起動してください。".to_string(),
),
}],
attempts: vec![],
});
};
let mut attempts = vec!["手動レンダーに失敗したため、AIに修正を依頼しました".to_string()];
let mut markdown = payload.markdown;
let mut diagnostics = payload.diagnostics;
for attempt in 1..=2 {
let fixed = match call_openai(
&state,
api_key,
recipe_repair_prompt(&state.forge),
repair_user_prompt(&payload.prompt, &markdown, &diagnostics, &payload.pantry),
1_100,
)
.await
{
Ok(fixed) => fixed,
Err(err) => {
return Json(ApiResponse {
ok: false,
markdown,
html: String::new(),
diagnostics: vec![DiagnosticView {
code: "OpenAIRepairFailed".to_string(),
message: err,
suggestion: None,
}],
attempts,
})
}
};
let mut response = render_markdown(&state, fixed.clone(), &payload.pantry, true);
attempts.push(format!(
"修正 {attempt}: {}",
if response.ok {
"mdforgeレシピとして有効".to_string()
} else {
response
.diagnostics
.iter()
.map(|diag| format!("{}: {}", diag.code, diag.message))
.collect::<Vec<_>>()
.join("; ")
}
));
if response.ok || attempt == 2 {
response.attempts = attempts;
return Json(response);
}
markdown = fixed;
diagnostics = response.diagnostics;
}
unreachable!("repair loop returns inside the bounded attempt loop")
}
async fn generate_with_feedback(
state: &AppState,
api_key: &str,
user_prompt: &str,
pantry: &[String],
) -> Result<ApiResponse, String> {
let mut attempts = Vec::new();
let mut markdown = call_openai(
state,
api_key,
recipe_system_prompt(&state.forge, pantry),
generation_user_prompt(user_prompt, pantry),
1_100,
)
.await?;
for attempt in 1..=3 {
let mut response = render_markdown(state, markdown.clone(), pantry, true);
attempts.push(format!(
"生成 {attempt}: {}",
if response.ok {
"mdforgeレシピとして有効".to_string()
} else {
response
.diagnostics
.iter()
.map(|diag| format!("{}: {}", diag.code, diag.message))
.collect::<Vec<_>>()
.join("; ")
}
));
if response.ok || attempt == 3 {
response.attempts = attempts;
return Ok(response);
}
markdown = call_openai(
state,
api_key,
recipe_repair_prompt(&state.forge),
repair_user_prompt(user_prompt, &markdown, &response.diagnostics, pantry),
1_100,
)
.await?;
}
unreachable!("feedback loop returns inside the bounded attempt loop")
}
async fn call_openai(
state: &AppState,
api_key: &str,
system_prompt: String,
user_prompt: String,
max_output_tokens: u16,
) -> Result<String, String> {
let response = reqwest::Client::new()
.post("https://api.openai.com/v1/responses")
.bearer_auth(api_key)
.json(&json!({
"model": state.openai_model,
"input": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
"max_output_tokens": max_output_tokens,
"store": false
}))
.send()
.await
.map_err(|err| err.to_string())?;
let status = response.status();
let body: Value = response.json().await.map_err(|err| err.to_string())?;
if !status.is_success() {
return Err(format!("OpenAI returned {status}: {body}"));
}
extract_response_text(&body).ok_or_else(|| format!("OpenAI response had no text: {body}"))
}
fn recipe_system_prompt(forge: &Forge, pantry: &[String]) -> String {
format!(
"あなたは Recipe Lab という料理アシスタントです。返答は会話文ではなく、raw mdforge レシピMarkdownだけにしてください。\n\n\
絶対ルール:\n\
- 日本語で返す。\n\
- Markdownコードフェンスは禁止。\n\
- 文書の前後に説明文を足さない。\n\
- 全体を必ず1つの root :::recipe ブロックで囲む。\n\
- すべての :::step と :::tip は ::: で閉じる。\n\
- root :::recipe も最後に追加の ::: で閉じる。\n\
- 最後の子要素が step/tip の場合、末尾の空でない2行は ::: と ::: になる。\n\
- 引数値に空白を入れない。必要なら underscore を使う。日本語の短い値はそのままでよい。\n\n\
使える mdforge 構文:\n{}\n\n\
家にある食材 refs:\n{}\n\n\
全食材 refs:\n{}\n\n\
構成ルール:\n\
- できるだけ「家にある食材 refs」だけで作る。\n\
- {{ingredient ...}} に使ってよい ref は「家にある食材 refs」だけ。\n\
- 家にある食材は {{ingredient name=表示名 ref=ref amount=分量}} でマークアップする。\n\
- 家に無いが必要な食材は必ず {{needed name=表示名 ref=ref amount=分量}} でマークアップする。\n\
- ingredient と needed には name/ref/amount の3つを必ず全部付ける。\n\
- ref=none, ref=なし, ref=other のような未定義refは禁止。必ず全食材 refs から選ぶ。\n\
- amount も必須。例: amount=1合, amount=2個, amount=大さじ1, amount=10g。\n\
- 調味料や油も、文中でマークアップするなら必ず amount を書く。\n\
- 家に無い食材を {{ingredient ...}} に入れると検証エラーになる。\n\
- needed は調達が必要な食材用。家にある食材には絶対に使わない。\n\
- 3〜6個の :::step を n=1,2,3... で作る。kind は prep/cook/serve。\n\
- すべての :::step には必ず minutes=<整数> を付ける。任意ではなく必須。\n\
- 少なくとも1つ :::tip を入れる。kind は swap/safety/timing。\n\
- timer インラインは補足に使ってよいが、各フェーズの分数は必ず step の minutes に入れる。\n\n\
形の例:\n\
:::recipe title=トマトごはん servings=2 difficulty=easy\n\
{{badge kind=quick}}\n\
:::step n=1 kind=prep minutes=4\n\
{{ingredient name=トマト ref=tomato amount=1個}}を切ります。{{ingredient name=醤油 ref=soy_sauce amount=小さじ1}}を用意します。\n\
:::\n\
:::tip kind=timing\n\
火加減は中火にします。\n\
:::\n\
:::",
forge.signature(),
pantry.join(", "),
recipe_ingredients().join(", ")
)
}
fn recipe_repair_prompt(forge: &Forge) -> String {
format!(
"あなたは壊れた mdforge レシピMarkdownを修正します。返答は修正済みの raw mdforge 文書だけにしてください。\n\
修正説明やコードフェンスは禁止です。内容はできるだけ維持し、診断エラーをすべて直してください。\n\
特に閉じ忘れの :::、未定義の引数値、家にある食材/調達食材のマークアップに注意してください。\n\
ingredient/needed は必ず name/ref/amount をすべて持ち、ref は全食材 refs から選んでください。\n\
有効な構文:\n{}\n\
全食材 refs: {}.",
forge.signature(),
recipe_ingredients().join(", ")
)
}
fn generation_user_prompt(user_prompt: &str, pantry: &[String]) -> String {
format!(
"ユーザーの要望:\n{user_prompt}\n\n家にある食材 refs:\n{}\n\nこの食材セットを主役にして、足りない食材は needed で明示してください。",
pantry.join(", ")
)
}
fn repair_user_prompt(
user_prompt: &str,
markdown: &str,
diagnostics: &[DiagnosticView],
pantry: &[String],
) -> String {
let diagnostics = diagnostics
.iter()
.map(|diag| {
format!(
"- {}: {}{}",
diag.code,
diag.message,
diag.suggestion
.as_ref()
.map(|suggestion| format!(" ({suggestion})"))
.unwrap_or_default()
)
})
.collect::<Vec<_>>()
.join("\n");
format!(
"元のユーザー要望:\n{user_prompt}\n\n家にある食材 refs:\n{}\n\n壊れた mdforge Markdown:\n{markdown}\n\n修正すべき診断:\n{diagnostics}",
pantry.join(", ")
)
}
fn extract_response_text(response: &Value) -> Option<String> {
response
.get("output")
.and_then(Value::as_array)?
.iter()
.flat_map(|item| {
item.get("content")
.and_then(Value::as_array)
.into_iter()
.flatten()
})
.find_map(|content| {
content
.get("text")
.and_then(Value::as_str)
.map(str::to_string)
})
}
fn render_markdown(
state: &AppState,
markdown: String,
pantry: &[String],
enforce_pantry: bool,
) -> ApiResponse {
let doc = match state.forge.parse(markdown.trim_end()) {
Ok(doc) => doc,
Err(diagnostics) => return response_with_diagnostics(markdown, diagnostics),
};
if let Err(diagnostics) = state.forge.validate(&doc) {
return response_with_diagnostics(markdown, diagnostics);
}
if let Err(diagnostics) = state.forge.eval(&doc, &state.eval_ctx) {
return response_with_diagnostics(markdown, diagnostics);
}
if enforce_pantry {
let invalid_home_ingredients = pantry_mismatches(&doc, pantry);
if !invalid_home_ingredients.is_empty() {
return response_with_views(
markdown,
invalid_home_ingredients
.into_iter()
.map(|id| DiagnosticView {
code: "PantryIngredientNotAvailable".to_string(),
message: format!(
"'{}' は今回の冷蔵庫にありません。家にない食材は needed でマークしてください。",
ingredient_label(&id)
),
suggestion: Some(format!(
"{{needed name={} ref={} amount=必要量}} を使って調達が必要な食材として示してください。",
ingredient_label(&id),
id
)),
})
.collect(),
);
}
}
match state
.forge
.render_html(&doc, &state.eval_ctx, &RecipeHtmlRenderer)
{
Ok(html) => {
let summary = render_used_ingredients(&doc, pantry);
ApiResponse {
ok: true,
markdown,
html: format!("{summary}{html}"),
diagnostics: vec![],
attempts: vec![],
}
}
Err(diagnostics) => response_with_diagnostics(markdown, diagnostics),
}
}
fn response_with_views(markdown: String, diagnostics: Vec<DiagnosticView>) -> ApiResponse {
ApiResponse {
ok: false,
markdown,
html: String::new(),
diagnostics,
attempts: vec![],
}
}
fn response_with_diagnostics(markdown: String, diagnostics: Vec<Diagnostic>) -> ApiResponse {
ApiResponse {
ok: false,
markdown,
html: String::new(),
diagnostics: diagnostics
.into_iter()
.map(|diag| DiagnosticView {
code: format!("{:?}", diag.code),
message: diag.message,
suggestion: diag.suggestion,
})
.collect(),
attempts: vec![],
}
}
fn render_used_ingredients(doc: &mdforge::Document, pantry: &[String]) -> String {
let mut home = Vec::new();
let mut needed = Vec::new();
collect_used_ingredients(&doc.nodes, &mut home, &mut needed);
home.sort();
home.dedup_by(|a, b| a.id == b.id && a.amount == b.amount);
needed.sort();
needed.dedup_by(|a, b| a.id == b.id && a.amount == b.amount);
let pantry_set = pantry.iter().cloned().collect::<HashSet<_>>();
let home_items = ingredient_chips(&home);
let needed_items = ingredient_chips(&needed);
let pantry_items = pantry_chips(pantry);
let outside_home = home
.iter()
.filter(|item| !pantry_set.contains(&item.id))
.map(|item| ingredient_label(&item.id))
.collect::<Vec<_>>()
.join("</span><span>");
let mut warning = String::new();
if !outside_home.is_empty() {
warning = format!(
"<p class=\"summary-warning\">家にある食材としてマークされていますが、今回の冷蔵庫リスト外です: <span>{outside_home}</span></p>"
);
}
format!(
"<section class=\"used-summary\"><p class=\"eyebrow\">今回の食材</p>\
<div><strong>家にあるもの</strong><span>{home_items}</span></div>\
<div><strong>調達が必要</strong><span>{needed_items}</span></div>\
<details><summary>今回の冷蔵庫</summary><span>{pantry_items}</span></details>{warning}</section>"
)
}
#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd)]
struct UsedIngredient {
id: String,
amount: String,
}
fn ingredient_chips(items: &[UsedIngredient]) -> String {
if items.is_empty() {
return "<span>なし</span>".to_string();
}
format!(
"<span>{}</span>",
items
.iter()
.map(|item| format!(
"{} {}",
ingredient_label(&item.id),
item.amount.replace('_', " ")
))
.collect::<Vec<_>>()
.join("</span><span>")
)
}
fn pantry_chips(ids: &[String]) -> String {
if ids.is_empty() {
return "<span>なし</span>".to_string();
}
format!(
"<span>{}</span>",
ids.iter()
.map(|id| ingredient_label(id))
.collect::<Vec<_>>()
.join("</span><span>")
)
}
fn collect_used_ingredients(
nodes: &[mdforge::Node],
home: &mut Vec<UsedIngredient>,
needed: &mut Vec<UsedIngredient>,
) {
for node in nodes {
match node {
mdforge::Node::Markdown(events) => {
for event in events {
let mdforge::MdEvent::Text(text) = event;
for inline in scan_inline_exts(text) {
let target = match inline.name.as_str() {
"ingredient" => Some(&mut *home),
"needed" => Some(&mut *needed),
_ => None,
};
if let Some(target) = target {
if let Some(ArgValue::String(reference)) = inline.args.get("ref") {
let amount = match inline.args.get("amount") {
Some(ArgValue::String(amount)) => amount.clone(),
Some(ArgValue::Int(amount)) => amount.to_string(),
None => "?".to_string(),
};
target.push(UsedIngredient {
id: reference.clone(),
amount,
});
}
}
}
}
}
mdforge::Node::Block(block) => collect_used_ingredients(&block.body, home, needed),
}
}
}
fn pantry_mismatches(doc: &mdforge::Document, pantry: &[String]) -> Vec<String> {
let mut home = Vec::new();
let mut needed = Vec::new();
collect_used_ingredients(&doc.nodes, &mut home, &mut needed);
let pantry_set = pantry.iter().cloned().collect::<HashSet<_>>();
home.into_iter()
.filter(|item| !pantry_set.contains(&item.id))
.map(|item| item.id)
.collect::<HashSet<_>>()
.into_iter()
.collect()
}
fn scan_inline_exts(text: &str) -> Vec<InlineExt> {
let mut out = Vec::new();
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'{' {
i += 1;
continue;
}
let start = i;
let mut j = i + 1;
while j < bytes.len() && bytes[j] != b'}' {
j += 1;
}
if j >= bytes.len() {
break;
}
let content = &text[i + 1..j];
let parts = content.split_whitespace().collect::<Vec<_>>();
if !parts.is_empty() {
let mut args = HashMap::new();
for token in &parts[1..] {
if let Some((key, value)) = token.split_once('=') {
let value = value
.parse::<i64>()
.map(ArgValue::Int)
.unwrap_or_else(|_| ArgValue::String(value.to_string()));
args.insert(key.to_string(), value);
}
}
out.push(InlineExt {
name: parts[0].to_string(),
args,
span: mdforge::Span { start, end: j + 1 },
});
}
i = j + 1;
}
out
}
fn recipe_forge() -> Forge {
Forge::builder()
.block("recipe")
.arg("title", ArgType::String.required())
.arg("servings", ArgType::Int.required())
.arg(
"difficulty",
ArgType::StaticEnum(&["easy", "medium", "hard"]).required(),
)
.body_markdown()
.register()
.block("step")
.arg("n", ArgType::Int.required())
.arg(
"kind",
ArgType::StaticEnum(&["prep", "cook", "serve"]).required(),
)
.arg("minutes", ArgType::Int.required())
.body_markdown()
.register()
.block("tip")
.arg(
"kind",
ArgType::StaticEnum(&["swap", "safety", "timing"]).required(),
)
.body_markdown()
.register()
.inline("ingredient")
.arg("name", ArgType::String.required())
.arg("ref", ArgType::DynamicEnum("ingredients").required())
.arg("amount", ArgType::String.required())
.register()
.inline("needed")
.arg("name", ArgType::String.required())
.arg("ref", ArgType::DynamicEnum("ingredients").required())
.arg("amount", ArgType::String.required())
.register()
.inline("timer")
.arg("minutes", ArgType::Int.required())
.register()
.inline("badge")
.arg(
"kind",
ArgType::StaticEnum(&["quick", "veggie", "spicy", "make_ahead"]).required(),
)
.register()
.build()
}
fn recipe_eval_ctx() -> EvalContext {
let mut dynamic_values = HashMap::new();
dynamic_values.insert(
"ingredients".to_string(),
recipe_ingredients()
.into_iter()
.map(str::to_string)
.collect::<HashSet<_>>(),
);
EvalContext { dynamic_values }
}
fn recipe_ingredients() -> Vec<&'static str> {
vec![
"egg",
"rice",
"chicken",
"salmon",
"tofu",
"tomato",
"onion",
"garlic",
"butter",
"olive_oil",
"soy_sauce",
"miso",
"pasta",
"cheese",
"basil",
"potato",
"carrot",
"mushroom",
"cabbage",
"spinach",
"green_onion",
"ginger",
"milk",
"flour",
"bread",
"tuna",
"natto",
"sesame_oil",
"daikon",
"pork",
]
}
fn random_pantry() -> Vec<String> {
let mut items = recipe_ingredients()
.into_iter()
.filter(|id| !staple_ingredients().contains(id))
.map(str::to_string)
.collect::<Vec<_>>();
let mut seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos() as u64)
.unwrap_or(0x5eed);
for i in (1..items.len()).rev() {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
items.swap(i, (seed as usize) % (i + 1));
}
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
let count = 5 + (seed as usize % 6);
items.truncate(count);
let mut pantry = staple_ingredients()
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>();
pantry.extend(items);
pantry.sort();
pantry.dedup();
pantry
}
fn staple_ingredients() -> Vec<&'static str> {
vec![
"soy_sauce",
"miso",
"olive_oil",
"sesame_oil",
"butter",
"garlic",
"ginger",
]
}
fn ingredient_label(id: &str) -> String {
match id {
"egg" => "卵",
"rice" => "ごはん",
"chicken" => "鶏肉",
"salmon" => "鮭",
"tofu" => "豆腐",
"tomato" => "トマト",
"onion" => "玉ねぎ",
"garlic" => "にんにく",
"butter" => "バター",
"olive_oil" => "オリーブオイル",
"soy_sauce" => "醤油",
"miso" => "味噌",
"pasta" => "パスタ",
"cheese" => "チーズ",
"basil" => "バジル",
"potato" => "じゃがいも",
"carrot" => "にんじん",
"mushroom" => "きのこ",
"cabbage" => "キャベツ",
"spinach" => "ほうれん草",
"green_onion" => "ねぎ",
"ginger" => "しょうが",
"milk" => "牛乳",
"flour" => "小麦粉",
"bread" => "パン",
"tuna" => "ツナ",
"natto" => "納豆",
"sesame_oil" => "ごま油",
"daikon" => "大根",
"pork" => "豚肉",
other => other,
}
.to_string()
}
struct RecipeHtmlRenderer;
impl HtmlRenderer for RecipeHtmlRenderer {
fn render_block(&self, block: &BlockNode, _ctx: &EvalContext, children_html: String) -> String {
match block.name.as_str() {
"recipe" => {
let title = display_arg(block, "title");
let servings = int_arg(block, "servings").unwrap_or_default();
let difficulty = display_arg(block, "difficulty");
format!(
"<article class=\"recipe-card\">\
<header><p class=\"eyebrow\">レシピ</p><h2>{}</h2>\
<div class=\"meta\"><span>{}人分</span><span>{}</span></div></header>\
<div class=\"recipe-body\">{}</div></article>",
escape_html(&title),
servings,
escape_html(&difficulty_label(&difficulty)),
children_html
)
}
"step" => {
let n = int_arg(block, "n").unwrap_or_default();
let kind = display_arg(block, "kind");
let minutes = int_arg(block, "minutes").unwrap_or_default();
format!(
"<section class=\"step step-{}\"><div class=\"step-number\">{}</div>\
<div class=\"step-copy\"><p class=\"step-kind\">{}<span class=\"phase-time\">{}分</span></p>{}</div></section>",
escape_attr(&kind),
n,
escape_html(&step_kind_label(&kind)),
minutes,
children_html
)
}
"tip" => {
let kind = display_arg(block, "kind");
format!(
"<aside class=\"tip tip-{}\"><strong>{}</strong>{}</aside>",
escape_attr(&kind),
escape_html(&tip_kind_label(&kind)),
children_html
)
}
_ => children_html,
}
}
fn render_inline(&self, inline: &InlineExt, _ctx: &EvalContext) -> String {
match inline.name.as_str() {
"ingredient" => {
let name = display_inline_arg(inline, "name");
let reference = display_inline_arg(inline, "ref");
let amount = display_inline_arg(inline, "amount");
format!(
"<span class=\"ingredient\" title=\"家にある食材: {} / {}\">{}<small>{}</small></span>",
escape_attr(&reference),
escape_attr(&amount),
escape_html(&name),
escape_html(&amount)
)
}
"needed" => {
let name = display_inline_arg(inline, "name");
let reference = display_inline_arg(inline, "ref");
let amount = display_inline_arg(inline, "amount");
format!(
"<span class=\"needed\" title=\"調達が必要: {} / {}\">買う: {}<small>{}</small></span>",
escape_attr(&reference),
escape_attr(&amount),
escape_html(&name),
escape_html(&amount)
)
}
"timer" => {
let minutes = int_inline_arg(inline, "minutes").unwrap_or_default();
format!("<span class=\"timer\">{} min</span>", minutes)
}
"badge" => {
let kind = display_inline_arg(inline, "kind");
format!(
"<span class=\"badge badge-{}\">{}</span>",
escape_attr(&kind),
escape_html(&badge_label(&kind))
)
}
_ => String::new(),
}
}
}
fn display_arg(block: &BlockNode, name: &str) -> String {
match block.args.get(name) {
Some(ArgValue::String(value)) => value.replace('_', " "),
Some(ArgValue::Int(value)) => value.to_string(),
None => String::new(),
}
}
fn int_arg(block: &BlockNode, name: &str) -> Option<i64> {
match block.args.get(name) {
Some(ArgValue::Int(value)) => Some(*value),
_ => None,
}
}
fn display_inline_arg(inline: &InlineExt, name: &str) -> String {
match inline.args.get(name) {
Some(ArgValue::String(value)) => value.replace('_', " "),
Some(ArgValue::Int(value)) => value.to_string(),
None => String::new(),
}
}
fn int_inline_arg(inline: &InlineExt, name: &str) -> Option<i64> {
match inline.args.get(name) {
Some(ArgValue::Int(value)) => Some(*value),
_ => None,
}
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn escape_attr(value: &str) -> String {
escape_html(value).replace('\'', "'")
}
fn difficulty_label(value: &str) -> String {
match value {
"easy" => "かんたん",
"medium" => "ふつう",
"hard" => "本格派",
other => other,
}
.to_string()
}
fn step_kind_label(value: &str) -> String {
match value {
"prep" => "下ごしらえ",
"cook" => "調理",
"serve" => "仕上げ",
other => other,
}
.to_string()
}
fn tip_kind_label(value: &str) -> String {
match value {
"swap" => "代用",
"safety" => "安全メモ",
"timing" => "タイミング",
other => other,
}
.to_string()
}
fn badge_label(value: &str) -> String {
match value {
"quick" => "時短",
"veggie" => "野菜多め",
"spicy" => "ピリ辛",
"make_ahead" => "作り置き",
other => other,
}
.to_string()
}
const INDEX_HTML: &str = r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Recipe Lab | mdforge デモ</title>
<style>
:root {
--ink: #22190f;
--muted: #7c6753;
--paper: #fff8ec;
--card: #fffdf6;
--tomato: #e34f35;
--basil: #2e7d4f;
--gold: #e3a229;
--line: #eadac3;
--shadow: 0 24px 70px rgba(87, 54, 23, .18);
}
* { box-sizing: border-box; }
body {
margin: 0;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(227, 79, 53, .22), transparent 32rem),
radial-gradient(circle at 80% 20%, rgba(46, 125, 79, .18), transparent 28rem),
linear-gradient(135deg, #fff8ec, #f7ead8 55%, #fff3df);
font-family: ui-serif, Georgia, Cambria, "Times New Roman", serif;
}
main { width: min(1180px, calc(100vw - 32px)); margin: 0 auto; padding: 38px 0 56px; }
.hero {
display: grid;
gap: 16px;
margin-bottom: 24px;
padding: 30px;
border: 1px solid rgba(124, 103, 83, .22);
border-radius: 30px;
background: rgba(255, 253, 246, .72);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
}
h1 { margin: 0; font-size: clamp(2.4rem, 6vw, 5.8rem); line-height: .86; letter-spacing: -.07em; }
.hero p { max-width: 760px; margin: 0; color: var(--muted); font-size: 1.12rem; line-height: 1.7; }
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, .95fr); gap: 22px; }
.pantry {
margin-bottom: 22px;
padding: 20px;
border: 1px solid rgba(124, 103, 83, .24);
border-radius: 28px;
background: rgba(255, 253, 246, .82);
box-shadow: 0 18px 50px rgba(87, 54, 23, .12);
}
.pantry-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 14px; }
.pantry-head h2 { margin: 0; font-size: 1.2rem; }
.chips { display: flex; flex-wrap: wrap; gap: 9px; }
.chip, .used-summary span {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 7px 11px;
background: #f4e3c9;
font: 800 .86rem/1 sans-serif;
}
.chip.staple { background: #e8f3dc; color: #365923; }
.panel {
border: 1px solid rgba(124, 103, 83, .24);
border-radius: 28px;
background: rgba(255, 253, 246, .82);
box-shadow: 0 18px 50px rgba(87, 54, 23, .12);
overflow: hidden;
}
.panel-head { padding: 18px 20px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; gap: 14px; }
.panel-head h2 { margin: 0; font-size: 1rem; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); }
.panel-body { padding: 18px; }
input {
width: 100%;
border: 1px solid var(--line);
border-radius: 18px;
background: #fffaf1;
color: var(--ink);
font: 15px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
padding: 14px;
outline: none;
}
input:focus { border-color: rgba(227, 79, 53, .65); box-shadow: 0 0 0 4px rgba(227, 79, 53, .12); }
.prompt-row { display: grid; grid-template-columns: 1fr auto; gap: 10px; margin-bottom: 14px; }
button {
border: 0;
border-radius: 999px;
padding: 12px 18px;
color: #fff8ec;
background: var(--ink);
font-weight: 800;
cursor: pointer;
box-shadow: 0 12px 26px rgba(34, 25, 15, .18);
}
button.secondary { color: var(--ink); background: #f0ddbf; }
button:disabled { opacity: .55; cursor: progress; }
.preview {
min-height: 560px;
padding: 18px;
background: linear-gradient(180deg, #fffdf6, #fff6e5);
}
.recipe-card {
border: 1px solid var(--line);
border-radius: 28px;
padding: 24px;
background: var(--card);
box-shadow: 0 16px 36px rgba(87, 54, 23, .12);
}
.recipe-card h2 { margin: 0; font-size: 2.1rem; letter-spacing: -.04em; }
.eyebrow, .step-kind { margin: 0 0 6px; color: var(--tomato); font: 800 .74rem/1 sans-serif; text-transform: uppercase; letter-spacing: .16em; }
.meta { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.meta span, .badge, .timer {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 6px 10px;
background: #f4e3c9;
font: 800 .8rem/1 sans-serif;
}
.recipe-body { margin-top: 22px; display: grid; gap: 14px; }
.step {
display: grid;
grid-template-columns: 52px 1fr;
gap: 14px;
padding: 16px;
border-radius: 22px;
background: #fff8ec;
border: 1px solid var(--line);
}
.step-number {
width: 52px;
height: 52px;
border-radius: 18px;
display: grid;
place-items: center;
color: #fff8ec;
background: var(--tomato);
font: 900 1.3rem/1 sans-serif;
}
.step-copy p { margin-top: 0; }
.phase-time {
margin-left: 8px;
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 4px 8px;
background: rgba(227, 162, 41, .22);
color: #7a4b00;
font: 900 .75rem/1 sans-serif;
letter-spacing: 0;
}
.ingredient, .needed {
position: relative;
display: inline-block;
padding: 1px 6px 2px;
border-radius: 8px;
font-weight: 800;
cursor: help;
}
.ingredient {
background: rgba(46, 125, 79, .12);
color: #1f6840;
}
.needed {
background: rgba(227, 79, 53, .14);
color: #a42f1f;
border: 1px dashed rgba(227, 79, 53, .55);
}
.ingredient small, .needed small {
margin-left: 4px;
opacity: .78;
font-size: .78em;
}
.timer { background: rgba(227, 162, 41, .22); color: #7a4b00; }
.badge { margin-right: 6px; background: rgba(227, 79, 53, .14); color: var(--tomato); }
.tip {
border-left: 5px solid var(--gold);
border-radius: 18px;
padding: 14px 16px;
background: #fff2d4;
}
.tip strong { margin-right: 8px; text-transform: uppercase; font: 900 .78rem/1 sans-serif; letter-spacing: .12em; }
#diagnostics { margin-top: 12px; display: grid; gap: 8px; }
.diag { border-radius: 14px; padding: 10px 12px; background: #ffe1dc; color: #8f2417; font: 14px/1.45 sans-serif; }
#attempts { margin-top: 12px; display: grid; gap: 6px; }
.attempt { border-radius: 14px; padding: 9px 11px; background: #e8f3dc; color: #365923; font: 13px/1.45 sans-serif; }
.used-summary {
margin-bottom: 14px;
padding: 18px;
border: 1px solid var(--line);
border-radius: 24px;
background: rgba(255, 253, 246, .9);
display: grid;
gap: 10px;
}
.used-summary div, .used-summary details { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.used-summary strong { min-width: 88px; font: 900 .82rem/1 sans-serif; color: var(--muted); }
.used-summary details { color: var(--muted); }
.summary-warning { margin: 0; color: #a42f1f; font: 13px/1.5 sans-serif; }
.signature, .generated-md { white-space: pre-wrap; color: var(--muted); font: 12px/1.45 ui-monospace, monospace; }
.generated-md {
margin: 0;
max-height: 420px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 18px;
background: #fffaf1;
padding: 14px;
}
@media (max-width: 900px) {
.workspace { grid-template-columns: 1fr; }
.prompt-row { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main>
<section class="hero">
<h1>Recipe Lab</h1>
<p>家にある食材だけで作れるレシピをAIに考えてもらい、mdforgeで構造と食材参照を検証します。足りない食材は「調達が必要」として特別にマークされます。</p>
</section>
<section class="pantry">
<div class="pantry-head">
<div>
<p class="eyebrow">今日の冷蔵庫</p>
<h2>常備調味料 + この食材の中から作ります</h2>
</div>
<button class="secondary" id="shuffle-pantry">食材をシャッフル</button>
</div>
<div id="pantry-list" class="chips"></div>
</section>
<section class="workspace">
<div class="panel">
<div class="panel-head"><h2>チャット</h2></div>
<div class="panel-body">
<div class="prompt-row">
<input id="prompt" placeholder="例: 家にあるもので、15分くらいで作れる美味しいレシピを教えて">
<button id="generate">レシピ作成</button>
</div>
<div id="attempts"></div>
<div id="diagnostics"></div>
</div>
</div>
<div class="panel">
<div class="panel-head"><h2>HTMLプレビュー</h2></div>
<div id="preview" class="preview"></div>
</div>
</section>
<section class="panel" style="margin-top:22px">
<div class="panel-head"><h2>AIに渡す構文仕様</h2></div>
<div class="panel-body">
<div id="signature" class="signature"></div>
<h3 style="margin:18px 0 10px">生成された拡張Markdown</h3>
<pre id="generated-md" class="generated-md"></pre>
</div>
</section>
</main>
<script>
const promptInput = document.querySelector('#prompt');
const preview = document.querySelector('#preview');
const diagnostics = document.querySelector('#diagnostics');
const attempts = document.querySelector('#attempts');
const signature = document.querySelector('#signature');
const generatedMd = document.querySelector('#generated-md');
const pantryList = document.querySelector('#pantry-list');
const generateButton = document.querySelector('#generate');
const shuffleButton = document.querySelector('#shuffle-pantry');
let lastDiagnostics = [];
let currentMarkdown = '';
let ingredientCatalog = [];
let currentPantry = [];
const stapleIngredients = ['soy_sauce', 'miso', 'olive_oil', 'sesame_oil', 'butter', 'garlic', 'ginger'];
preview.innerHTML = '<p style="color:#7c6753">お願いを入力して「レシピ作成」を押すと、ここにレシピが表示されます。</p>';
generatedMd.textContent = 'まだ生成されていません。';
function labelFor(id) {
return ingredientCatalog.find(item => item.id === id)?.name || id;
}
function shuffle(values) {
const copy = [...values];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
function pickPantry() {
const count = 5 + Math.floor(Math.random() * 6);
const staples = stapleIngredients.filter(id => ingredientCatalog.some(item => item.id === id));
const randomItems = shuffle(
ingredientCatalog
.map(item => item.id)
.filter(id => !staples.includes(id))
).slice(0, count);
currentPantry = [...new Set([...staples, ...randomItems])];
renderPantry();
}
function renderPantry() {
pantryList.innerHTML = '';
for (const id of currentPantry) {
const el = document.createElement('span');
el.className = stapleIngredients.includes(id) ? 'chip staple' : 'chip';
el.textContent = labelFor(id);
pantryList.appendChild(el);
}
}
async function api(path, body) {
const response = await fetch(path, {
method: 'POST',
headers: {'content-type': 'application/json'},
body: JSON.stringify(body)
});
return response.json();
}
function showResult(result) {
if (result.markdown) currentMarkdown = result.markdown.trim();
generatedMd.textContent = currentMarkdown || 'まだ生成されていません。';
preview.innerHTML = result.html || '<p style="color:#7c6753">まだプレビューはありません。</p>';
lastDiagnostics = result.diagnostics || [];
diagnostics.innerHTML = '';
attempts.innerHTML = '';
for (const attempt of result.attempts || []) {
const el = document.createElement('div');
el.className = 'attempt';
el.textContent = `AIフィードバック: ${attempt}`;
attempts.appendChild(el);
}
for (const diag of result.diagnostics || []) {
const el = document.createElement('div');
el.className = 'diag';
el.textContent = `${diag.code}: ${diag.message}${diag.suggestion ? ' (' + diag.suggestion + ')' : ''}`;
diagnostics.appendChild(el);
}
}
generateButton.addEventListener('click', async () => {
const prompt = promptInput.value.trim();
if (!prompt) {
diagnostics.innerHTML = '<div class="diag">お願いを入力してください。</div>';
return;
}
generateButton.disabled = true;
try {
showResult(await api('/api/generate', {prompt, pantry: currentPantry}));
} finally {
generateButton.disabled = false;
}
});
shuffleButton.addEventListener('click', pickPantry);
fetch('/api/signature').then(r => r.json()).then(data => {
ingredientCatalog = data.ingredients;
currentPantry = data.initial_pantry;
renderPantry();
signature.textContent = `${data.signature}\n\n食材refs:\n${data.ingredients.map(item => `${item.id}=${item.name}`).join(', ')}`;
});
</script>
</body>
</html>"#;