diff --git a/.gitignore b/.gitignore index d65f12b..84450f2 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ coverage.xml .hypothesis/ .pytest_cache/ cover/ +.ruff_cache/ # Translations *.mo @@ -144,6 +145,12 @@ venv.bak/ .dmypy.json dmypy.json +# pyright +.pyright/ + +# basedpyright +.basedpyright/ + # Pyre type checker .pyre/ @@ -153,6 +160,16 @@ dmypy.json # Cython debug symbols cython_debug/ +# Python tooling +.python-version +poetry.toml +uv.lock + +# Rust +Cargo.lock +**/*.rs.bk +*.pdb + # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore @@ -198,6 +215,9 @@ dist/ # Flycheck flycheck_*.el +# Treemacs +.treemacs-persist + # server auth directory /server/ @@ -209,5 +229,3 @@ flycheck_*.el # network security /network-security.data - - diff --git a/README.md b/README.md index c2102a0..f13d44e 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,24 @@ A lightweight, responsive, static-site framework for open educational resources in science. ## 🎯 Purpose -This repository provides a reusable foundation for sites like **evo-edu.org**, featuring: +This repository provides a reusable foundation for sites like **evo-edu.org**, `www2.talkorigins.org`, and `pandasthumb.net`, featuring: - Mobile-first responsive design - Modular content loading (HTML fragments) -- Language-switching support (for multilingual sites) +- Language-switching support for multilingual static trees - Integrated app-card and notebook-section templates - - Intended to host Javascript web apps + - Intended to host JavaScript web apps -- With study guides, alignment documents, reading (links to notebook sections) - Bibliography rendering (journal style + BibTeX) +- Multiple theme presets, including: + - `evo-edu` + - `talkorigins-modern` + - `pandasthumb` +- Optional content bridges for `doclift`, `GroundRecall`, `Didactopus`, and `CiteGeist` +- A generic notebook pattern for topic-level study modules that combine goals, + apps, source-derived sections, and bibliographies. See + [docs/NOTEBOOKS.md](docs/NOTEBOOKS.md) +- Optional translation tooling can use local GenieHive LLM endpoints. See + [docs/GENIEHIVE_TRANSLATION.md](docs/GENIEHIVE_TRANSLATION.md) ## πŸ› οΈ Features - Vanilla HTML/CSS/JS (no heavy frameworks) @@ -25,7 +35,8 @@ This repository provides a reusable foundation for sites like **evo-edu.org**, f ## πŸ“‚ Structure ``` /framework -β”œβ”€β”€ theme/ # Base layout, CSS, JS +β”œβ”€β”€ theme/ # Shared assets plus theme presets +β”‚ └── themes/ # Shipped theme variants β”œβ”€β”€ templates/ # Reusable HTML snippets β”œβ”€β”€ docs/ # Usage guide and examples β”œβ”€β”€ scripts/ # Language translation script and example glossary @@ -34,11 +45,12 @@ This repository provides a reusable foundation for sites like **evo-edu.org**, f ## 🧩 How to Use 1. Clone this repo -2. Copy `/theme/base.html` into your content project -3. Customize navigation and styling -4. Use `main.js` for dynamic section loading +2. Choose a theme preset and optional content sources in `site.json` +3. Build with `scripts/build.py` +4. Use `main.js` for dynamic section loading and language switching -> See [`evo-edu/en`](https://evo-edu.org/en) for a working example. +> Use the `talkorigins-modern` preset as the proving ground for the +> `www2.talkorigins.org` modernization line. ## πŸ“œ License MIT β€” free to use, modify, and redistribute. @@ -62,31 +74,24 @@ obtain the correct bibliography items to display. Content folding with lazy-loading aims to prevent pages from growing in size without bound, and provide modularity in collaboration. Models, programs, and simulations are meant to be implemented in a browser friendly form, -either Javascript, WebGL, or similar, so that processing takes place +either JavaScript, WebGL, or similar, so that processing takes place client-side, preventing heavy loads on servers. The current model of -content I am pursuing with this to to have a site landing page, a +content I am pursuing with this is to have a site landing page, a collection of web apps for a topic, and a 'notebook' on that topic -that includes didectic material sufficient to ground a naive user in +that includes didactic material sufficient to ground a naive user in the relevant concepts that the models, programs, and simulations address. The other major feature here is the architecture to support multiple -languages. Because my efforts are currently down to one developer, me, -this is accomplished by use of one or more locally-hosted multilingual -large language models that can automaticlly provide decent translation -from a source language to a target language. In my case, the source -language will be English, I have a Python program for a batch offline -process to traverse the site directory tree, open and parse HTML -files, ask for translations at the paragraph level, and assemble those -back into the same HTML structure in order to obtain each translated -page. Each page will incorporate the language switcher Javascript code -in its header, which amounts to redirecting the user to a copy of the -site whose static files are in the target language. The translation -is to be done via an LLM running locally via Mozilla Llamafile. +languages. The core framework can present static language trees and switch +between covered locales without requiring a translation backend. Optional +translation tooling can use locally-hosted multilingual large language models +routed through GenieHive; see +[docs/GENIEHIVE_TRANSLATION.md](docs/GENIEHIVE_TRANSLATION.md) for that +separate client-side configuration. This came together in a hurry, but I hope that other people may find some utility in it to aid in disseminating domain knowledge they and their collaborators may have. Wesley R. Elsberry, 2025-10-14 - diff --git a/docs/GENIEHIVE_TRANSLATION.md b/docs/GENIEHIVE_TRANSLATION.md new file mode 100644 index 0000000..f6dc022 --- /dev/null +++ b/docs/GENIEHIVE_TRANSLATION.md @@ -0,0 +1,136 @@ +# GenieHive Translation Configuration + +This guide covers the optional SciSiteForge translation path: + +- `scripts/translate_site.py` +- the `translation` block in a SciSiteForge site config +- the GenieHive OpenAI-compatible chat endpoint used by the translator + +The translator is intentionally separate from the static build path. It does +not own the translation model or routing policy. It reads site config, loads +optional glossaries, and sends paragraph-sized requests to GenieHive. + +## Client-Side Configuration + +SciSiteForge reads translation settings from the `translation` object in the +site config: + +```json +{ + "translation": { + "provider": "geniehive", + "base_url": "http://127.0.0.1:8800", + "model": "scientific_translator", + "api_key": "change-me-client-key", + "timeout": 120, + "system_prompt": "You are a careful scientific translator. Preserve meaning, structure, and technical terms. Return only the translation." + } +} +``` + +Recommended meaning of the fields: + +- `base_url`: GenieHive control-plane URL or a reverse-proxied client URL. +- `provider`: translation backend. The supported provider is currently + `geniehive`. +- `model`: a GenieHive role ID or directly addressable model name. +- `api_key`: the GenieHive client key. +- `timeout`: request timeout in seconds. +- `system_prompt`: the translation policy for the client. + +The CLI also accepts overrides: + +- `--base-url` +- `--model` +- `--api-key` +- `--timeout` + +Those flags override the site config for a single run. + +## Request Shape + +`scripts/translate_site.py` sends GenieHive a standard chat-completions request: + +- `POST /v1/chat/completions` +- `model`: from the translation config +- `messages`: one system message plus one user prompt +- `temperature`: low, so translations stay stable + +The user prompt asks for: + +- translation into the target language +- no commentary +- preservation of structure and technical terminology + +## Glossaries + +If `scripts/glossary_.json` exists, the translator loads it and passes +the glossary entries into the prompt. + +Use glossaries for: + +- fixed technical terms +- proper names that should not be translated +- site-specific terminology + +Keep each glossary small and explicit. The translator is not a terminology +database. + +## Practical Workflow + +1. Set the SciSiteForge `translation` block. +2. Confirm GenieHive is reachable at the configured `base_url`. +3. Confirm the selected GenieHive model or role exists. +4. Run `scripts/translate_site.py`. +5. Review the translated tree before publishing it. + +For site-specific translation runs, keep the source tree and destination tree +separate. Translation should not overwrite the English source. + +## Suggested Defaults + +For local development: + +- `base_url`: `http://127.0.0.1:8800` +- `model`: a translation-focused GenieHive role +- `timeout`: `120` + +For production or a LAN host: + +- use the reverse-proxied GenieHive URL +- keep the API key required +- prefer a translation role with conservative prompt policy + +## Relationship to Site Themes + +Theme choice and translation are independent. + +- themes control layout, styling, and rendering +- translation controls content generation for alternate language trees + +Use the same translation setup across `evo-edu`, `talkorigins-modern`, and +`pandasthumb` unless a site has language-specific terminology that needs a +custom glossary or prompt. + +For the TalkOrigins modernization proof-of-concept, keep the language switcher +in priority order rather than alphabetical order. A practical top-ten ordering +is: + +1. Spanish +2. French +3. Portuguese +4. German +5. Italian +6. Russian +7. Chinese +8. Japanese +9. Arabic +10. Hindi + +That order keeps the most broadly useful and generally stable translations near +the top of the chooser while still exposing the full target set. + +Use the `coverage` flag on each language entry to decide what appears in the +main switcher. Keep the full intended list in `language_policy.planned_languages` +so the site can show the broader roadmap without advertising unfinished locales +as active. diff --git a/docs/NOTEBOOKS.md b/docs/NOTEBOOKS.md new file mode 100644 index 0000000..246f2ad --- /dev/null +++ b/docs/NOTEBOOKS.md @@ -0,0 +1,129 @@ +# SciSiteForge Notebooks + +A SciSiteForge notebook is a topic-level study module. It is smaller than a +full learner application and more structured than a list of cards. + +Use a notebook when a site needs to connect: + +- a concept or claim +- one or more interactive apps or labs +- recovered source documents +- grounded knowledge and provenance +- guided-study concepts +- citations or bibliography updates + +## Role in the Ecosystem + +The notebook pattern is intentionally generic: + +- `doclift` rescues and normalizes legacy documents. +- `GroundRecall` provides grounded concepts, claims, observations, and + provenance. +- `Didactopus` provides learner-facing concepts, prerequisites, pathways, and + review-oriented packs. +- `CiteGeist` provides bibliography and literature-update material. +- SciSiteForge renders a static site shell that can present those artifacts in + a coherent topic module. + +SciSiteForge should not take over the job of those systems. It should render +their outputs in a predictable static format. + +## Notebook Shape + +In a site config, a notebook looks like this: + +```json +{ + "notebooks": [ + { + "id": "evidence-and-claims", + "title": "Evidence and Claims Notebook", + "summary": "Connect claims, evidence, source material, and citations.", + "audience": "self-learners and instructors", + "goals": [ + "Move from a claim to relevant evidence", + "Expose provenance and review status", + "Connect source documents to guided study" + ], + "apps": [ + { + "title": "Public search", + "href": "/search/", + "description": "Search across related corpora" + } + ], + "source_kinds": ["section", "notebook", "app", "bibliography"], + "max_items": 8 + } + ] +} +``` + +The build system renders each notebook as: + +- title and summary +- audience note +- goals +- app/lab links +- selected study material from loaded content sources + +## Content Sources + +Notebook study material is selected from the loaded `content_sources`. + +Recommended mapping: + +- `doclift_bundle`: recovered legacy readings and source documents +- `groundrecall_bundle`: concepts, claims, observations, and provenance +- `didactopus_pack`: guided concepts and prerequisite structure +- `bibliography`: CiteGeist bibliography entries + +The first implementation uses the existing card stream and filters by +`source_kinds`. That keeps the model simple while preserving room for richer +notebook manifests later. + +## evo-edu.org Pattern + +For evo-edu.org, notebooks should frame a learning pathway around: + +- an app or lab, such as Avida-ED or an ecology/fitness landscape tool +- the concept sequence needed to use the tool well +- common misconceptions and review prompts +- source readings or curriculum fragments +- bibliography support for instructors or deeper learners + +This supports the current evo-edu direction: lab, atlas, and guided study in +one coherent site. + +## TalkOrigins Pattern + +For the TalkOrigins modernization proof-of-concept, notebooks should frame: + +- a claim or topic +- relevant Index to Creationist Claims entries +- stable Archive articles +- Panda's Thumb or TalkDesign context when appropriate +- bibliography updates and provenance + +This fits the static/dynamic split: stable archive material remains stable, +while notebook pages can provide a modern guided route through it. + +## Panda's Thumb Pattern + +For Panda's Thumb, notebooks should work as topic dossiers: + +- a recurring topic or controversy +- MT-era authoritative posts where available +- scraped-corpus posts for later years +- related Index to Creationist Claims material +- citations and source trails + +The notebook should identify provenance clearly when the same topic is covered +by multiple corpora. + +## Design Rule + +Keep notebooks static, reviewable, and source-aware. If a workflow needs +learner state, mastery ledgers, evaluator behavior, or interactive mentoring, +that belongs in Didactopus. SciSiteForge should publish the durable study +surface. diff --git a/docs/USAGE.md b/docs/USAGE.md index 9671e6d..d78a5db 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1,10 +1,12 @@ -# Using the evo-edu Framework +# Using SciSiteForge -## 1. Copy Theme Files -Copy `/theme/` into your site’s root. +## 1. Choose a Theme +Select one of the shipped presets under `/theme/themes/` and let the build +script materialize it into your site’s output tree. ## 2. Create Pages -Use `base.html` as a template. Replace `{{ }}` placeholders with actual content. +Use the selected theme's `base.html` as a template. Replace `{{ }}` placeholders +with actual content. ## 3. Add Dynamic Behavior (Optional) Include `/theme/main.js` for: @@ -16,4 +18,30 @@ Edit `style.css` to match your project’s visual identity. ## 5. Multilingual Support - Organize content under `/en/`, `/es/`, etc. -- Update language switcher options in `base.html` +- Update language switcher options in the theme template +- Translation generation is optional and separate from the static build path. + See [GENIEHIVE_TRANSLATION.md](GENIEHIVE_TRANSLATION.md) for the optional + GenieHive client settings and workflow. + +## 6. Supported Presets and Bridges +- `evo-edu` for the learning-platform shell +- `talkorigins-modern` for the `www2.talkorigins.org` modernization proof-of-concept +- `pandasthumb` for the archive/news shell +- Content bridges for `doclift`, `GroundRecall`, `Didactopus`, and `CiteGeist` + +## 7. Notebook Pattern +SciSiteForge notebooks are topic-level study modules. A notebook groups: + +- goals and audience +- apps or labs +- source-derived sections from `doclift`, `GroundRecall`, and `Didactopus` +- bibliography entries from `CiteGeist` +- provenance-oriented links back to source material + +Use notebooks when a site needs more than loose cards but does not need a full +learner application. The evo-edu instance can use this for digital evolution +study paths, while TalkOrigins can use the same pattern for claim-to-evidence +modules and Panda's Thumb can use it for topic dossiers. + +See [NOTEBOOKS.md](NOTEBOOKS.md) for the generic notebook pattern and the +site-specific application notes. diff --git a/examples/talkorigins-modern.site.json b/examples/talkorigins-modern.site.json new file mode 100644 index 0000000..dd92e43 --- /dev/null +++ b/examples/talkorigins-modern.site.json @@ -0,0 +1,73 @@ +{ + "lang": "en", + "title": "TalkOrigins Archive: Modernized Preview", + "site_title": "TalkOrigins Archive", + "license": "CC BY-SA 4.0", + "github_url": "https://example.invalid/talkorigins-modern", + "contact_email": "admin@example.invalid", + "theme": "talkorigins-modern", + "languages": [ + { "code": "en", "name": "English", "coverage": true }, + { "code": "es", "name": "EspaΓ±ol", "coverage": false }, + { "code": "fr", "name": "FranΓ§ais", "coverage": false }, + { "code": "pt", "name": "PortuguΓͺs", "coverage": false }, + { "code": "de", "name": "Deutsch", "coverage": false }, + { "code": "it", "name": "Italiano", "coverage": false }, + { "code": "ru", "name": "Русский", "coverage": false }, + { "code": "zh", "name": "δΈ­ζ–‡", "coverage": false }, + { "code": "ja", "name": "ζ—₯本θͺž", "coverage": false }, + { "code": "ar", "name": "Ψ§Ω„ΨΉΨ±Ψ¨ΩŠΨ©", "coverage": false }, + { "code": "hi", "name": "ΰ€Ήΰ€Ώΰ€¨ΰ₯ΰ€¦ΰ₯€", "coverage": false } + ], + "language_policy": { + "planned_languages": [ + { "code": "es", "name": "EspaΓ±ol" }, + { "code": "fr", "name": "FranΓ§ais" }, + { "code": "pt", "name": "PortuguΓͺs" }, + { "code": "de", "name": "Deutsch" }, + { "code": "it", "name": "Italiano" }, + { "code": "ru", "name": "Русский" }, + { "code": "zh", "name": "δΈ­ζ–‡" }, + { "code": "ja", "name": "ζ—₯本θͺž" }, + { "code": "ar", "name": "Ψ§Ω„ΨΉΨ±Ψ¨ΩŠΨ©" }, + { "code": "hi", "name": "ΰ€Ήΰ€Ώΰ€¨ΰ₯ΰ€¦ΰ₯€" } + ] + }, + "navigation": [ + { "label": "Home", "href": "/" }, + { "label": "Support", "href": "/foundation/2026-update/" }, + { "label": "Search", "href": "/search/" } + ], + "hero": { + "kicker": "Archive Preview", + "title": "A cleaner, more readable TalkOrigins Archive that still feels like the Archive.", + "lede": "Use this preset as the proving ground for the www2.talkorigins.org modernization line.", + "actions": [ + { "label": "Open the preview", "href": "/#overview", "primary": true }, + { "label": "View the framework", "href": "https://example.invalid/scisiteforge", "primary": false } + ] + }, + "notebooks": [ + { + "id": "evidence-and-claims", + "title": "Evidence and Claims Notebook", + "summary": "A reusable study module that can connect stable Archive articles, Index to Creationist Claims entries, guided concepts, and bibliography updates.", + "audience": "self-learners, instructors, and board-reviewed site editors", + "goals": [ + "Move from a claim to the relevant evidence and archive context", + "Keep stable source material separate from dynamic commentary", + "Expose provenance, citations, and review status as first-class study material" + ], + "apps": [ + { + "title": "Public search", + "href": "/search/", + "description": "Search across Foundation corpora and the Index to Creationist Claims" + } + ], + "source_kinds": ["section", "notebook", "app", "bibliography"], + "max_items": 8 + } + ], + "content_sources": {} +} diff --git a/scisiteforge/__init__.py b/scisiteforge/__init__.py new file mode 100644 index 0000000..e0da76f --- /dev/null +++ b/scisiteforge/__init__.py @@ -0,0 +1,11 @@ +from .content import ( + ContentCard, + SiteContent, + load_citegeist_cards, + load_didactopus_cards, + load_doclift_cards, + load_groundrecall_cards, +) +from .render import render_template +from .notebook import Notebook, NotebookApp, load_notebooks, render_notebooks +from .themes import ThemeSpec, available_themes, get_theme, materialize_theme diff --git a/scisiteforge/config.py b/scisiteforge/config.py new file mode 100644 index 0000000..148d4a7 --- /dev/null +++ b/scisiteforge/config.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +DEFAULT_THEME = "evo-edu" + + +def load_config(path: str | Path) -> dict[str, Any]: + return json.loads(Path(path).read_text(encoding="utf-8")) + + +def save_config(path: str | Path, config: dict[str, Any]) -> None: + Path(path).write_text(json.dumps(config, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def resolve_path(value: str | Path | None, base_dir: str | Path | None = None) -> Path | None: + if value in (None, ""): + return None + path = Path(value) + if path.is_absolute() or base_dir is None: + return path + return Path(base_dir) / path diff --git a/scisiteforge/content.py b/scisiteforge/content.py new file mode 100644 index 0000000..953e34a --- /dev/null +++ b/scisiteforge/content.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +import json +import re +from pathlib import Path +from typing import Any + +from .render import html_escape + + +@dataclass(slots=True) +class ContentCard: + title: str + body: str + href: str = "" + meta: str = "" + kind: str = "feature" + source: str = "" + + +@dataclass(slots=True) +class SiteContent: + feature_cards: list[ContentCard] = field(default_factory=list) + section_cards: list[ContentCard] = field(default_factory=list) + app_cards: list[ContentCard] = field(default_factory=list) + bibliography_entries: list[ContentCard] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + + +def _first_paragraph(text: str) -> str: + paragraphs = [chunk.strip() for chunk in re.split(r"\n\s*\n", text) if chunk.strip()] + return paragraphs[0] if paragraphs else text.strip() + + +def _read_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _read_yaml(path: Path) -> Any: + text = path.read_text(encoding="utf-8") + try: # pragma: no cover - exercised only if PyYAML is installed + import yaml # type: ignore + + return yaml.safe_load(text) or {} + except Exception: + stripped = text.strip() + if stripped.startswith("{") or stripped.startswith("["): + return json.loads(stripped) + return _parse_minimal_yaml(text) + + +def _parse_scalar(value: str) -> Any: + value = value.strip() + if value in {"", "null", "~"}: + return None + if value == "[]": + return [] + if value.startswith("[") and value.endswith("]"): + inner = value[1:-1].strip() + if not inner: + return [] + return [_parse_scalar(part) for part in inner.split(",")] + if value.startswith('"') and value.endswith('"'): + return value[1:-1] + if value.startswith("'") and value.endswith("'"): + return value[1:-1] + if value.isdigit(): + return int(value) + if value.lower() in {"true", "false"}: + return value.lower() == "true" + return value + + +def _parse_minimal_yaml(text: str) -> dict[str, Any]: + lines = [line.rstrip() for line in text.splitlines() if line.strip() and not line.strip().startswith("#")] + root: dict[str, Any] = {} + current_key: str | None = None + current_item: dict[str, Any] | None = None + + for index, raw in enumerate(lines): + stripped = raw.lstrip(" ") + indent = len(raw) - len(stripped) + if indent == 0: + current_item = None + if ":" not in stripped: + continue + key, value = stripped.split(":", 1) + key = key.strip() + value = value.strip() + if value: + root[key] = _parse_scalar(value) + else: + next_line = lines[index + 1] if index + 1 < len(lines) else "" + root[key] = [] if next_line.lstrip(" ").startswith("- ") else {} + current_key = key + continue + + if current_key is None: + continue + + container = root.get(current_key) + if isinstance(container, list) and stripped.startswith("- "): + item_text = stripped[2:].strip() + if not item_text: + current_item = {} + container.append(current_item) + elif ":" in item_text: + item_key, item_value = item_text.split(":", 1) + current_item = {item_key.strip(): _parse_scalar(item_value)} + container.append(current_item) + else: + current_item = None + container.append(_parse_scalar(item_text)) + continue + + target = current_item if isinstance(current_item, dict) else container + if isinstance(target, dict) and ":" in stripped: + key, value = stripped.split(":", 1) + target[key.strip()] = _parse_scalar(value) + elif isinstance(target, list) and stripped.startswith("- "): + target.append(_parse_scalar(stripped[2:])) + + return root + + +def load_doclift_cards(bundle_root: str | Path) -> list[ContentCard]: + base = Path(bundle_root) + manifest = _read_json(base / "manifest.json") + cards: list[ContentCard] = [] + for item in manifest.get("documents", []): + if not isinstance(item, dict): + continue + title = str(item.get("title") or item.get("document_id") or "Document") + body = str(item.get("summary") or item.get("description") or item.get("document_kind") or "") + markdown_path = item.get("markdown_path") + source_href = str(item.get("canonical_url") or item.get("source_path") or "") + if markdown_path: + md_path = base / str(markdown_path) + if md_path.exists(): + body = _first_paragraph(md_path.read_text(encoding="utf-8")) + cards.append( + ContentCard( + title=title, + body=body, + href=source_href, + meta=str(item.get("document_kind") or "document"), + kind="notebook", + source=str(item.get("document_id") or title.lower().replace(" ", "-")), + ) + ) + return cards + + +def load_groundrecall_cards(bundle_root: str | Path) -> list[ContentCard]: + base = Path(bundle_root) + bundle_path = base / "groundrecall_query_bundle.json" + if not bundle_path.exists(): + bundle_path = base / "exports" / "codex" / "codex_bundle.json" + if not bundle_path.exists(): + return [] + payload = _read_json(bundle_path) + concept = payload.get("concept") or {} + title = str(concept.get("title") or payload.get("title") or "GroundRecall concept") + body = str(payload.get("summary") or payload.get("explanation") or payload.get("body") or "") + claims = payload.get("claims") or payload.get("related_claims") or [] + claim_count = len(claims) if isinstance(claims, list) else 0 + cards = [ + ContentCard( + title=title, + body=body or f"{claim_count} related claims and observations are bundled here.", + href=str(payload.get("source_url") or ""), + meta=f"GroundRecall bundle Β· {claim_count} claims", + kind="section", + source=str(concept.get("concept_id") or title.lower().replace(" ", "-")), + ) + ] + for claim in claims if isinstance(claims, list) else []: + if not isinstance(claim, dict): + continue + cards.append( + ContentCard( + title=str(claim.get("claim_text") or claim.get("title") or "Claim"), + body=str(claim.get("support") or claim.get("notes") or ""), + href=str(claim.get("source_url") or ""), + meta=str(claim.get("claim_kind") or "claim"), + kind="section", + source=str(claim.get("claim_id") or claim.get("id") or ""), + ) + ) + return cards + + +def load_didactopus_cards(pack_root: str | Path) -> list[ContentCard]: + base = Path(pack_root) + pack_path = base / "pack.yaml" + concepts_path = base / "concepts.yaml" + if not pack_path.exists() or not concepts_path.exists(): + return [] + pack = _read_yaml(pack_path) or {} + concepts = _read_yaml(concepts_path) or {} + cards: list[ContentCard] = [] + for concept in concepts.get("concepts", []): + if not isinstance(concept, dict): + continue + title = str(concept.get("title") or concept.get("id") or "Concept") + description = str(concept.get("description") or "") + prerequisites = concept.get("prerequisites") or [] + prereq_text = ", ".join(str(item) for item in prerequisites) if prerequisites else "None" + body = description or f"Prerequisites: {prereq_text}." + cards.append( + ContentCard( + title=title, + body=body, + href=str(pack.get("display_name") or pack.get("name") or ""), + meta=f"Didactopus concept Β· {prereq_text}", + kind="app", + source=str(concept.get("id") or title.lower().replace(" ", "-")), + ) + ) + return cards + + +def load_citegeist_cards(source_root: str | Path) -> list[ContentCard]: + root = Path(source_root) + bib_files = sorted( + path + for path in root.rglob("*.bib") + if path.is_file() and not path.name.endswith("-bak.bib") and not path.name.startswith(".") + ) + if not bib_files: + return [] + cards: list[ContentCard] = [] + try: + from citegeist.bibtex import parse_bibtex # type: ignore + except Exception: + parse_bibtex = None + for bib_path in bib_files: + text = bib_path.read_text(encoding="utf-8") + entries = parse_bibtex(text) if parse_bibtex is not None else _fallback_parse_bibtex(text) + for entry in entries: + data = entry if isinstance(entry, dict) else entry.__dict__ + title = str(data.get("title") or data.get("citation_key") or "Reference") + author = str(data.get("author") or data.get("editor") or "") + year = str(data.get("year") or "") + body = " Β· ".join(part for part in [author, year] if part).strip() + cards.append( + ContentCard( + title=title, + body=body or bib_path.name, + href=str(bib_path.relative_to(root)), + meta="CiteGeist bibliography", + kind="bibliography", + source=str(data.get("citation_key") or title.lower().replace(" ", "-")), + ) + ) + return cards + + +def _fallback_parse_bibtex(text: str) -> list[dict[str, str]]: + entries: list[dict[str, str]] = [] + current: dict[str, str] | None = None + for line in text.splitlines(): + stripped = line.strip() + if not stripped: + continue + if stripped.startswith("@") and "{" in stripped: + if current: + entries.append(current) + kind, rest = stripped[1:].split("{", 1) + key = rest.split(",", 1)[0].strip() + current = {"entry_type": kind.strip(), "citation_key": key} + continue + if current and "=" in stripped: + field, value = stripped.split("=", 1) + current[field.strip().lower()] = value.strip().strip(",{}") + if current: + entries.append(current) + return entries diff --git a/scisiteforge/notebook.py b/scisiteforge/notebook.py new file mode 100644 index 0000000..52924e0 --- /dev/null +++ b/scisiteforge/notebook.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from .content import ContentCard, SiteContent +from .render import html_escape + + +@dataclass(slots=True) +class NotebookApp: + title: str + href: str + description: str = "" + + +@dataclass(slots=True) +class Notebook: + notebook_id: str + title: str + summary: str = "" + audience: str = "" + goals: list[str] = field(default_factory=list) + apps: list[NotebookApp] = field(default_factory=list) + source_kinds: list[str] = field(default_factory=list) + max_items: int = 8 + + +def load_notebooks(config: dict[str, Any]) -> list[Notebook]: + notebooks: list[Notebook] = [] + for item in config.get("notebooks", []): + if not isinstance(item, dict): + continue + apps = [ + NotebookApp( + title=str(app.get("title") or app.get("href") or "App"), + href=str(app.get("href") or "#"), + description=str(app.get("description") or ""), + ) + for app in item.get("apps", []) + if isinstance(app, dict) + ] + notebooks.append( + Notebook( + notebook_id=str(item.get("id") or item.get("notebook_id") or item.get("title") or "notebook"), + title=str(item.get("title") or "Notebook"), + summary=str(item.get("summary") or item.get("description") or ""), + audience=str(item.get("audience") or ""), + goals=[str(goal) for goal in item.get("goals", [])], + apps=apps, + source_kinds=[str(kind) for kind in item.get("source_kinds", [])], + max_items=int(item.get("max_items") or 8), + ) + ) + return notebooks + + +def select_notebook_cards(notebook: Notebook, site_content: SiteContent) -> list[ContentCard]: + cards = ( + site_content.section_cards + + site_content.app_cards + + site_content.feature_cards + + site_content.bibliography_entries + ) + if notebook.source_kinds: + allowed = set(notebook.source_kinds) + cards = [card for card in cards if card.kind in allowed or card.meta in allowed] + return cards[: notebook.max_items] + + +def render_notebooks(notebooks: list[Notebook], site_content: SiteContent) -> str: + if not notebooks: + return "" + return "\n".join(render_notebook(notebook, site_content) for notebook in notebooks) + + +def render_notebook(notebook: Notebook, site_content: SiteContent) -> str: + cards = select_notebook_cards(notebook, site_content) + goals_html = "".join(f"
  • {html_escape(goal)}
  • " for goal in notebook.goals) + apps_html = "".join( + ( + '
  • ' + f'{html_escape(app.title)}' + f' {html_escape(app.description)}' + '
  • ' + ) + for app in notebook.apps + ) + cards_html = "".join( + ( + '
  • ' + f'{html_escape(card.title)}' + f' {html_escape(card.meta)}' + f'

    {html_escape(card.body)}

    ' + '
  • ' + ) + for card in cards + ) + audience_html = f'

    Audience: {html_escape(notebook.audience)}

    ' if notebook.audience else "" + goals_block = f'

    Goals

    ' if goals_html else "" + apps_block = f'

    Apps and Labs

    ' if apps_html else "" + cards_block = f'

    Study Material

    ' if cards_html else "" + return ( + f'
    ' + f'

    {html_escape(notebook.title)}

    ' + f'

    {html_escape(notebook.summary)}

    ' + f'{audience_html}' + f'
    {goals_block}{apps_block}{cards_block}
    ' + '
    ' + ) diff --git a/scisiteforge/render.py b/scisiteforge/render.py new file mode 100644 index 0000000..f41ed4c --- /dev/null +++ b/scisiteforge/render.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from html import escape +import re +from pathlib import Path +from typing import Any + + +_PLACEHOLDER_RE = re.compile(r"\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}") + + +def html_escape(value: Any) -> str: + return escape("" if value is None else str(value), quote=True) + + +def render_template(template: str, context: dict[str, Any]) -> str: + def replace(match: re.Match[str]) -> str: + key = match.group(1) + value = context.get(key, "") + return "" if value is None else str(value) + + return _PLACEHOLDER_RE.sub(replace, template) + + +def read_text(path: str | Path) -> str: + return Path(path).read_text(encoding="utf-8") + + +def write_text(path: str | Path, text: str) -> None: + Path(path).parent.mkdir(parents=True, exist_ok=True) + Path(path).write_text(text, encoding="utf-8") diff --git a/scisiteforge/themes.py b/scisiteforge/themes.py new file mode 100644 index 0000000..7be5e88 --- /dev/null +++ b/scisiteforge/themes.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +import shutil + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +@dataclass(frozen=True) +class ThemeSpec: + name: str + display_name: str + template_path: Path + stylesheet_path: Path + extra_assets: tuple[Path, ...] = field(default_factory=tuple) + body_class: str = "" + shell_class: str = "" + page_class: str = "" + description: str = "" + + +def _theme_path(*parts: str) -> Path: + return REPO_ROOT.joinpath(*parts) + + +_THEMES: dict[str, ThemeSpec] = { + "evo-edu": ThemeSpec( + name="evo-edu", + display_name="Evo-Edu", + template_path=_theme_path("theme", "themes", "evo-edu", "base.html"), + stylesheet_path=_theme_path("theme", "themes", "evo-edu", "style.css"), + body_class="theme-evo-edu", + shell_class="site-shell", + page_class="evo-edu-page", + description="Warm learning-focused theme derived from the evo-edu.org home page.", + ), + "talkorigins-modern": ThemeSpec( + name="talkorigins-modern", + display_name="TalkOrigins Modern", + template_path=_theme_path("theme", "themes", "talkorigins-modern", "base.html"), + stylesheet_path=_theme_path("theme", "themes", "talkorigins-modern", "style.css"), + extra_assets=( + _theme_path("theme", "themes", "talkorigins-modern", "assets", "toa.ico"), + _theme_path("theme", "themes", "talkorigins-modern", "assets", "toa_logo_001_edit_001.png"), + ), + body_class="theme-talkorigins-modern", + shell_class="site-shell", + page_class="talkorigins-preview", + description="Archive-forward theme derived from the www2.talkorigins.org modernization proof-of-concept.", + ), + "pandasthumb": ThemeSpec( + name="pandasthumb", + display_name="Panda's Thumb", + template_path=_theme_path("theme", "themes", "pandasthumb", "base.html"), + stylesheet_path=_theme_path("theme", "themes", "pandasthumb", "style.css"), + body_class="theme-pandasthumb", + shell_class="site-shell", + page_class="pandasthumb-page", + description="Legacy-archive theme derived from pandasthumb.net.", + ), +} + + +def available_themes() -> list[ThemeSpec]: + return [_THEMES[name] for name in sorted(_THEMES)] + + +def get_theme(name: str | None) -> ThemeSpec: + theme_name = name or "evo-edu" + try: + return _THEMES[theme_name] + except KeyError as exc: + raise KeyError(f"Unknown SciSiteForge theme: {theme_name}") from exc + + +def materialize_theme(theme: ThemeSpec, output_dir: str | Path) -> dict[str, str]: + out = Path(output_dir) + theme_root = out / "theme" + assets_root = theme_root / "assets" + assets_root.mkdir(parents=True, exist_ok=True) + + style_target = theme_root / "style.css" + shutil.copyfile(theme.stylesheet_path, style_target) + + copied_assets: list[str] = [] + for asset in theme.extra_assets: + target = assets_root / asset.name + shutil.copyfile(asset, target) + copied_assets.append(target.relative_to(out).as_posix()) + + shared_js = _theme_path("theme", "main.js") + if shared_js.exists(): + shutil.copyfile(shared_js, theme_root / "main.js") + + return { + "theme_name": theme.name, + "theme_display_name": theme.display_name, + "theme_description": theme.description, + "theme_stylesheet_href": "/theme/style.css", + "theme_script_href": "/theme/main.js", + "theme_asset_prefix": "/theme/assets", + "theme_assets": copied_assets, + } diff --git a/scisiteforge/translations.py b/scisiteforge/translations.py new file mode 100644 index 0000000..6cabe57 --- /dev/null +++ b/scisiteforge/translations.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +from urllib import error, request + + +@dataclass(slots=True) +class TranslationConfig: + provider: str = "geniehive" + base_url: str = "http://127.0.0.1:8800" + model: str = "general_assistant" + api_key: str = "" + timeout: int = 120 + system_prompt: str = ( + "You are a careful scientific translator. Preserve meaning, section structure, " + "and technical terminology. Return only the translation." + ) + + +class GenieHiveTranslator: + def __init__(self, config: TranslationConfig): + if config.provider != "geniehive": + raise ValueError(f"Unsupported translation provider: {config.provider}") + self.config = config + + def translate(self, text: str, target_language: str, glossary: dict[str, str] | None = None) -> str: + if not text.strip(): + return text + prompt = self._build_prompt(text, target_language, glossary or {}) + payload = { + "model": self.config.model, + "messages": [ + {"role": "system", "content": self.config.system_prompt}, + {"role": "user", "content": prompt}, + ], + "temperature": 0.1, + } + response = self._post_json("/v1/chat/completions", payload) + try: + return response["choices"][0]["message"]["content"].strip() + except Exception as exc: + raise RuntimeError("GenieHive response did not contain a translation.") from exc + + def _build_prompt(self, text: str, target_language: str, glossary: dict[str, str]) -> str: + glossary_text = "" + if glossary: + glossary_text = "Use these translations when they fit the target language:\n" + "\n".join( + f"- {source} => {target}" for source, target in glossary.items() + ) + glossary_text += "\n\n" + return ( + f"Translate the following English text into {target_language}.\n" + "Keep the HTML/text structure intact. Do not add commentary.\n\n" + f"{glossary_text}Text:\n{text}\n" + ) + + def _post_json(self, path: str, payload: dict) -> dict: + url = self.config.base_url.rstrip("/") + path + data = json.dumps(payload).encode("utf-8") + headers = {"Content-Type": "application/json"} + if self.config.api_key: + headers["Authorization"] = f"Bearer {self.config.api_key}" + req = request.Request(url, data=data, headers=headers, method="POST") + try: + with request.urlopen(req, timeout=self.config.timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + except error.HTTPError as exc: # pragma: no cover - network path + raise RuntimeError(f"GenieHive request failed with HTTP {exc.code}") from exc + except error.URLError as exc: # pragma: no cover - network path + raise RuntimeError(f"GenieHive request failed: {exc.reason}") from exc diff --git a/scripts/README.md b/scripts/README.md index dca8427..24c0ec6 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,74 +1,55 @@ # SciSiteForge Scripts -## πŸ› οΈ Build +## Build -πŸ§ͺ Usage Example +Initialize a site config: - Initialize config: - bash - +```bash +cd /opt/www/dev/SciSiteForge +python3 scripts/build.py --init +``` - +Build a site: -cd domain_di/framework -python build.py --init - β†’ creates site.json - - -Build English site: - - - python build.py --config site.json --output ../content/en/ - - +```bash +python3 scripts/build.py --config site.json --output /tmp/scisiteforge-site +``` -Build Spanish site (after editing site.json to set "lang": "es"): -bash - - - - python build.py --config site-es.json --output ../content/es/ - - - - - -### Benefits - - - No runtime dependencies: Output is pure static HTML/CSS/JS - - Reusable: Same framework for any educational site - - Customizable: Each project has its own site.json - - Automation-friendly: Integrate into CI/CD or translation pipelines - +The shipped theme presets are: +- `evo-edu` +- `talkorigins-modern` +- `pandasthumb` +Use `talkorigins-modern` as the proving ground for the +`www2.talkorigins.org` modernization line. ## Translate -This site framework supports offline multilingual translation using Llamafile. +Translation is optional and separate from the static build. The current +translation provider is GenieHive through its OpenAI-compatible chat endpoint. -### Prerequisites -- Download a multilingual GGUF model (e.g., `mistral-7b-instruct.Q5_K_M.gguf`) -- Install [Llamafile](https://github.com/Mozilla-Ocho/llamafile) -- Python 3 with `requests` and `beautifulsoup4` +See `docs/GENIEHIVE_TRANSLATION.md` for the SciSiteForge client-side +configuration guide and the GenieHive repository's +`docs/translation_support.md` for the control-plane and node-side notes. -### Steps -1. Launch Llamafile: - ```bash - ./mistral-7b-instruct.Q5_K_M.llamafile --port 8080 - ``` -2. Run translation: - ```bash - python scripts/translate_site.py --langs es,fr - ``` -3. Commit translated content: - ```bash - git add es/ fr/ - ``` - -> Translated files are saved to `/es/`, `/fr/`, etc., and served alongside English content. +```bash +python3 scripts/translate_site.py \ + --config site.json \ + --langs es,fr \ + --src content/en \ + --dest content ``` -#### πŸ“ `example/content/scripts/glossary_es.json` -β†’ Language-specific scientific term mappings +Optional translation settings can be provided in the site config under +`translation`: +- `provider` +- `base_url` +- `model` +- `api_key` +- `timeout` +- `system_prompt` + +The translator loads language glossaries from `scripts/glossary_.json` +when present. diff --git a/scripts/build.py b/scripts/build.py index 54d1587..601794f 100644 --- a/scripts/build.py +++ b/scripts/build.py @@ -1,120 +1,224 @@ #!/usr/bin/env python3 -""" -Static site generator for evo-edu framework. +"""Static site generator for SciSiteForge.""" -Two modes: -1. --init : Prompt user for site config and save to site.json -2. --config --output : Render templates using config -""" +from __future__ import annotations -import os -import json import argparse from pathlib import Path -import shutil +import sys +from typing import Any -# Template directory (relative to this script) -TEMPLATE_DIR = Path(__file__).parent / "templates" +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -def prompt_for_config(): - """Prompt user for site configuration.""" - print("=== evo-edu Framework Site Config ===") - config = { - "lang": input("Language code (e.g., 'en'): ") or "en", - "title": input("Page title (e.g., 'Notebook On Evolution'): ") or "Notebook On Evolution", - "site_title": input("Site name (e.g., 'evo-edu.org'): ") or "evo-edu.org", - "license": input("License text (e.g., 'CC BY-SA 4.0'): ") or "CC BY-SA 4.0", - "github_url": input("GitHub URL: ") or "https://github.com/evo-edu", - "contact_email": input("Contact email: ") or "admin@evo-edu.org", - "languages": [] +from scisiteforge.config import DEFAULT_THEME, load_config, save_config +from scisiteforge.content import ( + SiteContent, + load_citegeist_cards, + load_didactopus_cards, + load_doclift_cards, + load_groundrecall_cards, +) +from scisiteforge.render import html_escape, read_text, render_template, write_text +from scisiteforge.notebook import load_notebooks, render_notebooks +from scisiteforge.themes import available_themes, get_theme, materialize_theme + + +def _prompt_for_config() -> dict[str, Any]: + print("=== SciSiteForge Site Config ===") + themes = available_themes() + print("Available themes:") + for theme in themes: + print(f" - {theme.name}: {theme.description}") + theme_name = input(f"Theme name (default: {DEFAULT_THEME}): ").strip() or DEFAULT_THEME + languages_input = input("Languages (code:name pairs, comma-separated; default: en:English): ").strip() or "en:English" + languages = [] + for pair in languages_input.split(","): + code, name = pair.strip().split(":", 1) + languages.append({"code": code.strip(), "name": name.strip()}) + return { + "lang": input("Language code (default: en): ").strip() or "en", + "title": input("Page title (default: SciSiteForge Preview): ").strip() or "SciSiteForge Preview", + "site_title": input("Site name (default: SciSiteForge): ").strip() or "SciSiteForge", + "license": input("License text (default: CC BY-SA 4.0): ").strip() or "CC BY-SA 4.0", + "github_url": input("GitHub URL (optional): ").strip() or "https://github.com/", + "contact_email": input("Contact email (optional): ").strip() or "admin@example.org", + "theme": theme_name, + "languages": languages, + "navigation": [ + {"label": "Home", "href": "/"}, + ], + "hero": { + "kicker": "Preview", + "title": "A site shell that can adapt to more than one audience.", + "lede": "SciSiteForge now supports multiple theme presets and local content loaders for reusable science sites.", + "actions": [ + {"label": "Read the overview", "href": "#overview", "primary": True}, + {"label": "Theme catalog", "href": "#themes", "primary": False}, + ], + }, + "content_sources": {}, + "notebooks": [], } - - # Language options - print("\nLanguage switcher options (e.g., en:English, es:EspaΓ±ol):") - lang_input = input("Enter as 'code:name' pairs (comma-separated): ") or "en:English" - for pair in lang_input.split(','): - code, name = pair.strip().split(':', 1) - config["languages"].append({"code": code, "name": name}) - lang_options = "\n".join([ - f'' - for lang in config["languages"] - ]) - result = result.replace("{{language_options}}", lang_options) - # Save - out_file = input("\nSave config as (default: site.json): ") or "site.json" - with open(out_file, 'w', encoding='utf-8') as f: - json.dump(config, f, indent=2) - print(f"βœ… Config saved to {out_file}") -def render_template(template_text, config): - """Replace {{key}} and {{#each}} blocks with config values.""" - result = template_text - - # Simple key replacements - for key, value in config.items(): - if key != "languages": - result = result.replace("{{" + key + "}}", str(value)) - - # Handle {{#each languages}}...{{/each}} - if "{{#each languages}}" in result: - lang_block_start = result.find("{{#each languages}}") - lang_block_end = result.find("{{/each}}", lang_block_start) - if lang_block_end != -1: - block = result[lang_block_start + len("{{#each languages}}"):lang_block_end] - rendered_langs = [] - for lang in config.get("languages", []): - lang_item = block - lang_item = lang_item.replace("{{code}}", lang["code"]) - lang_item = lang_item.replace("{{name}}", lang["name"]) - # Handle {{#if (eq code ../lang)}} - if f'{{{{lang}}}}' in result: - selected = 'selected' if lang["code"] == config.get("lang", "") else '' - lang_item = lang_item.replace("{{selected_attr}}", f'selected="{selected}"' if selected else '') - else: - lang_item = lang_item.replace("{{selected_attr}}", "") - rendered_langs.append(lang_item) - result = result[:lang_block_start] + "".join(rendered_langs) + result[lang_block_end + len("{{/each}}"):] - - return result -def build_site(config_file, output_dir): - """Render all templates using config.""" - with open(config_file, 'r', encoding='utf-8') as f: - config = json.load(f) - +def _language_options_html(languages: list[dict[str, str]], current_lang: str) -> str: + visible_languages = [ + item + for item in languages + if item.get("coverage", True) or item.get("code") == current_lang + ] + return "\n".join( + f'' + for item in visible_languages + ) + + +def _language_policy_html(language_policy: dict[str, Any]) -> str: + planned_languages = language_policy.get("planned_languages", []) + if not planned_languages: + return "" + planned_names = ", ".join(html_escape(item.get("name", item.get("code", ""))) for item in planned_languages if item.get("name") or item.get("code")) + if not planned_names: + return "" + return f'

    Planned languages: {planned_names}

    ' + + +def _hero_actions_html(actions: list[dict[str, Any]]) -> str: + if not actions: + return "" + return "\n".join( + f'{html_escape(action.get("label", "Open"))}' + for action in actions + ) + + +def _navigation_html(navigation: list[dict[str, str]]) -> str: + return "\n".join( + f'{html_escape(item.get("label", "Link"))}' + for item in navigation + ) + + +def _render_cards(cards: list, template_path: str | Path, lang: str) -> str: + if not cards: + return "" + template = read_text(template_path) + rendered: list[str] = [] + for card in cards: + rendered.append( + render_template( + template, + { + "lang": lang, + "app_title": html_escape(card.title), + "app_description": html_escape(card.body), + "app_slug": html_escape(card.source or card.title.lower().replace(" ", "-")), + "section_title": html_escape(card.title), + "section_meta": html_escape(card.meta), + "section_excerpt": html_escape(card.body), + "section_path": html_escape(card.source or card.title.lower().replace(" ", "-")), + "href": html_escape(card.href), + "link_label": "Open", + }, + ) + ) + return "\n".join(rendered) + + +def build_site(config_file: str | Path, output_dir: str | Path) -> dict[str, Any]: + config = load_config(config_file) + theme = get_theme(config.get("theme")) out_path = Path(output_dir) out_path.mkdir(parents=True, exist_ok=True) - - # Copy theme assets - theme_src = Path(__file__).parent / "theme" - for asset in ["style.css", "main.js"]: - shutil.copy(theme_src / asset, out_path / asset) - - # Render base.html β†’ index.html (example) - with open(theme_src / "base.html", 'r', encoding='utf-8') as f: - template = f.read() - - rendered = render_template(template, config) - with open(out_path / "index.html", 'w', encoding='utf-8') as f: - f.write(rendered) - - print(f"βœ… Site built in {output_dir}") -def main(): - parser = argparse.ArgumentParser() + theme_context = materialize_theme(theme, out_path) + template = read_text(theme.template_path) + + content_sources = config.get("content_sources", {}) + site_content = SiteContent() + if source := content_sources.get("doclift_bundle"): + site_content.section_cards.extend(load_doclift_cards(source)) + if source := content_sources.get("groundrecall_bundle"): + site_content.section_cards.extend(load_groundrecall_cards(source)) + if source := content_sources.get("didactopus_pack"): + site_content.feature_cards.extend(load_didactopus_cards(source)) + if source := content_sources.get("bibliography"): + site_content.bibliography_entries.extend(load_citegeist_cards(source)) + notebooks = load_notebooks(config) + + languages = config.get("languages", [{"code": config.get("lang", "en"), "name": "English", "coverage": True}]) + language_policy = config.get("language_policy", {}) + hero = config.get("hero", {}) + page_context = { + "lang": config.get("lang", "en"), + "page_title": html_escape(config.get("title", config.get("site_title", "SciSiteForge"))), + "site_title": html_escape(config.get("site_title", "SciSiteForge")), + "description": html_escape(config.get("description", "")), + "license": html_escape(config.get("license", "CC BY-SA 4.0")), + "github_url": html_escape(config.get("github_url", "")), + "contact_email": html_escape(config.get("contact_email", "")), + "theme_name": html_escape(theme.name), + "theme_display_name": html_escape(theme.display_name), + "theme_description": html_escape(theme.description), + "theme_stylesheet_href": theme_context["theme_stylesheet_href"], + "theme_script_href": theme_context["theme_script_href"], + "theme_asset_prefix": theme_context["theme_asset_prefix"], + "body_class": html_escape(theme.body_class), + "site_shell_class": html_escape(theme.shell_class), + "page_class": html_escape(theme.page_class), + "navigation_html": _navigation_html(config.get("navigation", [])), + "language_options": _language_options_html(languages, config.get("lang", "en")), + "language_policy_html": _language_policy_html(language_policy), + "hero_kicker": html_escape(hero.get("kicker", theme.display_name)), + "hero_title": html_escape(hero.get("title", config.get("title", ""))), + "hero_lede": html_escape(hero.get("lede", config.get("description", ""))), + "hero_actions_html": _hero_actions_html(hero.get("actions", [])), + "feature_cards_html": _render_cards(site_content.feature_cards, Path(__file__).parent.parent / "templates" / "app-card.html", config.get("lang", "en")), + "section_cards_html": _render_cards(site_content.section_cards, Path(__file__).parent.parent / "templates" / "notebook-section.html", config.get("lang", "en")), + "app_cards_html": _render_cards(site_content.app_cards, Path(__file__).parent.parent / "templates" / "app-card.html", config.get("lang", "en")), + "bibliography_html": "\n".join( + f'
  • {html_escape(card.title)} {html_escape(card.body)}
  • ' + for card in site_content.bibliography_entries + ), + "notebook_html": render_notebooks(notebooks, site_content), + } + page_context.update( + { + "content_panels_html": page_context["feature_cards_html"] + "\n" + page_context["section_cards_html"], + } + ) + rendered = render_template(template, page_context) + write_text(out_path / "index.html", rendered) + return {"output_dir": str(out_path), "theme": theme.name, "theme_assets": theme_context["theme_assets"]} + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build a SciSiteForge site from a JSON config.") parser.add_argument("--init", action="store_true", help="Create site config interactively") parser.add_argument("--config", help="Path to site.json") parser.add_argument("--output", help="Output directory for built site") + parser.add_argument("--themes", action="store_true", help="List the built-in theme presets") + parser.add_argument("--save-config", help="Where to write the config when using --init", default="site.json") args = parser.parse_args() - + + if args.themes: + for theme in available_themes(): + print(f"{theme.name}: {theme.description}") + return + if args.init: - prompt_for_config() - elif args.config and args.output: - build_site(args.config, args.output) - else: - print("Usage:") - print(" python build.py --init") - print(" python build.py --config site.json --output ../content/en/") + config = _prompt_for_config() + save_config(args.save_config, config) + print(f"Wrote config to {args.save_config}") + return + + if args.config and args.output: + result = build_site(args.config, args.output) + print(f"Built {result['output_dir']} with theme {result['theme']}") + return + + parser.print_help() + if __name__ == "__main__": main() diff --git a/scripts/translate_site.py b/scripts/translate_site.py index 3844e9e..720e1b5 100644 --- a/scripts/translate_site.py +++ b/scripts/translate_site.py @@ -1,119 +1,111 @@ #!/usr/bin/env python3 -""" -Offline multilingual translation for evo-edu.org using Llamafile. -Requires: BeautifulSoup4, requests -Install with: pip install beautifulsoup4 requests -""" +"""Optional offline multilingual translation for SciSiteForge sites.""" + +from __future__ import annotations -import os -import json import argparse -import time +import json +import sys from pathlib import Path -from bs4 import BeautifulSoup, NavigableString -import requests +from time import sleep + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from scisiteforge.config import load_config +from scisiteforge.translations import GenieHiveTranslator, TranslationConfig + -# --- Configuration --- -MODEL_API_URL = "http://localhost:8080/completion" LANGUAGES = { "es": "Spanish", "fr": "French", "pt": "Portuguese", - "de": "German" + "de": "German", + "it": "Italian", + "ru": "Russian", + "zh": "Chinese", + "ja": "Japanese", + "ar": "Arabic", + "hi": "Hindi", } -def translate_text(text, target_lang_name, glossary=None): - """Translate a block of text using Llamafile.""" - if not text.strip(): - return text - - glossary_text = "" - if glossary: - glossary_text = "Use these translations:\n" + "\n".join(f"'{k}' Ò†’ '{v}'" for k, v in glossary.items()) + "\n\n" - - prompt = f"""You are a scientific translator. Translate the following English text into {target_lang_name}. -Preserve technical terms like "genetic drift" or "natural selection" unless a standard translation exists. -Maintain paragraph structure. Do not add commentary. - -{glossary_text}Text: -"{text}" - -Translation:""" +def _load_bs4(): try: - response = requests.post(MODEL_API_URL, json={ - "prompt": prompt, - "temperature": 0.1, - "stop": ["\n\n", "Text:", "Translation:"], - "n_predict": 1024 - }, timeout=120) - response.raise_for_status() - result = response.json()["content"].strip() - return result - except Exception as e: - print(f" Γ’Ε‘ Γ―ΒΈ Translation failed: {e}") - return text # fallback to original + from bs4 import BeautifulSoup, NavigableString # type: ignore + except Exception as exc: # pragma: no cover - import-time fallback + raise RuntimeError("BeautifulSoup4 is required for HTML translation.") from exc + return BeautifulSoup, NavigableString + def extract_translatable_text(soup): - """Extract text nodes for translation, preserving structure.""" - texts = [] + _, NavigableString = _load_bs4() for elem in soup.descendants: - if isinstance(elem, NavigableString) and elem.parent.name not in ['script', 'style']: + if isinstance(elem, NavigableString) and elem.parent.name not in ["script", "style"]: if elem.strip(): - texts.append(elem) - return texts + yield elem -def translate_html_file(src_path, dest_path, target_lang_code): - """Translate an HTML file.""" - print(f"Translating {src_path} Ò†’ {dest_path}") - with open(src_path, 'r', encoding='utf-8') as f: - html = f.read() - soup = BeautifulSoup(html, 'html.parser') - text_nodes = extract_translatable_text(soup) - - # Optional: load glossary for this language - glossary = {} - glossary_path = Path(__file__).parent / f"glossary_{target_lang_code}.json" - if glossary_path.exists(): - with open(glossary_path, 'r') as f: - glossary = json.load(f) - - # Translate each text node - for node in text_nodes: - original = str(node) - translated = translate_text(original, LANGUAGES[target_lang_code], glossary) +def translate_html_file(src_path: Path, dest_path: Path, target_lang_code: str, translator: GenieHiveTranslator, glossary: dict[str, str] | None = None) -> None: + BeautifulSoup, _ = _load_bs4() + print(f"Translating {src_path} -> {dest_path}") + html = src_path.read_text(encoding="utf-8") + soup = BeautifulSoup(html, "html.parser") + for node in extract_translatable_text(soup): + translated = translator.translate(str(node), LANGUAGES[target_lang_code], glossary=glossary) node.replace_with(translated) - time.sleep(0.1) # be gentle on CPU - - # Save translated HTML + sleep(0.05) dest_path.parent.mkdir(parents=True, exist_ok=True) - with open(dest_path, 'w', encoding='utf-8') as f: - f.write(str(soup)) + dest_path.write_text(str(soup), encoding="utf-8") -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--langs", required=True, help="Comma-separated language codes (e.g., es,fr)") + +def build_translator(config_path: str | Path | None, args: argparse.Namespace) -> GenieHiveTranslator: + site_config = {} + if config_path: + site_config = load_config(config_path) + translation_cfg = site_config.get("translation", {}) + provider = getattr(args, "provider", None) or translation_cfg.get("provider") or "geniehive" + cfg = TranslationConfig( + provider=provider, + base_url=args.base_url or translation_cfg.get("base_url") or "http://127.0.0.1:8800", + model=args.model or translation_cfg.get("model") or "general_assistant", + api_key=args.api_key or translation_cfg.get("api_key") or "", + timeout=args.timeout or int(translation_cfg.get("timeout") or 120), + system_prompt=translation_cfg.get("system_prompt") + or "You are a careful scientific translator. Preserve meaning, structure, and technical terms. Return only the translation.", + ) + return GenieHiveTranslator(cfg) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Translate a SciSiteForge site with an optional provider backend.") + parser.add_argument("--langs", required=True, help="Comma-separated language codes (e.g. es,fr)") parser.add_argument("--src", default="content/en", help="Source directory (English)") parser.add_argument("--dest", default="content", help="Base destination directory") + parser.add_argument("--config", help="Optional site config to pull GenieHive settings from") + parser.add_argument("--provider", help="Translation provider (currently: geniehive)") + parser.add_argument("--base-url", help="Provider base URL (default for GenieHive: http://127.0.0.1:8800)") + parser.add_argument("--model", help="Provider model or role alias") + parser.add_argument("--api-key", help="Provider API key") + parser.add_argument("--timeout", type=int, help="HTTP timeout in seconds") args = parser.parse_args() - lang_codes = args.langs.split(',') + translator = build_translator(args.config, args) src_base = Path(args.src) dest_base = Path(args.dest) + glossary_cache: dict[str, dict[str, str]] = {} - for lang_code in lang_codes: + for lang_code in args.langs.split(","): if lang_code not in LANGUAGES: print(f"Unsupported language: {lang_code}") continue - print(f"\n=== Translating to {LANGUAGES[lang_code]} ({lang_code}) ===") + glossary_path = Path(__file__).parent / f"glossary_{lang_code}.json" + glossary = glossary_cache.setdefault(lang_code, json.loads(glossary_path.read_text(encoding="utf-8")) if glossary_path.exists() else {}) for html_file in src_base.rglob("*.html"): rel_path = html_file.relative_to(src_base) - dest_file = dest_base / lang_code / rel_path - translate_html_file(html_file, dest_file, lang_code) + translate_html_file(html_file, dest_base / lang_code / rel_path, lang_code, translator, glossary=glossary) + print("\nTranslation complete.") - print("\nÒœ… Translation complete.") if __name__ == "__main__": main() diff --git a/templates/app-card.html b/templates/app-card.html index a53f653..7b6c578 100644 --- a/templates/app-card.html +++ b/templates/app-card.html @@ -1,5 +1,5 @@ -
    + diff --git a/templates/notebook-section.html b/templates/notebook-section.html index 5a2470a..c4f80eb 100644 --- a/templates/notebook-section.html +++ b/templates/notebook-section.html @@ -1,7 +1,7 @@ -
    +

    {{ section_title }}

    {{ section_meta }}

    {{ section_excerpt }}

    - +
    diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..79a9918 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts")) diff --git a/tests/test_scisiteforge.py b/tests/test_scisiteforge.py new file mode 100644 index 0000000..7e5ae42 --- /dev/null +++ b/tests/test_scisiteforge.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +import json +import unittest +from pathlib import Path +from types import SimpleNamespace +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts")) + +import build +import translate_site + +from scisiteforge.content import SiteContent, load_citegeist_cards, load_didactopus_cards, load_doclift_cards, load_groundrecall_cards +from scisiteforge.notebook import load_notebooks, render_notebooks +from scisiteforge.themes import get_theme, materialize_theme +from scisiteforge.translations import GenieHiveTranslator, TranslationConfig + + +class SciSiteForgeTests(unittest.TestCase): + def test_theme_materialization_copies_theme_assets(self) -> None: + from tempfile import TemporaryDirectory + + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + theme = get_theme("talkorigins-modern") + payload = materialize_theme(theme, tmp_path) + + self.assertTrue((tmp_path / "theme" / "style.css").exists()) + self.assertTrue((tmp_path / "theme" / "main.js").exists()) + self.assertTrue((tmp_path / "theme" / "assets" / "toa.ico").exists()) + self.assertTrue((tmp_path / "theme" / "assets" / "toa_logo_001_edit_001.png").exists()) + self.assertEqual(payload["theme_name"], "talkorigins-modern") + + def test_content_loaders_parse_local_repo_artifacts(self) -> None: + from tempfile import TemporaryDirectory + + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + + doclift_root = tmp_path / "doclift" + doclift_root.mkdir() + (doclift_root / "manifest.json").write_text( + json.dumps( + { + "documents": [ + { + "document_id": "doc-1", + "title": "Legacy Document", + "document_kind": "article", + "markdown_path": "documents/doc-1/document.md", + "source_path": "source/doc-1.html", + } + ] + } + ), + encoding="utf-8", + ) + (doclift_root / "documents" / "doc-1").mkdir(parents=True) + (doclift_root / "documents" / "doc-1" / "document.md").write_text("First paragraph.\n\nSecond.", encoding="utf-8") + + groundrecall_root = tmp_path / "groundrecall" + groundrecall_root.mkdir() + (groundrecall_root / "groundrecall_query_bundle.json").write_text( + json.dumps( + { + "concept": {"concept_id": "concept::topic", "title": "GroundRecall Topic"}, + "summary": "Grounded summary.", + "claims": [{"claim_id": "clm-1", "claim_text": "Claim one", "claim_kind": "summary"}], + } + ), + encoding="utf-8", + ) + + didactopus_root = tmp_path / "didactopus" + didactopus_root.mkdir() + (didactopus_root / "pack.yaml").write_text("name: test-pack\ndisplay_name: Test Pack\n", encoding="utf-8") + (didactopus_root / "concepts.yaml").write_text( + "concepts:\n - id: prior\n title: Prior\n description: Previous knowledge.\n prerequisites: []\n", + encoding="utf-8", + ) + + citegeist_root = tmp_path / "citegeist" + citegeist_root.mkdir() + (citegeist_root / "refs.bib").write_text( + """@article{smith2024,\n title = {A Study},\n author = {Smith, Jane and Roe, John},\n year = {2024}\n}\n""", + encoding="utf-8", + ) + + doclift_cards = load_doclift_cards(doclift_root) + groundrecall_cards = load_groundrecall_cards(groundrecall_root) + didactopus_cards = load_didactopus_cards(didactopus_root) + citegeist_cards = load_citegeist_cards(citegeist_root) + + self.assertEqual(doclift_cards[0].title, "Legacy Document") + self.assertEqual(doclift_cards[0].body, "First paragraph.") + self.assertEqual(groundrecall_cards[0].title, "GroundRecall Topic") + self.assertEqual(groundrecall_cards[1].title, "Claim one") + self.assertEqual(didactopus_cards[0].title, "Prior") + self.assertEqual(citegeist_cards[0].title, "A Study") + + def test_build_site_renders_selected_theme_and_content(self) -> None: + from tempfile import TemporaryDirectory + + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + content_root = tmp_path / "content" + content_root.mkdir() + + doclift_root = content_root / "doclift" + doclift_root.mkdir() + (doclift_root / "manifest.json").write_text( + json.dumps({"documents": [{"document_id": "doc-1", "title": "Legacy Document", "markdown_path": "documents/doc-1/document.md"}]}), + encoding="utf-8", + ) + (doclift_root / "documents" / "doc-1").mkdir(parents=True) + (doclift_root / "documents" / "doc-1" / "document.md").write_text("Doclift content.", encoding="utf-8") + + didactopus_root = content_root / "didactopus" + didactopus_root.mkdir() + (didactopus_root / "pack.yaml").write_text("name: test-pack\ndisplay_name: Test Pack\n", encoding="utf-8") + (didactopus_root / "concepts.yaml").write_text( + "concepts:\n - id: prior\n title: Prior\n description: Previous knowledge.\n prerequisites: []\n", + encoding="utf-8", + ) + + groundrecall_root = content_root / "groundrecall" + groundrecall_root.mkdir() + (groundrecall_root / "groundrecall_query_bundle.json").write_text( + json.dumps({"concept": {"concept_id": "concept::topic", "title": "GroundRecall Topic"}, "summary": "Grounded summary."}), + encoding="utf-8", + ) + + citegeist_root = content_root / "citegeist" + citegeist_root.mkdir() + (citegeist_root / "refs.bib").write_text( + """@article{smith2024,\n title = {A Study},\n author = {Smith, Jane and Roe, John},\n year = {2024}\n}\n""", + encoding="utf-8", + ) + + config = { + "lang": "en", + "title": "TalkOrigins Preview", + "site_title": "TalkOrigins Archive", + "license": "CC BY-SA 4.0", + "github_url": "https://example.invalid", + "contact_email": "admin@example.invalid", + "theme": "talkorigins-modern", + "languages": [{"code": "en", "name": "English"}], + "navigation": [{"label": "Home", "href": "/"}], + "hero": { + "kicker": "Archive Preview", + "title": "Modernized, reviewable, and still archive-first.", + "lede": "Proof-of-concept output from SciSiteForge.", + "actions": [{"label": "Open", "href": "#overview", "primary": True}], + }, + "content_sources": { + "doclift_bundle": str(doclift_root), + "groundrecall_bundle": str(groundrecall_root), + "didactopus_pack": str(didactopus_root), + "bibliography": str(citegeist_root), + }, + } + config_path = tmp_path / "site.json" + config_path.write_text(json.dumps(config), encoding="utf-8") + + out_dir = tmp_path / "out" + result = build.build_site(config_path, out_dir) + + html = (out_dir / "index.html").read_text(encoding="utf-8") + self.assertEqual(result["theme"], "talkorigins-modern") + self.assertIn("TalkOrigins Preview", html) + self.assertIn("Legacy Document", html) + self.assertIn("GroundRecall Topic", html) + self.assertIn("Prior", html) + self.assertIn("A Study", html) + self.assertTrue((out_dir / "theme" / "assets" / "toa.ico").exists()) + + def test_build_site_filters_languages_by_coverage_and_shows_planned_list(self) -> None: + from tempfile import TemporaryDirectory + + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + config = { + "lang": "en", + "title": "TalkOrigins Preview", + "site_title": "TalkOrigins Archive", + "license": "CC BY-SA 4.0", + "github_url": "https://example.invalid", + "contact_email": "admin@example.invalid", + "theme": "talkorigins-modern", + "languages": [ + {"code": "en", "name": "English", "coverage": True}, + {"code": "es", "name": "EspaΓ±ol", "coverage": False}, + {"code": "fr", "name": "FranΓ§ais", "coverage": False}, + ], + "language_policy": { + "planned_languages": [ + {"code": "es", "name": "EspaΓ±ol"}, + {"code": "fr", "name": "FranΓ§ais"}, + ] + }, + "navigation": [{"label": "Home", "href": "/"}], + "hero": { + "kicker": "Archive Preview", + "title": "Modernized, reviewable, and still archive-first.", + "lede": "Proof-of-concept output from SciSiteForge.", + "actions": [{"label": "Open", "href": "#overview", "primary": True}], + }, + "content_sources": {}, + } + config_path = tmp_path / "site.json" + config_path.write_text(json.dumps(config), encoding="utf-8") + + out_dir = tmp_path / "out" + build.build_site(config_path, out_dir) + + html = (out_dir / "index.html").read_text(encoding="utf-8") + self.assertIn('', html) + self.assertNotIn('value="es"', html) + self.assertNotIn('value="fr"', html) + self.assertIn("Planned languages: EspaΓ±ol, FranΓ§ais", html) + + def test_notebook_pattern_groups_goals_apps_and_source_cards(self) -> None: + from tempfile import TemporaryDirectory + + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + doclift_root = tmp_path / "doclift" + doclift_root.mkdir() + (doclift_root / "manifest.json").write_text( + json.dumps( + { + "documents": [ + { + "document_id": "doc-1", + "title": "Legacy Reading", + "document_kind": "article", + "markdown_path": "documents/doc-1/document.md", + } + ] + } + ), + encoding="utf-8", + ) + (doclift_root / "documents" / "doc-1").mkdir(parents=True) + (doclift_root / "documents" / "doc-1" / "document.md").write_text("Recovered source paragraph.", encoding="utf-8") + + config = { + "notebooks": [ + { + "id": "digital-evolution", + "title": "Digital Evolution Notebook", + "summary": "Lab plus source-grounded study path.", + "audience": "self-learners", + "goals": ["Connect simulation output to evolutionary concepts"], + "apps": [{"title": "Avida-ED", "href": "/app4/", "description": "Digital evolution lab"}], + "source_kinds": ["notebook"], + } + ] + } + notebooks = load_notebooks(config) + content = SiteContent(section_cards=load_doclift_cards(doclift_root)) + html = render_notebooks(notebooks, content) + + self.assertIn("Digital Evolution Notebook", html) + self.assertIn("Avida-ED", html) + self.assertIn("Legacy Reading", html) + self.assertIn("Recovered source paragraph.", html) + + def test_geniehive_translator_uses_openai_compatible_chat_payload(self) -> None: + translator = GenieHiveTranslator( + TranslationConfig(base_url="http://geniehive.local:8800", model="translation-role", api_key="abc123") + ) + captured: dict[str, object] = {} + + def fake_post_json(path: str, payload: dict) -> dict: + captured["path"] = path + captured["payload"] = payload + return {"choices": [{"message": {"content": "Hola"}}]} + + translator._post_json = fake_post_json # type: ignore[method-assign] + result = translator.translate("Hello world", "Spanish", {"evolution": "evoluciΓ³n"}) + + self.assertEqual(result, "Hola") + self.assertEqual(captured["path"], "/v1/chat/completions") + payload = captured["payload"] + self.assertEqual(payload["model"], "translation-role") + user_text = payload["messages"][1]["content"] + self.assertIn("Spanish", user_text) + self.assertIn("evoluciΓ³n", user_text) + + def test_translate_site_builds_translator_from_config(self) -> None: + from tempfile import TemporaryDirectory + + with TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + config_path = tmp_path / "site.json" + config_path.write_text( + json.dumps( + { + "translation": { + "base_url": "http://geniehive.local:8800", + "model": "translation-role", + "api_key": "abc123", + "timeout": 33, + "system_prompt": "Translate carefully.", + } + } + ), + encoding="utf-8", + ) + args = SimpleNamespace(base_url=None, model=None, api_key=None, timeout=None) + translator = translate_site.build_translator(config_path, args) + + self.assertEqual(translator.config.provider, "geniehive") + self.assertEqual(translator.config.base_url, "http://geniehive.local:8800") + self.assertEqual(translator.config.model, "translation-role") + self.assertEqual(translator.config.api_key, "abc123") + self.assertEqual(translator.config.timeout, 33) + + +if __name__ == "__main__": + unittest.main() diff --git a/theme/main.js b/theme/main.js index dec3952..47e852c 100644 --- a/theme/main.js +++ b/theme/main.js @@ -1,15 +1,30 @@ -// Auto-update year -document.getElementById('year')?.textContent = new Date().getFullYear(); +window.langCode = document.documentElement.lang || "en"; + +document.getElementById("year")?.textContent = new Date().getFullYear(); -// Language switcher function switchLanguage(lang) { - const currentPath = window.location.pathname; - let newPath = currentPath.replace(new RegExp(`^/${window.langCode}/|^/`), `/${lang}/`); - if (!currentPath.startsWith(`/${lang}/`)) { - newPath = `/${lang}${currentPath}`; + if (!lang) return; + const currentPath = window.location.pathname || "/"; + const parts = currentPath.split("/").filter(Boolean); + if (parts.length > 0 && parts[0].length === 2) { + parts[0] = lang; + } else { + parts.unshift(lang); } - window.location.href = newPath; + const nextPath = "/" + parts.join("/"); + window.location.href = nextPath.endsWith("/") ? nextPath : nextPath + (currentPath.endsWith("/") ? "/" : ""); } -// Optional: expose langCode for JS logic -window.langCode = document.documentElement.lang || 'en'; +document.querySelectorAll("[data-src]").forEach((button) => { + button.addEventListener("click", () => { + const target = button.getAttribute("data-src"); + if (!target) return; + const content = button.parentElement?.querySelector(".content"); + if (!content) return; + fetch(target) + .then((resp) => resp.text()) + .then((html) => { + content.innerHTML = html; + }); + }); +}); diff --git a/theme/themes/evo-edu/base.html b/theme/themes/evo-edu/base.html new file mode 100644 index 0000000..730cfb7 --- /dev/null +++ b/theme/themes/evo-edu/base.html @@ -0,0 +1,96 @@ + + + + + + {{ page_title }} β€” {{ site_title }} + + + + + +
    +
    +
    + {{ theme_display_name }} + {{ site_title }} +

    {{ theme_description }}

    +
    + +
    + +
    +

    {{ hero_kicker }}

    +
    +
    +

    {{ hero_title }}

    +

    {{ hero_lede }}

    +
    + {{ hero_actions_html }} +
    +
    +
    +
    + Theme + {{ theme_display_name }} +
    +
    + Language + {{ lang }} +
    +
    + Sources + doclift, GroundRecall, Didactopus, CiteGeist +
    +
    + LLM + GenieHive-backed translation +
    +
    +
    +
    + +
    +

    Overview

    +

    What this theme supports

    +
    + {{ feature_cards_html }} +
    +
    + +
    +

    Notebook and apps

    +

    Structured sources and learning artifacts

    + {{ notebook_html }} +
    + {{ section_cards_html }} + {{ app_cards_html }} +
    +
    + +
    +

    Theme catalog

    +

    + SciSiteForge ships multiple theme presets so different science sites can share the same + content pipeline while presenting different reading experiences. +

    +
    + + +
    + + diff --git a/theme/themes/evo-edu/style.css b/theme/themes/evo-edu/style.css new file mode 100644 index 0000000..db21b5a --- /dev/null +++ b/theme/themes/evo-edu/style.css @@ -0,0 +1,375 @@ +:root { + --bg-top: #f8f4ec; + --bg-bottom: #e7dcc9; + --paper: rgba(255, 252, 247, 0.84); + --paper-strong: rgba(255, 252, 247, 0.94); + --ink: #16251f; + --muted: #5b675f; + --accent: #0f766e; + --accent-strong: #0b5d57; + --accent-warm: #bc6c25; + --line: rgba(22, 37, 31, 0.12); + --shadow: 0 24px 70px rgba(24, 35, 30, 0.12); + --max-width: 1180px; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + font-family: Georgia, "Times New Roman", serif; + color: var(--ink); + background: + radial-gradient(circle at top left, rgba(15, 118, 110, 0.14), transparent 28%), + radial-gradient(circle at top right, rgba(188, 108, 37, 0.16), transparent 24%), + linear-gradient(180deg, var(--bg-top), var(--bg-bottom) 72%, #dfd3bf); +} + +a { + color: var(--accent-strong); +} + +img { + max-width: 100%; + display: block; +} + +.site-shell { + width: min(var(--max-width), calc(100vw - 28px)); + margin: 0 auto; + padding: 18px 0 40px; +} + +.site-topbar, +.hero-card, +.content-card, +.feature-card, +.footer-card, +.path-card, +.resource-card, +.note-band { + background: var(--paper); + border: 1px solid var(--line); + border-radius: 24px; + box-shadow: var(--shadow); + backdrop-filter: blur(10px); +} + +.site-topbar { + display: flex; + gap: 18px; + align-items: center; + justify-content: space-between; + padding: 18px 22px; + margin-bottom: 20px; +} + +.brand-block { + display: flex; + flex-direction: column; + gap: 4px; +} + +.brand-mark { + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.76rem; +} + +.brand-block a { + color: var(--ink); + text-decoration: none; + font-size: 1.5rem; + font-weight: 700; +} + +.brand-summary { + margin: 0; + color: var(--muted); + font-size: 0.94rem; +} + +.site-nav { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: flex-end; +} + +.site-nav a, +.button-link, +.button-link-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border-radius: 999px; + padding: 11px 16px; + text-decoration: none; + font-size: 0.95rem; + transition: transform 160ms ease, background 160ms ease, border-color 160ms ease; +} + +.site-nav a, +.button-link-secondary { + color: var(--ink); + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.58); +} + +.button-link { + color: white; + background: var(--accent); + border: 1px solid transparent; +} + +.site-nav a:hover, +.button-link:hover, +.button-link-secondary:hover { + transform: translateY(-1px); +} + +.hero-card { + padding: 34px; + margin-bottom: 22px; +} + +.eyebrow { + margin: 0 0 12px; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.18em; + font-size: 0.8rem; +} + +.hero-grid { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.9fr); + gap: 24px; + align-items: start; +} + +.hero-card h1 { + margin: 0 0 14px; + font-size: clamp(2.6rem, 6vw, 4.9rem); + line-height: 0.96; +} + +.hero-card .lede, +.intro-text, +.content-card p, +.feature-card p, +.path-card p, +.resource-card p, +.footer-card p, +.note-band p, +.roadmap-phase li { + color: var(--muted); + line-height: 1.62; + font-size: 1.02rem; +} + +.hero-actions, +.button-row { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 20px; +} + +.stat-grid, +.feature-grid, +.path-grid, +.resource-grid { + display: grid; + gap: 16px; +} + +.stat-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.stat-card { + padding: 16px; + border-radius: 18px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.66); +} + +.stat-card strong { + display: block; + font-size: 1.8rem; + margin-bottom: 6px; +} + +.stat-card span { + color: var(--muted); +} + +.content-card, +.note-band, +.footer-card { + padding: 26px; + margin-bottom: 18px; +} + +.section-heading { + margin: 0 0 14px; + font-size: 1.8rem; +} + +.section-kicker { + margin: 0 0 10px; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.78rem; +} + +.feature-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.feature-card, +.path-card, +.resource-card { + padding: 22px; +} + +.feature-card h3, +.path-card h3, +.resource-card h3, +.content-card h3 { + margin: 0 0 10px; + font-size: 1.28rem; +} + +.meta-list, +.plain-list { + margin: 12px 0 0; + padding-left: 18px; + color: var(--muted); + line-height: 1.6; +} + +.path-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.resource-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.note-band { + background: rgba(15, 118, 110, 0.1); +} + +.roadmap-phase { + padding-left: 20px; +} + +.roadmap-phase li + li { + margin-top: 8px; +} + +.footer-card { + display: flex; + gap: 18px; + align-items: start; + justify-content: space-between; +} + +.footer-card small { + color: var(--muted); +} + +.redirect-card { + max-width: 720px; + margin: 7vh auto; +} + +.notebook-panel { + border: 1px solid var(--line); + border-radius: 16px; + padding: 20px; + background: rgba(255, 255, 255, 0.76); + margin: 18px 0; +} + +.notebook-panel h2, +.notebook-panel h3 { + margin-top: 0; +} + +.notebook-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; +} + +.notebook-panel .meta { + color: var(--muted); + font-size: 0.9rem; +} + +@media (max-width: 980px) { + .hero-grid, + .feature-grid, + .path-grid, + .resource-grid, + .notebook-grid { + grid-template-columns: 1fr; + } + + .site-topbar, + .footer-card { + flex-direction: column; + align-items: stretch; + } + + .site-nav { + justify-content: flex-start; + } +} + +@media (max-width: 640px) { + .site-shell { + width: min(var(--max-width), calc(100vw - 18px)); + padding-top: 10px; + } + + .site-topbar, + .hero-card, + .content-card, + .feature-card, + .path-card, + .resource-card, + .notebook-panel, + .footer-card, + .note-band { + border-radius: 20px; + } + + .hero-card, + .content-card, + .feature-card, + .path-card, + .resource-card, + .notebook-panel, + .footer-card, + .note-band { + padding: 20px; + } + + .stat-grid { + grid-template-columns: 1fr; + } +} diff --git a/theme/themes/pandasthumb/base.html b/theme/themes/pandasthumb/base.html new file mode 100644 index 0000000..7550012 --- /dev/null +++ b/theme/themes/pandasthumb/base.html @@ -0,0 +1,57 @@ + + + + + + {{ page_title }} β€” {{ site_title }} + + + + + + + +
    +
    +

    {{ hero_title }}

    +

    {{ hero_lede }}

    +
    + {{ hero_actions_html }} +
    +
    + +
    +

    Feature cards

    +
    + {{ feature_cards_html }} +
    +
    + +
    +

    Notebook and app content

    + {{ notebook_html }} +
    + {{ section_cards_html }} + {{ app_cards_html }} +
    +
    + +
    +

    Bibliography

    +
      + {{ bibliography_html }} +
    +
    +
    +
    +

    {{ site_title }} Β· {{ license }}

    +
    + + diff --git a/theme/themes/pandasthumb/style.css b/theme/themes/pandasthumb/style.css new file mode 100644 index 0000000..91b948e --- /dev/null +++ b/theme/themes/pandasthumb/style.css @@ -0,0 +1,395 @@ +/* Panda's Thumb legacy archive, restyled to match the TalkOrigins preview system. */ + +:root { + --bg: #f2eee6; + --panel: #fffdf9; + --ink: #1b2330; + --muted: #5b6471; + --line: rgba(28, 35, 48, 0.12); + --blue: #214f94; + --blue-deep: #163659; + --gold: #b18d33; + --shadow: 0 22px 55px rgba(20, 33, 53, 0.1); + --max-width: 1180px; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + color: var(--ink); + background: linear-gradient(180deg, #f7f3ea, var(--bg)); + font-family: Georgia, "Times New Roman", serif; +} + +a { + color: var(--blue); +} + +a:hover { + color: var(--blue-deep); +} + +img { + max-width: 100%; + height: auto; +} + +body > header, +body > main, +body > footer { + width: min(var(--max-width), calc(100vw - 28px)); + margin-left: auto; + margin-right: auto; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 20px; + box-shadow: var(--shadow); +} + +body > header, +body > main, +body > footer { + padding: 22px 24px; +} + +body > header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px 20px; + flex-wrap: wrap; + margin-top: 20px; + margin-bottom: 18px; + border-top: 4px solid var(--gold); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(255, 253, 249, 0.92)); +} + +.site-header .site-name, +.site-header .site-name a { + color: var(--ink); +} + +.site-header .site-name { + font-family: "Helvetica Neue", Arial, sans-serif; + font-size: clamp(1.35rem, 2vw, 1.8rem); + font-weight: 700; +} + +.site-header .site-name a { + text-decoration: none; +} + +.site-header .site-name a:hover { + color: var(--blue-deep); +} + +.site-header .site-tagline { + color: var(--muted); + font-family: "Helvetica Neue", Arial, sans-serif; + font-size: 0.82rem; + text-transform: uppercase; + letter-spacing: 0.12em; +} + +body > main { + margin-bottom: 18px; + line-height: 1.78; + font-size: 1.08rem; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.99), rgba(251, 247, 240, 0.98)); + border-top: 5px solid var(--blue); +} + +body > main > h1:first-child, +.post-title { + margin: 0 0 14px; + font: 700 clamp(2rem, 4vw, 2.8rem)/1.08 "Helvetica Neue", Arial, sans-serif; +} + +body > main > h2, +body > main > h3, +.comments-heading { + font-family: "Helvetica Neue", Arial, sans-serif; + line-height: 1.2; + color: var(--blue-deep); +} + +body > main > p:first-of-type, +.post-meta, +.comment-meta, +body > footer, +.canonical-link { + color: var(--muted); +} + +.post-meta, +.comment-meta { + font-family: "Helvetica Neue", Arial, sans-serif; + font-size: 0.9rem; +} + +.post-author, +.comment-author { + font-weight: 700; + color: var(--ink); +} + +.post-body, +.comment-body { + line-height: 1.78; +} + +.post-body p, +.post-body li, +.post-body blockquote, +.comment-body p, +.comment-body li, +.comment-body blockquote { + max-width: 68ch; +} + +.post-body blockquote, +.comment-body blockquote { + margin: 1.2rem 0; + padding: 0.75rem 1rem; + border-left: 4px solid var(--gold); + background: rgba(177, 141, 51, 0.08); +} + +.canonical-link, +.post-canonical { + margin: 0 0 1.2rem; + padding: 0.8rem 1rem; + border-radius: 14px; + background: linear-gradient(180deg, rgba(255, 250, 241, 0.98), rgba(251, 246, 238, 0.98)); + border: 1px solid rgba(22, 54, 89, 0.12); + font-size: 0.92rem; +} + +.comments-section { + border-top: 2px solid rgba(28, 35, 48, 0.12); + margin-top: 2rem; + padding-top: 1rem; +} + +.comments-heading { + margin: 0 0 1rem; + font-size: 1.15rem; +} + +.comment { + margin-bottom: 1.2rem; + padding: 0.85rem 1rem 0.9rem; + border-left: 4px solid var(--blue); + border-radius: 14px; + background: linear-gradient(180deg, rgba(244, 248, 252, 0.98), white); +} + +.archive-list { + list-style: none; + padding: 0; + columns: 3 16rem; + column-gap: 1rem; +} + +.archive-list li { + break-inside: avoid; + margin: 0.2rem 0; +} + +.archive-list a { + text-decoration: none; +} + +.archive-list a:hover { + text-decoration: underline; +} + +body > footer { + margin-bottom: 28px; + text-align: center; + font-family: "Helvetica Neue", Arial, sans-serif; + font-size: 0.85rem; + line-height: 1.6; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(244, 248, 252, 0.92)); +} + +@media (max-width: 860px) { + body > header, + body > main, + body > footer { + width: min(var(--max-width), calc(100vw - 18px)); + padding: 18px 16px; + } + + body > main { + font-size: 1.02rem; + } + + .archive-list { + columns: 2 14rem; + } +} + +@media (max-width: 620px) { + body > header { + align-items: flex-start; + } + + body > header .tagline { + letter-spacing: 0.08em; + } + + .archive-list { + columns: 1; + } +} + +.archive-index h2 { + font-family: var(--font-ui); + font-size: 1.05rem; + border-bottom: 1px solid var(--color-border); + padding-bottom: 0.2rem; + margin: 1.5rem 0 0.5rem; +} + +.month-links { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + margin-bottom: 0.5rem; + font-family: var(--font-ui); + font-size: 0.9rem; +} + +.post-list { + list-style: none; + padding: 0; + margin: 0; +} + +.post-list li { + padding: 0.22rem 0; + border-bottom: 1px dotted #eee; +} + +.post-list a { + text-decoration: none; +} + +.post-list a:hover { + text-decoration: underline; +} + +.post-list .meta { + font-family: var(--font-ui); + font-size: 0.78rem; + color: var(--color-meta); + margin-left: 0.35rem; +} + +/* ── Home page ────────────────────────────────────────── */ + +.home-intro { + margin-bottom: 1.5rem; +} + +.recent-posts { + list-style: none; + padding: 0; +} + +.recent-posts li { + padding: 0.28rem 0; + border-bottom: 1px dotted #eee; +} + +.recent-posts a { + text-decoration: none; +} + +.recent-posts a:hover { + text-decoration: underline; +} + +.recent-posts .meta { + font-family: var(--font-ui); + font-size: 0.78rem; + color: var(--color-meta); + margin-left: 0.35rem; +} + +/* ── Breadcrumb ───────────────────────────────────────── */ + +.breadcrumb { + font-family: var(--font-ui); + font-size: 0.83rem; + color: var(--color-meta); + margin-bottom: 1rem; +} + +.breadcrumb a { + color: var(--color-meta); +} + +.notebook-panel { + border: 1px solid var(--color-border); + border-radius: 6px; + padding: 1rem; + margin: 1rem 0 1.5rem; + background: #fff; +} + +.notebook-panel h2, +.notebook-panel h3 { + margin-top: 0; +} + +.notebook-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; +} + +.notebook-panel .meta { + color: var(--color-meta); + font-family: var(--font-ui); + font-size: 0.85rem; +} + +.plain-list { + padding-left: 1.1rem; +} + +.plain-list li + li { + margin-top: 0.5rem; +} + +/* ── Site footer ──────────────────────────────────────── */ + +.site-footer { + border-top: 1px solid var(--color-border); + margin-top: 2.5rem; + padding: 1.2rem 0 1rem; + text-align: center; + font-family: var(--font-ui); + font-size: 0.75rem; + color: #aaa; +} + +/* ── Responsive ───────────────────────────────────────── */ + +@media (max-width: 600px) { + .post-title { font-size: 1.25rem; } + .site-header { gap: 0.1rem; } + .archive-index h2 { font-size: 1rem; } + .notebook-grid { grid-template-columns: 1fr; } +} diff --git a/theme/themes/talkorigins-modern/assets/toa.ico b/theme/themes/talkorigins-modern/assets/toa.ico new file mode 100644 index 0000000..48e9967 Binary files /dev/null and b/theme/themes/talkorigins-modern/assets/toa.ico differ diff --git a/theme/themes/talkorigins-modern/assets/toa_logo_001_edit_001.png b/theme/themes/talkorigins-modern/assets/toa_logo_001_edit_001.png new file mode 100644 index 0000000..f6007f4 Binary files /dev/null and b/theme/themes/talkorigins-modern/assets/toa_logo_001_edit_001.png differ diff --git a/theme/themes/talkorigins-modern/base.html b/theme/themes/talkorigins-modern/base.html new file mode 100644 index 0000000..00b0205 --- /dev/null +++ b/theme/themes/talkorigins-modern/base.html @@ -0,0 +1,90 @@ + + + + + + {{ page_title }} β€” {{ site_title }} + + + + + + + +
    + + +
    +
    +

    {{ hero_kicker }}

    +
    +
    +

    {{ hero_title }}

    +
    {{ hero_lede }}
    +
    + {{ hero_actions_html }} +
    +
    +
    +

    Framework

    +

    Modular, static, and reviewable

    +

    This theme is intended to prove the modernization line without giving up the archive posture.

    + +
    +
    +
    + +
    +

    Feature cards

    +
    + {{ feature_cards_html }} +
    +
    + +
    +

    Notebook and app content

    + {{ notebook_html }} +
    + {{ section_cards_html }} + {{ app_cards_html }} +
    +
    + +
    +

    Bibliography

    +
      + {{ bibliography_html }} +
    +
    +
    + + +
    + + diff --git a/theme/themes/talkorigins-modern/style.css b/theme/themes/talkorigins-modern/style.css new file mode 100644 index 0000000..abdfe4b --- /dev/null +++ b/theme/themes/talkorigins-modern/style.css @@ -0,0 +1,618 @@ +:root { + --bg: #f2eee6; + --panel: #fffdf9; + --panel-warm: #fbf6ee; + --panel-cool: #f4f8fc; + --ink: #1b2330; + --muted: #5b6471; + --line: rgba(28, 35, 48, 0.12); + --blue: #214f94; + --blue-deep: #163659; + --blue-soft: #dbe8f7; + --gold: #b18d33; + --gold-soft: #f3e4b3; + --brick-soft: #efe1dc; + --shadow: 0 22px 55px rgba(20, 33, 53, 0.1); + --max-width: 1180px; + --article-width: 760px; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + color: var(--ink); + background: + radial-gradient(circle at top left, rgba(33, 79, 148, 0.11), transparent 22%), + radial-gradient(circle at top right, rgba(177, 141, 51, 0.12), transparent 18%), + linear-gradient(180deg, #f7f3ea, var(--bg)); + font-family: Georgia, "Times New Roman", serif; +} + +a { + color: var(--blue); +} + +a:hover { + color: var(--blue-deep); +} + +.skip-link { + position: absolute; + left: -999px; + top: 0; +} + +.skip-link:focus { + left: 1rem; + top: 1rem; + background: var(--ink); + color: white; + padding: 0.7rem 0.9rem; + z-index: 10; +} + +.site-shell { + width: min(var(--max-width), calc(100vw - 28px)); + margin: 0 auto; + padding: 20px 0 40px; +} + +.site-header, +.hero-panel, +.content-panel, +.article-header, +.article-body, +.side-card, +.site-footer, +.continuity-note, +.feature-card, +.section-card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 22px; + box-shadow: var(--shadow); +} + +.site-header, +.site-footer, +.hero-panel, +.content-panel, +.article-header, +.article-body, +.side-card { + padding: 22px 24px; +} + +.site-header { + display: flex; + justify-content: space-between; + gap: 20px; + align-items: center; + margin-bottom: 20px; + border-top: 4px solid var(--gold); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(255, 253, 249, 0.92)); + backdrop-filter: blur(8px); +} + +.brand-kicker, +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--gold); + font: 700 0.78rem/1.2 "Helvetica Neue", Arial, sans-serif; + margin: 0 0 8px; +} + +.brand-title { + display: inline-block; + text-decoration: none; + color: var(--ink); + font: 700 clamp(1.7rem, 3vw, 2.2rem)/1.05 "Helvetica Neue", Arial, sans-serif; + letter-spacing: -0.02em; +} + +.brand-row { + display: flex; + align-items: center; + gap: 16px; +} + +.brand-copy { + min-width: 0; +} + +.brand-mark { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: clamp(58px, 8vw, 82px); + height: clamp(58px, 8vw, 82px); + border-radius: 18px; + padding: 6px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(244, 248, 252, 0.9)); + border: 1px solid rgba(22, 54, 89, 0.12); + box-shadow: 0 14px 28px rgba(20, 33, 53, 0.1); +} + +.brand-mark img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.brand-summary, +.site-footer p, +.continuity-note p, +.feature-card p, +.section-card p, +.article-note, +.article-meta, +.article-sidebar li span { + color: var(--muted); +} + +.top-nav { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.top-nav a, +.button-link { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 0.72rem 1rem; + border-radius: 999px; + border: 1px solid var(--line); + text-decoration: none; + font: 600 0.95rem/1.2 "Helvetica Neue", Arial, sans-serif; +} + +.top-nav a, +.button-link.button-link-secondary { + background: rgba(255, 255, 255, 0.82); + color: var(--ink); +} + +.button-link, +.top-nav a:first-child { + background: linear-gradient(180deg, var(--blue), var(--blue-deep)); + border-color: transparent; + color: white; +} + +.hero-panel { + margin-bottom: 18px; + overflow: hidden; + position: relative; + background: + linear-gradient(135deg, rgba(33, 79, 148, 0.08), transparent 42%), + linear-gradient(180deg, #fffefb, #faf6ee); + border-top: 5px solid var(--blue); +} + +.hero-panel::after { + content: ""; + position: absolute; + inset: auto -7% -32px auto; + width: 280px; + height: 280px; + background: radial-gradient(circle, rgba(177, 141, 51, 0.18), transparent 68%); + pointer-events: none; +} + +.hero-grid, +.article-layout { + display: grid; + gap: 20px; +} + +.hero-grid { + grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.8fr); + align-items: start; +} + +.hero-copy { + position: relative; + z-index: 1; +} + +.hero-panel h1, +.article-header h1 { + margin: 0 0 14px; + font: 700 clamp(2rem, 4vw, 3rem)/1.05 "Helvetica Neue", Arial, sans-serif; + letter-spacing: -0.03em; +} + +.lede { + font-size: 1.08rem; + line-height: 1.72; + color: var(--muted); +} + +.button-row { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 20px; +} + +.continuity-note { + padding: 18px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(244, 248, 252, 0.9)); + border: 1px solid rgba(22, 54, 89, 0.12); +} + +.hero-note { + position: relative; + z-index: 1; +} + +.content-panel { + margin-bottom: 18px; + position: relative; + overflow: hidden; +} + +.content-panel h2, +.continuity-note h2, +.side-card h2 { + margin-top: 0; + font: 700 1.2rem/1.2 "Helvetica Neue", Arial, sans-serif; +} + +.lead-panel { + background: + linear-gradient(180deg, rgba(255, 250, 241, 0.98), rgba(251, 246, 238, 0.98)); + border-top: 5px solid var(--gold); +} + +.direction-panel { + background: + linear-gradient(180deg, rgba(244, 248, 252, 0.98), rgba(255, 255, 255, 0.98)); + border-top: 5px solid var(--blue-soft); +} + +.roadmap-panel { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 241, 233, 0.98)); + border-top: 5px solid rgba(177, 141, 51, 0.45); +} + +.preview-panel { + background: + linear-gradient(180deg, rgba(244, 248, 252, 0.92), rgba(255, 255, 255, 0.96)); +} + +.feature-grid, +.section-grid { + display: grid; + gap: 16px; +} + +.feature-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.section-grid { + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); +} + +.feature-card, +.section-card { + padding: 18px; + position: relative; + overflow: hidden; +} + +.feature-card h3, +.section-card h3 { + margin-top: 0; + font: 700 1.02rem/1.3 "Helvetica Neue", Arial, sans-serif; +} + +.feature-card::before, +.section-card::before { + content: ""; + position: absolute; + inset: 0 auto auto 0; + width: 100%; + height: 4px; + background: var(--gold); + opacity: 0.85; +} + +.lead-panel .feature-card:nth-child(4n + 1), +.direction-panel .feature-card:nth-child(3n + 1), +.section-card:nth-child(4n + 1) { + background: linear-gradient(180deg, rgba(255, 250, 241, 0.98), white); +} + +.lead-panel .feature-card:nth-child(4n + 2), +.direction-panel .feature-card:nth-child(3n + 2), +.section-card:nth-child(4n + 2) { + background: linear-gradient(180deg, rgba(244, 248, 252, 0.98), white); +} + +.lead-panel .feature-card:nth-child(4n + 3), +.direction-panel .feature-card:nth-child(3n + 3), +.section-card:nth-child(4n + 3) { + background: linear-gradient(180deg, rgba(239, 225, 220, 0.55), white); +} + +.lead-panel .feature-card:nth-child(4n + 4), +.section-card:nth-child(4n + 4) { + background: linear-gradient(180deg, rgba(243, 228, 179, 0.32), white); +} + +.article-layout { + grid-template-columns: minmax(0, var(--article-width)) minmax(250px, 1fr); + align-items: start; +} + +.article-header { + background: + linear-gradient(180deg, rgba(255, 250, 241, 0.98), rgba(255, 255, 255, 0.98)); + border-top: 5px solid var(--gold); +} + +.article-body { + line-height: 1.78; + font-size: 1.08rem; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.99), rgba(251, 247, 240, 0.98)); + border-top: 4px solid rgba(33, 79, 148, 0.18); +} + +.article-body p, +.article-body li, +.article-body blockquote { + max-width: 68ch; +} + +.article-body h2, +.article-body h3 { + margin-top: 2rem; + font-family: "Helvetica Neue", Arial, sans-serif; + line-height: 1.2; + color: var(--blue-deep); +} + +.article-body h2 { + font-size: 1.45rem; +} + +.article-body h3 { + font-size: 1.15rem; +} + +.article-body blockquote { + margin: 1.4rem 0; + padding: 0.75rem 1rem; + border-left: 4px solid var(--gold); + background: rgba(177, 141, 51, 0.08); +} + +.article-body code { + font-family: "SFMono-Regular", Consolas, monospace; + font-size: 0.92em; + background: rgba(23, 56, 102, 0.08); + padding: 0.1rem 0.3rem; + border-radius: 4px; +} + +.claim-block { + margin-bottom: 1.8rem; + padding: 1.35rem 1.4rem 1.1rem; + border: 1px solid rgba(22, 54, 89, 0.12); + border-left: 6px solid var(--blue); + border-radius: 18px; + background: + linear-gradient(180deg, rgba(219, 232, 247, 0.55), rgba(255, 255, 255, 0.96)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8); +} + +.claim-block h2 { + margin-top: 0; + margin-bottom: 0.9rem; + color: var(--blue-deep); +} + +.claim-block p:first-of-type { + margin-top: 0; + font-size: 1.22rem; + line-height: 1.55; + color: var(--ink); +} + +.claim-block p:first-of-type::before { + content: "Claim at issue"; + display: block; + margin-bottom: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--gold); + font: 700 0.76rem/1.2 "Helvetica Neue", Arial, sans-serif; +} + +.response-block { + margin-bottom: 1.6rem; +} + +.article-sidebar { + position: sticky; + top: 18px; +} + +.side-card { + margin-bottom: 16px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(244, 248, 252, 0.92)); +} + +.side-card-links { + border-top: 4px solid var(--blue); +} + +.side-card-hooks { + border-top: 4px solid var(--gold); +} + +.link-list { + padding-left: 1.1rem; +} + +.link-list li + li { + margin-top: 0.8rem; +} + +.article-meta, +.article-note, +.footer-note { + font-size: 0.95rem; + line-height: 1.6; +} + +.meta-link { + word-break: break-word; +} + +.site-footer { + display: grid; + gap: 10px; + margin-top: 10px; + color: rgba(255, 255, 255, 0.86); + background: + linear-gradient(180deg, #223247, #142236); + border-color: rgba(255, 255, 255, 0.08); +} + +.site-footer h2 { + margin-top: 0; + color: white; + font: 700 1.1rem/1.2 "Helvetica Neue", Arial, sans-serif; +} + +.site-footer p { + color: rgba(255, 255, 255, 0.88); +} + +.site-footer a, +.site-footer strong, +.footer-note { + color: rgba(255, 255, 255, 0.9); +} + +.compact-list { + margin: 0; + padding-left: 1rem; +} + +.compact-list li + li { + margin-top: 0.5rem; +} + +.notebook-panel { + border: 1px solid var(--line); + border-radius: 8px; + padding: 1.25rem; + margin: 1rem 0 1.5rem; + background: rgba(255, 255, 255, 0.84); +} + +.notebook-panel h2, +.notebook-panel h3 { + margin-top: 0; +} + +.notebook-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; +} + +.notebook-panel .meta { + color: var(--muted); + font-size: 0.9rem; +} + +.plain-list { + padding-left: 1rem; +} + +.plain-list li + li { + margin-top: 0.5rem; +} + +a:focus-visible, +button:focus-visible { + outline: 3px solid #e5b748; + outline-offset: 3px; +} + +@media (max-width: 900px) { + .site-header, + .hero-grid, + .article-layout, + .notebook-grid { + grid-template-columns: 1fr; + } + + .article-sidebar { + position: static; + } + + .site-header { + align-items: flex-start; + } +} + +@media (max-width: 640px) { + .site-shell { + width: min(var(--max-width), calc(100vw - 18px)); + padding-top: 12px; + } + + .site-header, + .site-footer, + .hero-panel, + .content-panel, + .article-header, + .article-body, + .side-card { + padding: 18px; + border-radius: 18px; + } + + .top-nav { + width: 100%; + } + + .brand-row { + align-items: flex-start; + gap: 12px; + } + + .brand-mark { + width: 58px; + height: 58px; + border-radius: 14px; + } + + .top-nav a, + .button-link { + width: 100%; + } +}