Compare commits

..

4 Commits

29 changed files with 3598 additions and 281 deletions

22
.gitignore vendored
View File

@ -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

View File

@ -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

View File

@ -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_<lang>.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.

129
docs/NOTEBOOKS.md Normal file
View File

@ -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.

View File

@ -1,10 +1,12 @@
# Using the evo-edu Framework
# Using SciSiteForge
## 1. Copy Theme Files
Copy `/theme/` into your sites root.
## 1. Choose a Theme
Select one of the shipped presets under `/theme/themes/` and let the build
script materialize it into your sites 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 projects 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.

View File

@ -0,0 +1,164 @@
{
"lang": "en",
"title": "TalkOrigins Archive: Modernized Preview",
"site_title": "TalkOrigins Archive",
"description": "A SciSiteForge proof-of-concept for poc1.talkorigins.org and the TalkOrigins Archive modernization line.",
"license": "CC BY-SA 4.0",
"github_url": "https://git.cns.fyi/welsberr/talkorigins-modern",
"contact_email": "feedback@talkorigins.org",
"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": "Start Here", "href": "#start" },
{ "label": "Key Resources", "href": "#resources" },
{ "label": "Search", "href": "/search/" },
{ "label": "Roadmap", "href": "#roadmap" },
{ "label": "Support", "href": "/foundation/2026-update/" }
],
"hero": {
"kicker": "Archive Preview",
"title": "A clearer front door for the TalkOrigins Archive.",
"lede": "The modernization work keeps the Archive's stable reference role while making it easier for a first-time reader to find explanations, claims, search tools, and sister Foundation resources.",
"actions": [
{ "label": "Start with the overview", "href": "#start", "primary": true },
{ "label": "View the support update", "href": "/foundation/2026-update/", "primary": false }
]
},
"content": {
"feature_cards": [
{
"title": "New to the topic?",
"body": "Start with readable explanations that separate the scientific issues from the rhetoric around them.",
"href": "/faqs/faq-misconceptions/",
"meta": "Archive guide",
"link_label": "Read a sample FAQ"
},
{
"title": "Looking for a specific claim?",
"body": "The Index to Creationist Claims gives readers a direct path from a familiar claim to a response and references.",
"href": "/indexcc/CA100/",
"meta": "Claim index",
"link_label": "Open the Index"
},
{
"title": "Searching across Foundation sites?",
"body": "Foundation search keeps TalkOrigins, TalkDesign, Panda's Thumb, Panda's Thumb MT, and the claim index as distinct selectable corpora.",
"href": "/search/",
"meta": "Search",
"link_label": "Search the sites"
}
],
"section_cards": [
{
"title": "Featured FAQ: Misconceptions",
"body": "A representative long-form Archive article demonstrates how legacy explanatory material can be presented in the modern theme.",
"href": "/faqs/faq-misconceptions/",
"meta": "Archive article",
"link_label": "Read the FAQ"
},
{
"title": "Featured Claim: CA100",
"body": "A representative Index to Creationist Claims entry shows the claim-centered path into evidence and rebuttal material.",
"href": "/indexcc/CA100/",
"meta": "Index claim",
"link_label": "Read the claim entry"
}
],
"bibliography_entries": [
{
"title": "TalkOrigins Archive",
"body": "Stable explanatory articles and FAQs for recurring creation/evolution controversy topics.",
"href": "https://www.talkorigins.org/",
"meta": "primary corpus"
},
{
"title": "Index to Creationist Claims",
"body": "Claim-indexed rebuttal and reference material used as a cross-site search and notebook anchor.",
"href": "https://www.talkorigins.org/indexcc/",
"meta": "claim index"
},
{
"title": "Panda's Thumb MT Archive",
"body": "Definitive corpus for the Movable Type-era Panda's Thumb archive, distinct from the broader scraped corpus.",
"href": "https://pandasthumb.net/",
"meta": "sister corpus"
}
]
},
"notebooks": [
{
"id": "evidence-and-claims",
"title": "Evidence and Claims Reading Path",
"summary": "A guided path can connect stable Archive articles, Index to Creationist Claims entries, search results, and bibliography updates without hiding where each source came from.",
"audience": "interested lay readers, instructors, and site editors",
"goals": [
"Move from a claim to the relevant evidence and archive context",
"Keep stable source material separate from dynamic commentary",
"Show provenance, citations, and review status as part of the reading experience"
],
"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
}
],
"roadmap": [
{
"title": "Replace the preview placeholders with real Archive pages",
"status": "Next",
"body": "Use the modernization theme on selected high-value FAQs and claim pages so readers can evaluate actual content, not just a shell."
},
{
"title": "Promote corpus-aware Foundation search",
"status": "Next",
"body": "Make search visible from each Foundation-associated domain with Index and local-domain corpora selected by default."
},
{
"title": "Connect support messaging to visible modernization work",
"status": "Planned",
"body": "Use the proof-of-concept site to show that the Archive is being actively maintained while preserving the stable Archive and dynamic commentary split."
},
{
"title": "Preserve legacy TalkOrigins URLs",
"status": "Architecture",
"body": "For this long-running Archive, English content should remain reachable at root paths such as /faqs/ and /indexcc/. Future language variants can live under language-code paths while the original English URLs continue to resolve."
},
{
"title": "Add guided reading paths",
"status": "Planned",
"body": "Generalize the notebook pattern into reader-friendly paths that help users move from questions, claims, and classroom needs to reliable Archive material."
}
],
"content_sources": {}
}

