$i['item_type'] === 'collection'); if ($nested) { $lines[] = "collections:"; foreach ($nested as $item) { $lines[] = " - " . $item['item_slug']; } } return implode("\n", $lines) . "\n"; } function yaml_quote(string $s): string { if ($s === '') return '""'; if (preg_match('/[:#{}&*!|>"\'\[\]]/', $s) || preg_match('/^\s/', $s)) { $s = str_replace('"', '\\"', $s); return '"' . $s . '"'; } return $s; } function yaml_multiline(string $s, int $indent = 0): string { if ($s === '') return '""'; $pad = str_repeat(' ', $indent); // Use folded scalar for readability $lines = explode("\n", trim($s)); if (count($lines) <= 1 && strlen($s) < 60) { return yaml_quote($s); } $out = ">-"; foreach ($lines as $line) { $out .= "\n" . $pad . " " . $line; } return $out; } function yaml_array(array $arr): string { if (empty($arr)) return '[]'; return '[' . implode(', ', array_map('yaml_quote', $arr)) . ']'; } /** * Parse a YAML protocol file into a database-ready array. * Uses yaml_parse if available, otherwise a simple parser for the protocol format. */ function parse_protocol_yaml(string $yaml_text): ?array { if (function_exists('yaml_parse')) { // Harden against PHP object injection via !php/object and !php/const tags. // The callbacks must be valid callables; null them out to neutralize deserialization. $neutralize = fn() => null; $data = yaml_parse($yaml_text, 0, $ndocs, [ '!php/object' => $neutralize, '!php/const' => $neutralize, ]); if (!is_array($data)) return null; } else { $data = simple_yaml_parse($yaml_text); if (!is_array($data)) return null; } $steps = []; foreach (($data['steps'] ?? []) as $step) { $steps[] = [ 'headline' => $step['headline'] ?? '', 'description' => $step['description'] ?? '', ]; } return [ 'slug' => $data['id'] ?? slugify($data['title'] ?? 'untitled'), 'title' => $data['title'] ?? '', 'description' => $data['description'] ?? '', 'source' => $data['source'] ?? '', 'source_url' => $data['source_url'] ?? '', 'tags' => json_encode($data['tags'] ?? []), 'forked_from' => $data['forked_from'] ?? null, 'image' => $data['image'] ?? null, 'steps' => json_encode($steps), 'outcome' => $data['outcome'] ?? '', 'practice' => $data['practice'] ?? '', ]; } /** * Minimal YAML parser for simple protocol files. * Handles: top-level key: value, nested lists with - headline:, folded scalars (>-) * This is NOT a full YAML parser — it handles the protocol format only. */ function simple_yaml_parse(string $text): ?array { $result = []; $lines = explode("\n", $text); $current_step_idx = -1; // State for folded scalar tracking $in_folded = false; $folded_target = null; // 'top' for top-level key, 'step' for step description $folded_top_key = null; $folded_buffer = ''; $folded_indent = 0; $flush_folded = function() use (&$result, &$in_folded, &$folded_target, &$folded_top_key, &$folded_buffer, &$current_step_idx) { if ($in_folded) { $val = trim($folded_buffer); if ($folded_target === 'top' && $folded_top_key !== null) { $result[$folded_top_key] = $val; } elseif ($folded_target === 'step' && $current_step_idx >= 0) { $result['steps'][$current_step_idx]['description'] = $val; } $in_folded = false; $folded_target = null; $folded_top_key = null; $folded_buffer = ''; } }; foreach ($lines as $line) { // Skip comments if (preg_match('/^\s*#/', $line)) continue; // Folded scalar continuation — indented lines that are more indented than the key if ($in_folded) { $trimmed = trim($line); if ($trimmed === '') { continue; } // blank lines in folded scalars become spaces // Check if this line is indented more than the key (continuation of folded scalar) $line_indent = strlen($line) - strlen(ltrim($line)); if ($line_indent > $folded_indent) { $folded_buffer .= ' ' . $trimmed; continue; } else { $flush_folded(); // fall through to process this line normally } } // Skip empty lines (not inside folded) if (trim($line) === '') continue; // Step headline: " - headline: ..." if (preg_match('/^(\s+)-\s+headline:\s*(.*)$/', $line, $m)) { $flush_folded(); $current_step_idx++; $result['steps'][] = ['headline' => trim($m[2], '"'), 'description' => '']; continue; } // Step description: " description: ..." if (preg_match('/^(\s+)description:\s*(.*)$/', $line, $m) && $current_step_idx >= 0) { $val = trim($m[2], '"'); if ($val === '>-' || $val === '>+' || $val === '|' || $val === '|-' || $val === '|+') { // Start folded scalar for step description $in_folded = true; $folded_target = 'step'; $folded_buffer = ''; $folded_indent = strlen($m[1]); } else { $result['steps'][$current_step_idx]['description'] = $val; } continue; } // Top-level key: value if (preg_match('/^([a-z_]+):\s*(.*)$/', $line, $m)) { $flush_folded(); $key = $m[1]; $val = trim($m[2]); if ($val === '') { // Could be "steps:" (empty value, starts a list) or just empty if ($key === 'steps') { $result['steps'] = []; $current_step_idx = -1; } continue; } // Check for folded scalar indicators if ($val === '>-' || $val === '>+' || $val === '|' || $val === '|-' || $val === '|+') { $in_folded = true; $folded_target = 'top'; $folded_top_key = $key; $folded_buffer = ''; $folded_indent = 0; // top-level keys have indent 0 continue; } // Parse inline array [a, b, c] if (str_starts_with($val, '[') && str_ends_with($val, ']')) { $inner = substr($val, 1, -1); $result[$key] = array_map(fn($s) => trim($s, ' "'), array_filter(explode(',', $inner), fn($s) => trim($s) !== '')); continue; } // null literal if ($val === 'null' || $val === '~') { $result[$key] = null; continue; } $result[$key] = trim($val, '"'); continue; } } $flush_folded(); return $result; }