#!/usr/bin/env python3 """ Syncs content from the hindsight-cookbook repository. - Clones the cookbook repo to a temp directory - Converts notebooks/*.ipynb → docs/cookbook/recipes/*.md - Converts applications/*/ directories (with README.md) → docs/cookbook/applications/*.md - Updates sidebars.ts with the new entries Usage: sync-cookbook (after installing hindsight-dev) Conventions in cookbook repo: - notebooks/*.ipynb → Recipes (use cases, tutorials) - applications/*/ directories with README.md → Applications (complete apps) - Notebook title extracted from first # heading in first markdown cell - App title extracted from first # heading in README.md """ import json import os import re import shutil import subprocess import tempfile from pathlib import Path COOKBOOK_REPO = "https://github.com/vectorize-io/hindsight-cookbook.git" IGNORE_DIRS = {".git", "notebooks", "node_modules", "__pycache__", ".venv", "venv"} def get_docs_dir() -> Path: """Find the hindsight-docs directory relative to this script.""" # Navigate from hindsight-dev to hindsight-docs script_dir = Path(__file__).parent docs_dir = script_dir.parent.parent / "hindsight-docs" / "docs" / "cookbook" return docs_dir def get_sidebars_file() -> Path: script_dir = Path(__file__).parent return script_dir.parent.parent / "hindsight-docs" / "sidebars.ts" def slugify(filename: str) -> str: """Convert filename to slug. e.g., '01-quickstart.ipynb' → 'quickstart'""" slug = re.sub(r"\.ipynb$", "", filename) slug = re.sub(r"\.md$", "", slug) slug = re.sub(r"^\d+-", "", slug) return slug def extract_title_from_notebook(notebook_path: Path) -> str: """Extract title from first markdown cell's # heading.""" try: content = json.loads(notebook_path.read_text()) for cell in content.get("cells", []): if cell.get("cell_type") == "markdown": source = cell.get("source", []) if isinstance(source, list): source = "".join(source) match = re.search(r"^#\s+(.+)$", source, re.MULTILINE) if match: return match.group(1).strip() except Exception as e: print(f" Warning: Could not parse notebook {notebook_path}: {e}") # Fallback to filename slug = slugify(notebook_path.name) return " ".join(word.capitalize() for word in slug.split("-")) def extract_description_from_notebook(notebook_path: Path) -> str | None: """Extract description from notebook metadata.""" try: content = json.loads(notebook_path.read_text()) metadata = content.get("metadata", {}) description = metadata.get("description", "") if description: return description[:200] except Exception: pass return None def extract_tags_from_notebook(notebook_path: Path) -> list[str]: """Extract tags from notebook metadata. Supports both array format and structured object format. """ try: content = json.loads(notebook_path.read_text()) metadata = content.get("metadata", {}) tags = metadata.get("tags", []) # Array format: ["Python", "Client"] if isinstance(tags, list): return tags # Object format: { "language": "Python", "sdk": "Client", "topic": "Learning" } if isinstance(tags, dict): result = [] for key in ["language", "sdk", "topic"]: if key in tags and tags[key]: result.append(tags[key]) return result except Exception: pass return [] def extract_description_from_readme(readme_path: Path) -> str | None: """Extract description from frontmatter in README.""" try: content = readme_path.read_text() # Check for frontmatter if content.startswith("---"): end_idx = content.find("---", 3) if end_idx > 0: frontmatter = content[3:end_idx] # Look for description: line for line in frontmatter.split("\n"): if line.strip().startswith("description:"): desc = line.split("description:", 1)[1].strip() # Remove quotes if present desc = desc.strip('"').strip("'") return desc[:200] except Exception: pass return None def extract_tags_from_readme(readme_path: Path) -> list[str]: """Extract tags from frontmatter in README if present. Supports multiple formats: - Array: tags: ["Python", "Client"] - Structured YAML: tags:\n language: "Python"\n sdk: "Client" - Object literal: tags: { language: "Python", sdk: "Client" } """ try: content = readme_path.read_text() # Check for frontmatter if content.startswith("---"): end_idx = content.find("---", 3) if end_idx > 0: frontmatter = content[3:end_idx] lines = frontmatter.split("\n") # Look for tags: line for i, line in enumerate(lines): if line.strip().startswith("tags:"): tags_str = line.split("tags:", 1)[1].strip() # Inline array format: tags: ["Python", "Client"] if tags_str.startswith("["): tags_str = tags_str.strip("[]") return [t.strip().strip('"').strip("'") for t in tags_str.split(",")] # JavaScript object literal format: tags: { language: "Python", sdk: "Client", topic: "Learning" } if tags_str.startswith("{"): tags = [] # Extract the entire object literal (might span multiple lines) obj_str = tags_str if "}" not in obj_str: # Multi-line object - collect remaining lines for j in range(i + 1, len(lines)): obj_str += " " + lines[j].strip() if "}" in lines[j]: break # Parse the object literal obj_str = obj_str.strip("{}") # Split by comma and extract key-value pairs for pair in obj_str.split(","): if ":" in pair: key, value = pair.split(":", 1) value = value.strip().strip('"').strip("'") if value: tags.append(value) return tags # Structured YAML format: # tags: # language: "Python" # sdk: "Client" if not tags_str or tags_str == "": # Parse structured tags from following lines tags = [] for j in range(i + 1, len(lines)): next_line = lines[j].strip() if not next_line or not next_line.startswith(("language:", "sdk:", "topic:")): break # Extract value if ":" in next_line: value = next_line.split(":", 1)[1].strip().strip('"').strip("'") if value: tags.append(value) return tags except Exception: pass return [] def extract_title_from_readme(readme_path: Path) -> str | None: """Extract title from README's first # heading.""" try: content = readme_path.read_text() match = re.search(r"^#\s+(.+)$", content, re.MULTILINE) if match: return match.group(1).strip() except Exception as e: print(f" Warning: Could not read {readme_path}: {e}") return None def convert_notebook_to_markdown(notebook_path: Path) -> str: """Convert Jupyter notebook to markdown. Uses nbconvert with --no-input to exclude outputs (which often contain characters that break MDX parsing). """ # Try nbconvert first try: with tempfile.TemporaryDirectory() as tmpdir: subprocess.run( [ "jupyter", "nbconvert", "--to", "markdown", "--TemplateExporter.exclude_output=True", # Exclude cell outputs str(notebook_path), "--output-dir", tmpdir, ], capture_output=True, check=True, ) md_file = Path(tmpdir) / notebook_path.with_suffix(".md").name if md_file.exists(): return md_file.read_text() except Exception as e: print(f" Warning: nbconvert failed ({e}), using fallback parser") # Fallback: manual conversion return convert_notebook_manually(notebook_path) def convert_notebook_manually(notebook_path: Path) -> str: """Manually convert notebook to markdown. Note: We skip cell outputs to avoid MDX parsing issues (outputs often contain characters like < and > that get interpreted as JSX tags). """ content = json.loads(notebook_path.read_text()) parts = [] lang = content.get("metadata", {}).get("kernelspec", {}).get("language", "python") for cell in content.get("cells", []): source = cell.get("source", []) if isinstance(source, list): source = "".join(source) if cell.get("cell_type") == "markdown": parts.append(source) elif cell.get("cell_type") == "code": parts.append(f"```{lang}\n{source}\n```") # Skip outputs - they often contain characters that break MDX parsing return "\n\n".join(parts) def process_notebooks(cookbook_dir: Path, recipes_dir: Path) -> list[dict]: """Process all notebooks and convert to recipe markdown files.""" notebooks_dir = cookbook_dir / "notebooks" recipes = [] if not notebooks_dir.exists(): print(" No notebooks directory found") return recipes files = sorted(f for f in notebooks_dir.iterdir() if f.suffix == ".ipynb") print(f" Found {len(files)} notebooks") for i, notebook_path in enumerate(files): slug = slugify(notebook_path.name) title = extract_title_from_notebook(notebook_path) description = extract_description_from_notebook(notebook_path) tags = extract_tags_from_notebook(notebook_path) print(f" Processing: {notebook_path.name} → {slug}.md") # Convert notebook to markdown md_content = convert_notebook_to_markdown(notebook_path) # Strip any existing frontmatter from converted notebook md_content = strip_frontmatter(md_content) # Create recipe page with frontmatter notebook_url = f"https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/{notebook_path.name}" frontmatter = f"""--- sidebar_position: {i + 1} --- """ callout = f""" :::tip Run this notebook This recipe is available as an interactive Jupyter notebook. [**Open in GitHub →**]({notebook_url}) ::: """ # Insert callout after first heading first_heading_match = re.search(r"^(#\s+.+\n)", md_content, re.MULTILINE) if first_heading_match: idx = md_content.index(first_heading_match.group(0)) + len(first_heading_match.group(0)) final_content = md_content[:idx] + "\n" + callout + "\n" + md_content[idx:] else: final_content = callout + "\n" + md_content output_path = recipes_dir / f"{slug}.md" output_path.write_text(frontmatter + final_content) recipes.append( { "slug": slug, "title": title, "description": description, "tags": tags, "id": f"cookbook/recipes/{slug}", } ) return recipes def strip_frontmatter(content: str) -> str: """Remove frontmatter from markdown content.""" if content.startswith("---"): end_idx = content.find("---", 3) if end_idx > 0: return content[end_idx + 3 :].lstrip() return content def process_applications(cookbook_dir: Path, apps_dir: Path) -> list[dict]: """Process application directories with README.md.""" apps = [] # Applications are now in the applications/ subdirectory applications_dir = cookbook_dir / "applications" if not applications_dir.exists(): print(" No applications directory found") return apps for entry in sorted(applications_dir.iterdir()): if not entry.is_dir() or entry.name in IGNORE_DIRS: continue readme_path = entry / "README.md" if not readme_path.exists(): continue slug = entry.name title = extract_title_from_readme(readme_path) or " ".join(word.capitalize() for word in slug.split("-")) description = extract_description_from_readme(readme_path) tags = extract_tags_from_readme(readme_path) print(f" Processing app: {entry.name} → {slug}.md") # Read README content and strip existing frontmatter readme_content = readme_path.read_text() readme_content = strip_frontmatter(readme_content) # Create application page with frontmatter app_url = f"https://github.com/vectorize-io/hindsight-cookbook/tree/main/applications/{entry.name}" frontmatter = f"""--- sidebar_position: {len(apps) + 1} --- """ callout = f""" :::info Complete Application This is a complete, runnable application demonstrating Hindsight integration. [**View source on GitHub →**]({app_url}) ::: """ # Insert callout after first heading first_heading_match = re.search(r"^(#\s+.+\n)", readme_content, re.MULTILINE) if first_heading_match: idx = readme_content.index(first_heading_match.group(0)) + len(first_heading_match.group(0)) final_content = readme_content[:idx] + "\n" + callout + "\n" + readme_content[idx:] else: final_content = callout + "\n" + readme_content output_path = apps_dir / f"{slug}.md" output_path.write_text(frontmatter + final_content) apps.append( { "slug": slug, "title": title, "description": description, "tags": tags, "id": f"cookbook/applications/{slug}", } ) return apps def update_sidebars(recipes: list[dict], apps: list[dict], sidebars_file: Path): """Update sidebars.ts - keep it simple with just the index.""" content = sidebars_file.read_text() # Simple sidebar with just the cookbook index new_cookbook_sidebar = """cookbookSidebar: [ { type: 'doc', id: 'cookbook/index', label: 'Cookbook', }, ]""" # Replace existing cookbookSidebar start = content.find("cookbookSidebar:") if start == -1: raise ValueError("cookbookSidebar not found in sidebars.ts") # Find the opening bracket bracket_start = content.find("[", start) if bracket_start == -1: raise ValueError("Could not find opening bracket for cookbookSidebar") # Find matching closing bracket by counting brackets depth = 0 end = bracket_start for i, char in enumerate(content[bracket_start:], bracket_start): if char == "[": depth += 1 elif char == "]": depth -= 1 if depth == 0: end = i + 1 break # Include trailing comma if present if end < len(content) and content[end] == ",": end += 1 content = content[:start] + new_cookbook_sidebar + "," + content[end:] sidebars_file.write_text(content) print("\nUpdated sidebars.ts") def clean_description(desc: str) -> str: """Clean description for display in carousel cards.""" if not desc: return "" # Remove markdown formatting desc = re.sub(r"\*\*([^*]+)\*\*", r"\1", desc) # Bold desc = re.sub(r"\*([^*]+)\*", r"\1", desc) # Italic desc = re.sub(r"`([^`]+)`", r"\1", desc) # Code desc = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", desc) # Links desc = re.sub(r"^[-*]\s+", "", desc) # List items desc = re.sub(r"\s+", " ", desc).strip() # Normalize whitespace # Truncate at sentence boundary or max length if len(desc) > 120: # Try to cut at sentence period_idx = desc.rfind(".", 0, 120) if period_idx > 60: desc = desc[: period_idx + 1] else: desc = desc[:117] + "..." return desc def convert_tags_to_structured(tags: list[str]) -> dict[str, str]: """Convert list of tags to structured format. New format has 2 tags: - sdk: Package name (detected from tag values) - topic: anything else (Learning, Quick Start, etc.) If sdk tag starts with '@vectorize-io', it's Node.js. Otherwise assumes Python. """ structured = {} topic_tags = {"Learning", "Quick Start", "Recommendation", "Chat"} for tag in tags: # Check if it's a topic tag if tag in topic_tags: structured["topic"] = tag # Check if it's already a package name (contains @ or -) elif "@" in tag or (tag and not tag[0].isupper()): structured["sdk"] = tag else: # Legacy tag values - map to new format # For now, treat everything else as SDK/package identifier structured["sdk"] = tag return structured def update_cookbook_index(recipes: list[dict], apps: list[dict], docs_dir: Path): """Update cookbook/index.mdx with recipe and app carousels.""" # Build recipe items for the carousel with descriptions and tags recipe_items = [] for r in recipes: title = r["title"].replace('"', '\\"') description = r.get("description", "") if description: description = clean_description(description).replace('"', '\\"') tags = r.get("tags", []) item = f' {{\n title: "{title}",\n href: "/cookbook/recipes/{r["slug"]}"' if description: item += f',\n description: "{description}"' if tags: # Convert tags list to structured format structured_tags = convert_tags_to_structured(tags) tags_parts = [] if "language" in structured_tags: tags_parts.append(f'language: "{structured_tags["language"]}"') if "sdk" in structured_tags: tags_parts.append(f'sdk: "{structured_tags["sdk"]}"') if "topic" in structured_tags: tags_parts.append(f'topic: "{structured_tags["topic"]}"') if tags_parts: item += f",\n tags: {{ {', '.join(tags_parts)} }}" item += "\n }" recipe_items.append(item) recipes_json = ",\n".join(recipe_items) # Build app items for the carousel app_items = [] for a in apps: title = a["title"].replace('"', '\\"') description = a.get("description", "") if description: description = clean_description(description).replace('"', '\\"') tags = a.get("tags", []) item = f' {{\n title: "{title}",\n href: "/cookbook/applications/{a["slug"]}"' if description: item += f',\n description: "{description}"' if tags: # Convert tags list to structured format structured_tags = convert_tags_to_structured(tags) tags_parts = [] if "language" in structured_tags: tags_parts.append(f'language: "{structured_tags["language"]}"') if "sdk" in structured_tags: tags_parts.append(f'sdk: "{structured_tags["sdk"]}"') if "topic" in structured_tags: tags_parts.append(f'topic: "{structured_tags["topic"]}"') if tags_parts: item += f",\n tags: {{ {', '.join(tags_parts)} }}" item += "\n }" app_items.append(item) apps_json = ",\n".join(app_items) content = f"""--- sidebar_position: 1 hide_table_of_contents: true pagination_next: null pagination_prev: null custom_edit_url: null sidebar_class_name: hidden-sidebar --- import RecipeCarousel from '@site/src/components/RecipeCarousel';