12
scisiteforge/__init__.py Normal file
View File

@ -0,0 +1,12 @@
from .content import (
cards_from_config,
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

25
scisiteforge/config.py Normal file
View File

@ -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

300
scisiteforge/content.py Normal file
View File

@ -0,0 +1,300 @@
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 = ""
link_label: str = "Read More"
@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 cards_from_config(items: list[dict[str, Any]], *, default_kind: str) -> list[ContentCard]:
cards: list[ContentCard] = []
for item in items:
if not isinstance(item, dict):
continue
title = str(item.get("title") or item.get("name") or "Item")
cards.append(
ContentCard(
title=title,
body=str(item.get("body") or item.get("description") or item.get("summary") or ""),
href=str(item.get("href") or item.get("url") or ""),
meta=str(item.get("meta") or item.get("kind") or default_kind),
kind=str(item.get("kind") or default_kind),
source=str(item.get("source") or item.get("id") or title.lower().replace(" ", "-")),
link_label=str(item.get("link_label") or item.get("label") or "Read More"),
)
)
return cards
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

110
scisiteforge/notebook.py Normal file
View File

@ -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"<li>{html_escape(goal)}</li>" for goal in notebook.goals)
apps_html = "".join(
(
'<li>'
f'<a href="{html_escape(app.href)}">{html_escape(app.title)}</a>'
f' <span class="meta">{html_escape(app.description)}</span>'
'</li>'
)
for app in notebook.apps
)
cards_html = "".join(
(
'<li>'
f'<strong>{html_escape(card.title)}</strong>'
f' <span class="meta">{html_escape(card.meta)}</span>'
f'<p>{html_escape(card.body)}</p>'
'</li>'
)
for card in cards
)
audience_html = f'<p class="meta">Audience: {html_escape(notebook.audience)}</p>' if notebook.audience else ""
goals_block = f'<div><h3>Goals</h3><ul class="plain-list">{goals_html}</ul></div>' if goals_html else ""
apps_block = f'<div><h3>Apps and Labs</h3><ul class="plain-list">{apps_html}</ul></div>' if apps_html else ""
cards_block = f'<div><h3>Study Material</h3><ul class="plain-list">{cards_html}</ul></div>' if cards_html else ""
return (
f'<article class="notebook-panel" id="{html_escape(notebook.notebook_id)}">'
f'<h2>{html_escape(notebook.title)}</h2>'
f'<p>{html_escape(notebook.summary)}</p>'
f'{audience_html}'
f'<div class="notebook-grid">{goals_block}{apps_block}{cards_block}</div>'
'</article>'
)

31
scisiteforge/render.py Normal file
View File

@ -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")

105
scisiteforge/themes.py Normal file
View File

@ -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,
}

View File

@ -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

View File

@ -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_<lang>.json`
when present.

View File

@ -1,120 +1,270 @@
#!/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 <file> --output <dir> : 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,
cards_from_config,
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": {},
"content": {},
"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'<option value="{lang["code"]}" {"selected" if lang["code"] == config["lang"] else ""}>{lang["name"]}</option>'
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
]
if len(visible_languages) <= 1:
return ""
return "\n".join(
f'<option value="{html_escape(item["code"])}" {"selected" if item["code"] == current_lang else ""}>{html_escape(item["name"])}</option>'
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'<p class="language-policy-note">Planned languages: {planned_names}</p>'
def _roadmap_html(roadmap: list[dict[str, Any]], language_policy: dict[str, Any]) -> str:
items = []
for item in roadmap:
if not isinstance(item, dict):
continue
title = html_escape(item.get("title", "Roadmap item"))
body = html_escape(item.get("body", ""))
status = html_escape(item.get("status", "Planned"))
items.append(f'<li><strong>{title}</strong> <span class="meta">{status}</span><p>{body}</p></li>')
planned_languages = language_policy.get("planned_languages", [])
planned_names = ", ".join(
html_escape(item.get("name", item.get("code", "")))
for item in planned_languages
if isinstance(item, dict) and (item.get("name") or item.get("code"))
)
if planned_names:
items.append(
'<li><strong>Multilingual access</strong> <span class="meta">Planned</span>'
f'<p>Language options will appear only when reviewed coverage exists. Target languages under consideration: {planned_names}.</p></li>'
)
if not items:
return ""
return '<ul class="roadmap-list">' + "\n".join(items) + "</ul>"
def _language_selector_html(language_options: str) -> str:
if not language_options:
return ""
return (
'<select id="lang-switch" aria-label="Language" onchange="switchLanguage(this.value)">'
f"{language_options}"
"</select>"
)
def _hero_actions_html(actions: list[dict[str, Any]]) -> str:
if not actions:
return ""
return "\n".join(
f'<a class="button-link{" button-link-secondary" if not action.get("primary") else ""}" href="{html_escape(action.get("href", "#"))}">{html_escape(action.get("label", "Open"))}</a>'
for action in actions
)
def _navigation_html(navigation: list[dict[str, str]]) -> str:
return "\n".join(
f'<a href="{html_escape(item.get("href", "#"))}">{html_escape(item.get("label", "Link"))}</a>'
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": html_escape(card.link_label),
},
)
)
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))
inline_content = config.get("content", {})
site_content.feature_cards.extend(cards_from_config(inline_content.get("feature_cards", []), default_kind="feature"))
site_content.section_cards.extend(cards_from_config(inline_content.get("section_cards", []), default_kind="section"))
site_content.app_cards.extend(cards_from_config(inline_content.get("app_cards", []), default_kind="app"))
site_content.bibliography_entries.extend(cards_from_config(inline_content.get("bibliography_entries", []), default_kind="bibliography"))
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_selector_html": _language_selector_html(_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'<li><strong>{html_escape(card.title)}</strong> <span class="meta">{html_escape(card.body)}</span></li>'
for card in site_content.bibliography_entries
),
"notebook_html": render_notebooks(notebooks, site_content),
"roadmap_html": _roadmap_html(config.get("roadmap", []), language_policy),
}
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()

View File

@ -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()

View File

@ -1,5 +1,5 @@
<article class="card">
<article class="card feature-card">
<h3>{{ app_title }}</h3>
<p>{{ app_description }}</p>
<a href="/{{ lang }}/apps/{{ app_slug }}/" class="btn">Launch App</a>
<a href="{{ href }}" class="btn button-link">{{ link_label }}</a>
</article>

View File

@ -1,7 +1,7 @@
<div class="section-card">
<div class="section-card card">
<h3>{{ section_title }}</h3>
<p class="meta">{{ section_meta }}</p>
<p class="excerpt">{{ section_excerpt }}</p>
<button class="expand-btn" data-src="/{{ lang }}/notebook/{{ section_path }}">Show</button>
<a href="{{ href }}" class="btn button-link">{{ link_label }}</a>
<div class="content"></div>
</div>

9
tests/conftest.py Normal file
View File

@ -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"))

347
tests/test_scisiteforge.py Normal file
View File

@ -0,0 +1,347 @@
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, cards_from_config, 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_inline_config_cards_can_seed_example_content(self) -> None:
cards = cards_from_config(
[
{
"title": "Foundation Search",
"body": "Corpus-aware search entry point.",
"href": "/search/",
"meta": "workbench",
"link_label": "Search",
}
],
default_kind="feature",
)
self.assertEqual(cards[0].title, "Foundation Search")
self.assertEqual(cards[0].kind, "feature")
self.assertEqual(cards[0].href, "/search/")
self.assertEqual(cards[0].meta, "workbench")
self.assertEqual(cards[0].link_label, "Search")
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.assertNotIn('<select id="lang-switch"', html)
self.assertNotIn('value="es"', html)
self.assertNotIn('value="fr"', html)
self.assertIn("Multilingual access", html)
self.assertIn("Target languages under consideration: 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()

View File

@ -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;
});
});
});

View File

@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="{{ lang }}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ page_title }} — {{ site_title }}</title>
<meta name="description" content="{{ description }}" />
<link rel="stylesheet" href="{{ theme_stylesheet_href }}">
<script src="{{ theme_script_href }}" defer></script>
</head>
<body class="{{ body_class }}">
<div class="{{ site_shell_class }}">
<header class="site-topbar">
<div class="brand-block">
<span class="brand-mark">{{ theme_display_name }}</span>
<a href="/">{{ site_title }}</a>
<p class="brand-summary">{{ theme_description }}</p>
</div>
<nav class="site-nav" aria-label="Primary navigation">
{{ navigation_html }}
<select id="lang-switch" onchange="switchLanguage(this.value)">
{{ language_options }}
</select>
{{ language_policy_html }}
</nav>
</header>
<section class="hero-card">
<p class="eyebrow">{{ hero_kicker }}</p>
<div class="hero-grid">
<div>
<h1>{{ hero_title }}</h1>
<p class="lede">{{ hero_lede }}</p>
<div class="hero-actions">
{{ hero_actions_html }}
</div>
</div>
<div class="stat-grid">
<div class="stat-card">
<strong>Theme</strong>
<span>{{ theme_display_name }}</span>
</div>
<div class="stat-card">
<strong>Language</strong>
<span>{{ lang }}</span>
</div>
<div class="stat-card">
<strong>Sources</strong>
<span>doclift, GroundRecall, Didactopus, CiteGeist</span>
</div>
<div class="stat-card">
<strong>LLM</strong>
<span>GenieHive-backed translation</span>
</div>
</div>
</div>
</section>
<section class="content-card" id="overview">
<p class="section-kicker">Overview</p>
<h2 class="section-heading">What this theme supports</h2>
<div class="feature-grid">
{{ feature_cards_html }}
</div>
</section>
<section class="content-card">
<p class="section-kicker">Notebook and apps</p>
<h2 class="section-heading">Structured sources and learning artifacts</h2>
{{ notebook_html }}
<div class="path-grid">
{{ section_cards_html }}
{{ app_cards_html }}
</div>
</section>
<section class="note-band" id="themes">
<h2 class="section-heading">Theme catalog</h2>
<p>
SciSiteForge ships multiple theme presets so different science sites can share the same
content pipeline while presenting different reading experiences.
</p>
</section>
<section class="footer-card">
<div>
<h3>{{ site_title }}</h3>
<p>{{ license }}</p>
</div>
<small>
<a href="{{ github_url }}">GitHub</a> · <a href="mailto:{{ contact_email }}">Contact</a>
</small>
</section>
</div>
</body>
</html>

View File

@ -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;
}
}

View File

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="{{ lang }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ page_title }} — {{ site_title }}</title>
<meta name="description" content="{{ description }}">
<link rel="stylesheet" href="{{ theme_stylesheet_href }}">
<script src="{{ theme_script_href }}" defer></script>
</head>
<body class="{{ body_class }}">
<header class="site-header">
<span class="site-name"><a href="/">{{ site_title }}</a></span>
<span class="site-tagline">{{ theme_description }}</span>
<select id="lang-switch" onchange="switchLanguage(this.value)">
{{ language_options }}
</select>
{{ language_policy_html }}
</header>
<main>
<div class="card">
<h1>{{ hero_title }}</h1>
<p>{{ hero_lede }}</p>
<div class="button-row">
{{ hero_actions_html }}
</div>
</div>
<section class="topic">
<h2>Feature cards</h2>
<div class="cards-grid">
{{ feature_cards_html }}
</div>
</section>
<section class="topic">
<h2>Notebook and app content</h2>
{{ notebook_html }}
<div class="cards-grid">
{{ section_cards_html }}
{{ app_cards_html }}
</div>
</section>
<section class="topic">
<h2>Bibliography</h2>
<ul class="archive-list bibliography-list">
{{ bibliography_html }}
</ul>
</section>
</main>
<footer class="site-footer">
<p>{{ site_title }} · {{ license }}</p>
</footer>
</body>
</html>

View File

@ -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; }
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="{{ lang }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ page_title }} — {{ site_title }}</title>
<meta name="description" content="{{ description }}" />
<link rel="stylesheet" href="{{ theme_stylesheet_href }}" />
<link rel="icon" href="{{ theme_asset_prefix }}/toa.ico" sizes="any" />
<script src="{{ theme_script_href }}" defer></script>
</head>
<body class="{{ body_class }}">
<a class="skip-link" href="#main">Skip to content</a>
<div class="{{ site_shell_class }}">
<header class="site-header">
<div class="brand-block">
<div class="brand-row">
<a class="brand-mark" href="/" aria-label="{{ site_title }} home">
<img src="{{ theme_asset_prefix }}/toa_logo_001_edit_001.png" alt="{{ site_title }} logo" />
</a>
<div class="brand-copy">
<p class="brand-kicker">{{ hero_kicker }}</p>
<a class="brand-title" href="/">{{ site_title }}</a>
<p class="brand-summary">{{ theme_description }}</p>
</div>
</div>
</div>
<nav class="top-nav" aria-label="Primary navigation">
{{ navigation_html }}
{{ language_selector_html }}
</nav>
</header>
<main id="main">
<section class="hero-panel">
<p class="eyebrow">{{ hero_kicker }}</p>
<div class="hero-grid">
<div class="hero-copy">
<h1>{{ hero_title }}</h1>
<div class="lede">{{ hero_lede }}</div>
<div class="button-row">
{{ hero_actions_html }}
</div>
</div>
<div class="continuity-note hero-note">
<p class="eyebrow">What This Is</p>
<h2>A preview of a working modernization path</h2>
<p>The Archive remains a stable reference library. The redesign makes that library easier to read, search, and connect to sister Foundation resources.</p>
<ul class="link-list compact-list">
<li>Stable explanatory articles</li>
<li>Claim-centered entry points</li>
<li>Search across Foundation corpora</li>
</ul>
</div>
</div>
</section>
<section id="start" class="content-panel lead-panel">
<h2>Start Here</h2>
<div class="feature-grid">
{{ feature_cards_html }}
</div>
</section>
<section id="resources" class="content-panel resources-panel">
<h2>Key Resources</h2>
{{ notebook_html }}
<div class="section-grid">
{{ section_cards_html }}
{{ app_cards_html }}
</div>
</section>
<section id="references" class="content-panel">
<h2>Reference Context</h2>
<ul class="archive-list bibliography-list">
{{ bibliography_html }}
</ul>
</section>
<section id="roadmap" class="content-panel roadmap-panel">
<h2>Roadmap</h2>
{{ roadmap_html }}
</section>
</main>
<footer class="site-footer">
<p class="footer-note"><strong>{{ site_title }}</strong></p>
<p class="footer-note">Exploring the creation/evolution controversy with archived resources, reviewed explanations, and modernized access.</p>
<p class="footer-note">
<a href="/">Home</a> |
<a href="/search/">Search</a> |
<a href="https://www.talkorigins.org/origins/feedback/">Feedback</a> |
<a href="https://www.talkorigins.org/">Legacy Archive</a>
</p>
</footer>
</div>
</body>
</html>

View File

@ -0,0 +1,667 @@
: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 18px 42px rgba(20, 33, 53, 0.09);
--card-shadow: 0 8px 20px rgba(20, 33, 53, 0.06);
--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 {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 22px;
box-shadow: var(--shadow);
}
.feature-card,
.section-card {
background: rgba(255, 255, 255, 0.58);
border: 1px solid var(--line);
border-radius: 10px;
box-shadow: var(--card-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;
align-items: center;
}
.top-nav a,
.button-link,
.top-nav select {
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,
.top-nav select {
background: rgba(255, 255, 255, 0.82);
color: var(--ink);
}
.top-nav select {
font: 600 0.95rem/1.2 "Helvetica Neue", Arial, sans-serif;
}
.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);
}
.resources-panel {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(244, 248, 252, 0.96));
border-top: 5px solid rgba(33, 79, 148, 0.34);
}
.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;
}
.feature-card .button-link,
.section-card .button-link {
min-height: 38px;
margin-top: 0.45rem;
padding: 0.55rem 0.78rem;
border-radius: 8px;
font-size: 0.88rem;
}
.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 rgba(22, 54, 89, 0.14);
border-left: 5px solid var(--blue);
border-radius: 12px;
padding: 1.25rem;
margin: 1rem 0 1.5rem;
background:
linear-gradient(180deg, rgba(244, 248, 252, 0.9), rgba(255, 255, 255, 0.86));
}
.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;
}
.roadmap-list,
.bibliography-list {
margin: 0;
padding-left: 1.2rem;
}
.roadmap-list li + li,
.bibliography-list li + li {
margin-top: 0.9rem;
}
.roadmap-list p {
max-width: 72ch;
margin: 0.35rem 0 0;
color: var(--muted);
line-height: 1.6;
}
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%;
}
}