Add multi-theme site framework and notebook pattern
This commit is contained in:
parent
2c73b072bc
commit
e0e5e414a0
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
53
README.md
53
README.md
|
|
@ -3,14 +3,24 @@
|
|||
A lightweight, responsive, static-site framework for open educational resources in science.
|
||||
|
||||
## 🎯 Purpose
|
||||
This repository provides a reusable foundation for sites like **evo-edu.org**, featuring:
|
||||
This repository provides a reusable foundation for sites like **evo-edu.org**, `www2.talkorigins.org`, and `pandasthumb.net`, featuring:
|
||||
- Mobile-first responsive design
|
||||
- Modular content loading (HTML fragments)
|
||||
- Language-switching support (for multilingual sites)
|
||||
- Language-switching support for multilingual static trees
|
||||
- Integrated app-card and notebook-section templates
|
||||
- Intended to host Javascript web apps
|
||||
- Intended to host JavaScript web apps
|
||||
-- With study guides, alignment documents, reading (links to notebook sections)
|
||||
- Bibliography rendering (journal style + BibTeX)
|
||||
- Multiple theme presets, including:
|
||||
- `evo-edu`
|
||||
- `talkorigins-modern`
|
||||
- `pandasthumb`
|
||||
- Optional content bridges for `doclift`, `GroundRecall`, `Didactopus`, and `CiteGeist`
|
||||
- A generic notebook pattern for topic-level study modules that combine goals,
|
||||
apps, source-derived sections, and bibliographies. See
|
||||
[docs/NOTEBOOKS.md](docs/NOTEBOOKS.md)
|
||||
- Optional translation tooling can use local GenieHive LLM endpoints. See
|
||||
[docs/GENIEHIVE_TRANSLATION.md](docs/GENIEHIVE_TRANSLATION.md)
|
||||
|
||||
## 🛠️ Features
|
||||
- Vanilla HTML/CSS/JS (no heavy frameworks)
|
||||
|
|
@ -25,7 +35,8 @@ This repository provides a reusable foundation for sites like **evo-edu.org**, f
|
|||
## 📂 Structure
|
||||
```
|
||||
/framework
|
||||
├── theme/ # Base layout, CSS, JS
|
||||
├── theme/ # Shared assets plus theme presets
|
||||
│ └── themes/ # Shipped theme variants
|
||||
├── templates/ # Reusable HTML snippets
|
||||
├── docs/ # Usage guide and examples
|
||||
├── scripts/ # Language translation script and example glossary
|
||||
|
|
@ -34,11 +45,12 @@ This repository provides a reusable foundation for sites like **evo-edu.org**, f
|
|||
|
||||
## 🧩 How to Use
|
||||
1. Clone this repo
|
||||
2. Copy `/theme/base.html` into your content project
|
||||
3. Customize navigation and styling
|
||||
4. Use `main.js` for dynamic section loading
|
||||
2. Choose a theme preset and optional content sources in `site.json`
|
||||
3. Build with `scripts/build.py`
|
||||
4. Use `main.js` for dynamic section loading and language switching
|
||||
|
||||
> See [`evo-edu/en`](https://evo-edu.org/en) for a working example.
|
||||
> Use the `talkorigins-modern` preset as the proving ground for the
|
||||
> `www2.talkorigins.org` modernization line.
|
||||
|
||||
## 📜 License
|
||||
MIT — free to use, modify, and redistribute.
|
||||
|
|
@ -62,31 +74,24 @@ obtain the correct bibliography items to display. Content folding
|
|||
with lazy-loading aims to prevent pages from growing in size without
|
||||
bound, and provide modularity in collaboration. Models, programs, and
|
||||
simulations are meant to be implemented in a browser friendly form,
|
||||
either Javascript, WebGL, or similar, so that processing takes place
|
||||
either JavaScript, WebGL, or similar, so that processing takes place
|
||||
client-side, preventing heavy loads on servers. The current model of
|
||||
content I am pursuing with this to to have a site landing page, a
|
||||
content I am pursuing with this is to have a site landing page, a
|
||||
collection of web apps for a topic, and a 'notebook' on that topic
|
||||
that includes didectic material sufficient to ground a naive user in
|
||||
that includes didactic material sufficient to ground a naive user in
|
||||
the relevant concepts that the models, programs, and simulations
|
||||
address.
|
||||
|
||||
The other major feature here is the architecture to support multiple
|
||||
languages. Because my efforts are currently down to one developer, me,
|
||||
this is accomplished by use of one or more locally-hosted multilingual
|
||||
large language models that can automaticlly provide decent translation
|
||||
from a source language to a target language. In my case, the source
|
||||
language will be English, I have a Python program for a batch offline
|
||||
process to traverse the site directory tree, open and parse HTML
|
||||
files, ask for translations at the paragraph level, and assemble those
|
||||
back into the same HTML structure in order to obtain each translated
|
||||
page. Each page will incorporate the language switcher Javascript code
|
||||
in its header, which amounts to redirecting the user to a copy of the
|
||||
site whose static files are in the target language. The translation
|
||||
is to be done via an LLM running locally via Mozilla Llamafile.
|
||||
languages. The core framework can present static language trees and switch
|
||||
between covered locales without requiring a translation backend. Optional
|
||||
translation tooling can use locally-hosted multilingual large language models
|
||||
routed through GenieHive; see
|
||||
[docs/GENIEHIVE_TRANSLATION.md](docs/GENIEHIVE_TRANSLATION.md) for that
|
||||
separate client-side configuration.
|
||||
|
||||
This came together in a hurry, but I hope that other people may find
|
||||
some utility in it to aid in disseminating domain knowledge they and
|
||||
their collaborators may have.
|
||||
|
||||
Wesley R. Elsberry, 2025-10-14
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
# Using the evo-edu Framework
|
||||
# Using SciSiteForge
|
||||
|
||||
## 1. Copy Theme Files
|
||||
Copy `/theme/` into your site’s root.
|
||||
## 1. Choose a Theme
|
||||
Select one of the shipped presets under `/theme/themes/` and let the build
|
||||
script materialize it into your site’s output tree.
|
||||
|
||||
## 2. Create Pages
|
||||
Use `base.html` as a template. Replace `{{ }}` placeholders with actual content.
|
||||
Use the selected theme's `base.html` as a template. Replace `{{ }}` placeholders
|
||||
with actual content.
|
||||
|
||||
## 3. Add Dynamic Behavior (Optional)
|
||||
Include `/theme/main.js` for:
|
||||
|
|
@ -16,4 +18,30 @@ Edit `style.css` to match your project’s visual identity.
|
|||
|
||||
## 5. Multilingual Support
|
||||
- Organize content under `/en/`, `/es/`, etc.
|
||||
- Update language switcher options in `base.html`
|
||||
- Update language switcher options in the theme template
|
||||
- Translation generation is optional and separate from the static build path.
|
||||
See [GENIEHIVE_TRANSLATION.md](GENIEHIVE_TRANSLATION.md) for the optional
|
||||
GenieHive client settings and workflow.
|
||||
|
||||
## 6. Supported Presets and Bridges
|
||||
- `evo-edu` for the learning-platform shell
|
||||
- `talkorigins-modern` for the `www2.talkorigins.org` modernization proof-of-concept
|
||||
- `pandasthumb` for the archive/news shell
|
||||
- Content bridges for `doclift`, `GroundRecall`, `Didactopus`, and `CiteGeist`
|
||||
|
||||
## 7. Notebook Pattern
|
||||
SciSiteForge notebooks are topic-level study modules. A notebook groups:
|
||||
|
||||
- goals and audience
|
||||
- apps or labs
|
||||
- source-derived sections from `doclift`, `GroundRecall`, and `Didactopus`
|
||||
- bibliography entries from `CiteGeist`
|
||||
- provenance-oriented links back to source material
|
||||
|
||||
Use notebooks when a site needs more than loose cards but does not need a full
|
||||
learner application. The evo-edu instance can use this for digital evolution
|
||||
study paths, while TalkOrigins can use the same pattern for claim-to-evidence
|
||||
modules and Panda's Thumb can use it for topic dossiers.
|
||||
|
||||
See [NOTEBOOKS.md](NOTEBOOKS.md) for the generic notebook pattern and the
|
||||
site-specific application notes.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
{
|
||||
"lang": "en",
|
||||
"title": "TalkOrigins Archive: Modernized Preview",
|
||||
"site_title": "TalkOrigins Archive",
|
||||
"license": "CC BY-SA 4.0",
|
||||
"github_url": "https://example.invalid/talkorigins-modern",
|
||||
"contact_email": "admin@example.invalid",
|
||||
"theme": "talkorigins-modern",
|
||||
"languages": [
|
||||
{ "code": "en", "name": "English", "coverage": true },
|
||||
{ "code": "es", "name": "Español", "coverage": false },
|
||||
{ "code": "fr", "name": "Français", "coverage": false },
|
||||
{ "code": "pt", "name": "Português", "coverage": false },
|
||||
{ "code": "de", "name": "Deutsch", "coverage": false },
|
||||
{ "code": "it", "name": "Italiano", "coverage": false },
|
||||
{ "code": "ru", "name": "Русский", "coverage": false },
|
||||
{ "code": "zh", "name": "中文", "coverage": false },
|
||||
{ "code": "ja", "name": "日本語", "coverage": false },
|
||||
{ "code": "ar", "name": "العربية", "coverage": false },
|
||||
{ "code": "hi", "name": "हिन्दी", "coverage": false }
|
||||
],
|
||||
"language_policy": {
|
||||
"planned_languages": [
|
||||
{ "code": "es", "name": "Español" },
|
||||
{ "code": "fr", "name": "Français" },
|
||||
{ "code": "pt", "name": "Português" },
|
||||
{ "code": "de", "name": "Deutsch" },
|
||||
{ "code": "it", "name": "Italiano" },
|
||||
{ "code": "ru", "name": "Русский" },
|
||||
{ "code": "zh", "name": "中文" },
|
||||
{ "code": "ja", "name": "日本語" },
|
||||
{ "code": "ar", "name": "العربية" },
|
||||
{ "code": "hi", "name": "हिन्दी" }
|
||||
]
|
||||
},
|
||||
"navigation": [
|
||||
{ "label": "Home", "href": "/" },
|
||||
{ "label": "Support", "href": "/foundation/2026-update/" },
|
||||
{ "label": "Search", "href": "/search/" }
|
||||
],
|
||||
"hero": {
|
||||
"kicker": "Archive Preview",
|
||||
"title": "A cleaner, more readable TalkOrigins Archive that still feels like the Archive.",
|
||||
"lede": "Use this preset as the proving ground for the www2.talkorigins.org modernization line.",
|
||||
"actions": [
|
||||
{ "label": "Open the preview", "href": "/#overview", "primary": true },
|
||||
{ "label": "View the framework", "href": "https://example.invalid/scisiteforge", "primary": false }
|
||||
]
|
||||
},
|
||||
"notebooks": [
|
||||
{
|
||||
"id": "evidence-and-claims",
|
||||
"title": "Evidence and Claims Notebook",
|
||||
"summary": "A reusable study module that can connect stable Archive articles, Index to Creationist Claims entries, guided concepts, and bibliography updates.",
|
||||
"audience": "self-learners, instructors, and board-reviewed site editors",
|
||||
"goals": [
|
||||
"Move from a claim to the relevant evidence and archive context",
|
||||
"Keep stable source material separate from dynamic commentary",
|
||||
"Expose provenance, citations, and review status as first-class study material"
|
||||
],
|
||||
"apps": [
|
||||
{
|
||||
"title": "Public search",
|
||||
"href": "/search/",
|
||||
"description": "Search across Foundation corpora and the Index to Creationist Claims"
|
||||
}
|
||||
],
|
||||
"source_kinds": ["section", "notebook", "app", "bibliography"],
|
||||
"max_items": 8
|
||||
}
|
||||
],
|
||||
"content_sources": {}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
from .content import (
|
||||
ContentCard,
|
||||
SiteContent,
|
||||
load_citegeist_cards,
|
||||
load_didactopus_cards,
|
||||
load_doclift_cards,
|
||||
load_groundrecall_cards,
|
||||
)
|
||||
from .render import render_template
|
||||
from .notebook import Notebook, NotebookApp, load_notebooks, render_notebooks
|
||||
from .themes import ThemeSpec, available_themes, get_theme, materialize_theme
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .render import html_escape
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ContentCard:
|
||||
title: str
|
||||
body: str
|
||||
href: str = ""
|
||||
meta: str = ""
|
||||
kind: str = "feature"
|
||||
source: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SiteContent:
|
||||
feature_cards: list[ContentCard] = field(default_factory=list)
|
||||
section_cards: list[ContentCard] = field(default_factory=list)
|
||||
app_cards: list[ContentCard] = field(default_factory=list)
|
||||
bibliography_entries: list[ContentCard] = field(default_factory=list)
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _first_paragraph(text: str) -> str:
|
||||
paragraphs = [chunk.strip() for chunk in re.split(r"\n\s*\n", text) if chunk.strip()]
|
||||
return paragraphs[0] if paragraphs else text.strip()
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _read_yaml(path: Path) -> Any:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
try: # pragma: no cover - exercised only if PyYAML is installed
|
||||
import yaml # type: ignore
|
||||
|
||||
return yaml.safe_load(text) or {}
|
||||
except Exception:
|
||||
stripped = text.strip()
|
||||
if stripped.startswith("{") or stripped.startswith("["):
|
||||
return json.loads(stripped)
|
||||
return _parse_minimal_yaml(text)
|
||||
|
||||
|
||||
def _parse_scalar(value: str) -> Any:
|
||||
value = value.strip()
|
||||
if value in {"", "null", "~"}:
|
||||
return None
|
||||
if value == "[]":
|
||||
return []
|
||||
if value.startswith("[") and value.endswith("]"):
|
||||
inner = value[1:-1].strip()
|
||||
if not inner:
|
||||
return []
|
||||
return [_parse_scalar(part) for part in inner.split(",")]
|
||||
if value.startswith('"') and value.endswith('"'):
|
||||
return value[1:-1]
|
||||
if value.startswith("'") and value.endswith("'"):
|
||||
return value[1:-1]
|
||||
if value.isdigit():
|
||||
return int(value)
|
||||
if value.lower() in {"true", "false"}:
|
||||
return value.lower() == "true"
|
||||
return value
|
||||
|
||||
|
||||
def _parse_minimal_yaml(text: str) -> dict[str, Any]:
|
||||
lines = [line.rstrip() for line in text.splitlines() if line.strip() and not line.strip().startswith("#")]
|
||||
root: dict[str, Any] = {}
|
||||
current_key: str | None = None
|
||||
current_item: dict[str, Any] | None = None
|
||||
|
||||
for index, raw in enumerate(lines):
|
||||
stripped = raw.lstrip(" ")
|
||||
indent = len(raw) - len(stripped)
|
||||
if indent == 0:
|
||||
current_item = None
|
||||
if ":" not in stripped:
|
||||
continue
|
||||
key, value = stripped.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if value:
|
||||
root[key] = _parse_scalar(value)
|
||||
else:
|
||||
next_line = lines[index + 1] if index + 1 < len(lines) else ""
|
||||
root[key] = [] if next_line.lstrip(" ").startswith("- ") else {}
|
||||
current_key = key
|
||||
continue
|
||||
|
||||
if current_key is None:
|
||||
continue
|
||||
|
||||
container = root.get(current_key)
|
||||
if isinstance(container, list) and stripped.startswith("- "):
|
||||
item_text = stripped[2:].strip()
|
||||
if not item_text:
|
||||
current_item = {}
|
||||
container.append(current_item)
|
||||
elif ":" in item_text:
|
||||
item_key, item_value = item_text.split(":", 1)
|
||||
current_item = {item_key.strip(): _parse_scalar(item_value)}
|
||||
container.append(current_item)
|
||||
else:
|
||||
current_item = None
|
||||
container.append(_parse_scalar(item_text))
|
||||
continue
|
||||
|
||||
target = current_item if isinstance(current_item, dict) else container
|
||||
if isinstance(target, dict) and ":" in stripped:
|
||||
key, value = stripped.split(":", 1)
|
||||
target[key.strip()] = _parse_scalar(value)
|
||||
elif isinstance(target, list) and stripped.startswith("- "):
|
||||
target.append(_parse_scalar(stripped[2:]))
|
||||
|
||||
return root
|
||||
|
||||
|
||||
def load_doclift_cards(bundle_root: str | Path) -> list[ContentCard]:
|
||||
base = Path(bundle_root)
|
||||
manifest = _read_json(base / "manifest.json")
|
||||
cards: list[ContentCard] = []
|
||||
for item in manifest.get("documents", []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
title = str(item.get("title") or item.get("document_id") or "Document")
|
||||
body = str(item.get("summary") or item.get("description") or item.get("document_kind") or "")
|
||||
markdown_path = item.get("markdown_path")
|
||||
source_href = str(item.get("canonical_url") or item.get("source_path") or "")
|
||||
if markdown_path:
|
||||
md_path = base / str(markdown_path)
|
||||
if md_path.exists():
|
||||
body = _first_paragraph(md_path.read_text(encoding="utf-8"))
|
||||
cards.append(
|
||||
ContentCard(
|
||||
title=title,
|
||||
body=body,
|
||||
href=source_href,
|
||||
meta=str(item.get("document_kind") or "document"),
|
||||
kind="notebook",
|
||||
source=str(item.get("document_id") or title.lower().replace(" ", "-")),
|
||||
)
|
||||
)
|
||||
return cards
|
||||
|
||||
|
||||
def load_groundrecall_cards(bundle_root: str | Path) -> list[ContentCard]:
|
||||
base = Path(bundle_root)
|
||||
bundle_path = base / "groundrecall_query_bundle.json"
|
||||
if not bundle_path.exists():
|
||||
bundle_path = base / "exports" / "codex" / "codex_bundle.json"
|
||||
if not bundle_path.exists():
|
||||
return []
|
||||
payload = _read_json(bundle_path)
|
||||
concept = payload.get("concept") or {}
|
||||
title = str(concept.get("title") or payload.get("title") or "GroundRecall concept")
|
||||
body = str(payload.get("summary") or payload.get("explanation") or payload.get("body") or "")
|
||||
claims = payload.get("claims") or payload.get("related_claims") or []
|
||||
claim_count = len(claims) if isinstance(claims, list) else 0
|
||||
cards = [
|
||||
ContentCard(
|
||||
title=title,
|
||||
body=body or f"{claim_count} related claims and observations are bundled here.",
|
||||
href=str(payload.get("source_url") or ""),
|
||||
meta=f"GroundRecall bundle · {claim_count} claims",
|
||||
kind="section",
|
||||
source=str(concept.get("concept_id") or title.lower().replace(" ", "-")),
|
||||
)
|
||||
]
|
||||
for claim in claims if isinstance(claims, list) else []:
|
||||
if not isinstance(claim, dict):
|
||||
continue
|
||||
cards.append(
|
||||
ContentCard(
|
||||
title=str(claim.get("claim_text") or claim.get("title") or "Claim"),
|
||||
body=str(claim.get("support") or claim.get("notes") or ""),
|
||||
href=str(claim.get("source_url") or ""),
|
||||
meta=str(claim.get("claim_kind") or "claim"),
|
||||
kind="section",
|
||||
source=str(claim.get("claim_id") or claim.get("id") or ""),
|
||||
)
|
||||
)
|
||||
return cards
|
||||
|
||||
|
||||
def load_didactopus_cards(pack_root: str | Path) -> list[ContentCard]:
|
||||
base = Path(pack_root)
|
||||
pack_path = base / "pack.yaml"
|
||||
concepts_path = base / "concepts.yaml"
|
||||
if not pack_path.exists() or not concepts_path.exists():
|
||||
return []
|
||||
pack = _read_yaml(pack_path) or {}
|
||||
concepts = _read_yaml(concepts_path) or {}
|
||||
cards: list[ContentCard] = []
|
||||
for concept in concepts.get("concepts", []):
|
||||
if not isinstance(concept, dict):
|
||||
continue
|
||||
title = str(concept.get("title") or concept.get("id") or "Concept")
|
||||
description = str(concept.get("description") or "")
|
||||
prerequisites = concept.get("prerequisites") or []
|
||||
prereq_text = ", ".join(str(item) for item in prerequisites) if prerequisites else "None"
|
||||
body = description or f"Prerequisites: {prereq_text}."
|
||||
cards.append(
|
||||
ContentCard(
|
||||
title=title,
|
||||
body=body,
|
||||
href=str(pack.get("display_name") or pack.get("name") or ""),
|
||||
meta=f"Didactopus concept · {prereq_text}",
|
||||
kind="app",
|
||||
source=str(concept.get("id") or title.lower().replace(" ", "-")),
|
||||
)
|
||||
)
|
||||
return cards
|
||||
|
||||
|
||||
def load_citegeist_cards(source_root: str | Path) -> list[ContentCard]:
|
||||
root = Path(source_root)
|
||||
bib_files = sorted(
|
||||
path
|
||||
for path in root.rglob("*.bib")
|
||||
if path.is_file() and not path.name.endswith("-bak.bib") and not path.name.startswith(".")
|
||||
)
|
||||
if not bib_files:
|
||||
return []
|
||||
cards: list[ContentCard] = []
|
||||
try:
|
||||
from citegeist.bibtex import parse_bibtex # type: ignore
|
||||
except Exception:
|
||||
parse_bibtex = None
|
||||
for bib_path in bib_files:
|
||||
text = bib_path.read_text(encoding="utf-8")
|
||||
entries = parse_bibtex(text) if parse_bibtex is not None else _fallback_parse_bibtex(text)
|
||||
for entry in entries:
|
||||
data = entry if isinstance(entry, dict) else entry.__dict__
|
||||
title = str(data.get("title") or data.get("citation_key") or "Reference")
|
||||
author = str(data.get("author") or data.get("editor") or "")
|
||||
year = str(data.get("year") or "")
|
||||
body = " · ".join(part for part in [author, year] if part).strip()
|
||||
cards.append(
|
||||
ContentCard(
|
||||
title=title,
|
||||
body=body or bib_path.name,
|
||||
href=str(bib_path.relative_to(root)),
|
||||
meta="CiteGeist bibliography",
|
||||
kind="bibliography",
|
||||
source=str(data.get("citation_key") or title.lower().replace(" ", "-")),
|
||||
)
|
||||
)
|
||||
return cards
|
||||
|
||||
|
||||
def _fallback_parse_bibtex(text: str) -> list[dict[str, str]]:
|
||||
entries: list[dict[str, str]] = []
|
||||
current: dict[str, str] | None = None
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
if stripped.startswith("@") and "{" in stripped:
|
||||
if current:
|
||||
entries.append(current)
|
||||
kind, rest = stripped[1:].split("{", 1)
|
||||
key = rest.split(",", 1)[0].strip()
|
||||
current = {"entry_type": kind.strip(), "citation_key": key}
|
||||
continue
|
||||
if current and "=" in stripped:
|
||||
field, value = stripped.split("=", 1)
|
||||
current[field.strip().lower()] = value.strip().strip(",{}")
|
||||
if current:
|
||||
entries.append(current)
|
||||
return entries
|
||||
|
|
@ -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>'
|
||||
)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
||||
```bash
|
||||
python3 scripts/build.py --config site.json --output /tmp/scisiteforge-site
|
||||
```
|
||||
|
||||
The shipped theme presets are:
|
||||
|
||||
cd domain_di/framework
|
||||
python build.py --init
|
||||
→ creates site.json
|
||||
|
||||
|
||||
Build English site:
|
||||
|
||||
|
||||
python build.py --config site.json --output ../content/en/
|
||||
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
- `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.
|
||||
|
|
|
|||
282
scripts/build.py
282
scripts/build.py
|
|
@ -1,120 +1,224 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Static site generator for evo-edu framework.
|
||||
"""Static site generator for SciSiteForge."""
|
||||
|
||||
Two modes:
|
||||
1. --init : Prompt user for site config and save to site.json
|
||||
2. --config <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,
|
||||
load_citegeist_cards,
|
||||
load_didactopus_cards,
|
||||
load_doclift_cards,
|
||||
load_groundrecall_cards,
|
||||
)
|
||||
from scisiteforge.render import html_escape, read_text, render_template, write_text
|
||||
from scisiteforge.notebook import load_notebooks, render_notebooks
|
||||
from scisiteforge.themes import available_themes, get_theme, materialize_theme
|
||||
|
||||
|
||||
def _prompt_for_config() -> dict[str, Any]:
|
||||
print("=== SciSiteForge Site Config ===")
|
||||
themes = available_themes()
|
||||
print("Available themes:")
|
||||
for theme in themes:
|
||||
print(f" - {theme.name}: {theme.description}")
|
||||
theme_name = input(f"Theme name (default: {DEFAULT_THEME}): ").strip() or DEFAULT_THEME
|
||||
languages_input = input("Languages (code:name pairs, comma-separated; default: en:English): ").strip() or "en:English"
|
||||
languages = []
|
||||
for pair in languages_input.split(","):
|
||||
code, name = pair.strip().split(":", 1)
|
||||
languages.append({"code": code.strip(), "name": name.strip()})
|
||||
return {
|
||||
"lang": input("Language code (default: en): ").strip() or "en",
|
||||
"title": input("Page title (default: SciSiteForge Preview): ").strip() or "SciSiteForge Preview",
|
||||
"site_title": input("Site name (default: SciSiteForge): ").strip() or "SciSiteForge",
|
||||
"license": input("License text (default: CC BY-SA 4.0): ").strip() or "CC BY-SA 4.0",
|
||||
"github_url": input("GitHub URL (optional): ").strip() or "https://github.com/",
|
||||
"contact_email": input("Contact email (optional): ").strip() or "admin@example.org",
|
||||
"theme": theme_name,
|
||||
"languages": languages,
|
||||
"navigation": [
|
||||
{"label": "Home", "href": "/"},
|
||||
],
|
||||
"hero": {
|
||||
"kicker": "Preview",
|
||||
"title": "A site shell that can adapt to more than one audience.",
|
||||
"lede": "SciSiteForge now supports multiple theme presets and local content loaders for reusable science sites.",
|
||||
"actions": [
|
||||
{"label": "Read the overview", "href": "#overview", "primary": True},
|
||||
{"label": "Theme catalog", "href": "#themes", "primary": False},
|
||||
],
|
||||
},
|
||||
"content_sources": {},
|
||||
"notebooks": [],
|
||||
}
|
||||
|
||||
# Language options
|
||||
print("\nLanguage switcher options (e.g., en:English, es:Español):")
|
||||
lang_input = input("Enter as 'code:name' pairs (comma-separated): ") or "en:English"
|
||||
for pair in lang_input.split(','):
|
||||
code, name = pair.strip().split(':', 1)
|
||||
config["languages"].append({"code": code, "name": name})
|
||||
lang_options = "\n".join([
|
||||
f'<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
|
||||
def _language_options_html(languages: list[dict[str, str]], current_lang: str) -> str:
|
||||
visible_languages = [
|
||||
item
|
||||
for item in languages
|
||||
if item.get("coverage", True) or item.get("code") == current_lang
|
||||
]
|
||||
return "\n".join(
|
||||
f'<option value="{html_escape(item["code"])}" {"selected" if item["code"] == current_lang else ""}>{html_escape(item["name"])}</option>'
|
||||
for item in visible_languages
|
||||
)
|
||||
|
||||
# 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}}"):]
|
||||
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>'
|
||||
|
||||
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 _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": "Open",
|
||||
},
|
||||
)
|
||||
)
|
||||
return "\n".join(rendered)
|
||||
|
||||
|
||||
def build_site(config_file: str | Path, output_dir: str | Path) -> dict[str, Any]:
|
||||
config = load_config(config_file)
|
||||
theme = get_theme(config.get("theme"))
|
||||
out_path = Path(output_dir)
|
||||
out_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy theme assets
|
||||
theme_src = Path(__file__).parent / "theme"
|
||||
for asset in ["style.css", "main.js"]:
|
||||
shutil.copy(theme_src / asset, out_path / asset)
|
||||
theme_context = materialize_theme(theme, out_path)
|
||||
template = read_text(theme.template_path)
|
||||
|
||||
# Render base.html → index.html (example)
|
||||
with open(theme_src / "base.html", 'r', encoding='utf-8') as f:
|
||||
template = f.read()
|
||||
content_sources = config.get("content_sources", {})
|
||||
site_content = SiteContent()
|
||||
if source := content_sources.get("doclift_bundle"):
|
||||
site_content.section_cards.extend(load_doclift_cards(source))
|
||||
if source := content_sources.get("groundrecall_bundle"):
|
||||
site_content.section_cards.extend(load_groundrecall_cards(source))
|
||||
if source := content_sources.get("didactopus_pack"):
|
||||
site_content.feature_cards.extend(load_didactopus_cards(source))
|
||||
if source := content_sources.get("bibliography"):
|
||||
site_content.bibliography_entries.extend(load_citegeist_cards(source))
|
||||
notebooks = load_notebooks(config)
|
||||
|
||||
rendered = render_template(template, config)
|
||||
with open(out_path / "index.html", 'w', encoding='utf-8') as f:
|
||||
f.write(rendered)
|
||||
languages = config.get("languages", [{"code": config.get("lang", "en"), "name": "English", "coverage": True}])
|
||||
language_policy = config.get("language_policy", {})
|
||||
hero = config.get("hero", {})
|
||||
page_context = {
|
||||
"lang": config.get("lang", "en"),
|
||||
"page_title": html_escape(config.get("title", config.get("site_title", "SciSiteForge"))),
|
||||
"site_title": html_escape(config.get("site_title", "SciSiteForge")),
|
||||
"description": html_escape(config.get("description", "")),
|
||||
"license": html_escape(config.get("license", "CC BY-SA 4.0")),
|
||||
"github_url": html_escape(config.get("github_url", "")),
|
||||
"contact_email": html_escape(config.get("contact_email", "")),
|
||||
"theme_name": html_escape(theme.name),
|
||||
"theme_display_name": html_escape(theme.display_name),
|
||||
"theme_description": html_escape(theme.description),
|
||||
"theme_stylesheet_href": theme_context["theme_stylesheet_href"],
|
||||
"theme_script_href": theme_context["theme_script_href"],
|
||||
"theme_asset_prefix": theme_context["theme_asset_prefix"],
|
||||
"body_class": html_escape(theme.body_class),
|
||||
"site_shell_class": html_escape(theme.shell_class),
|
||||
"page_class": html_escape(theme.page_class),
|
||||
"navigation_html": _navigation_html(config.get("navigation", [])),
|
||||
"language_options": _language_options_html(languages, config.get("lang", "en")),
|
||||
"language_policy_html": _language_policy_html(language_policy),
|
||||
"hero_kicker": html_escape(hero.get("kicker", theme.display_name)),
|
||||
"hero_title": html_escape(hero.get("title", config.get("title", ""))),
|
||||
"hero_lede": html_escape(hero.get("lede", config.get("description", ""))),
|
||||
"hero_actions_html": _hero_actions_html(hero.get("actions", [])),
|
||||
"feature_cards_html": _render_cards(site_content.feature_cards, Path(__file__).parent.parent / "templates" / "app-card.html", config.get("lang", "en")),
|
||||
"section_cards_html": _render_cards(site_content.section_cards, Path(__file__).parent.parent / "templates" / "notebook-section.html", config.get("lang", "en")),
|
||||
"app_cards_html": _render_cards(site_content.app_cards, Path(__file__).parent.parent / "templates" / "app-card.html", config.get("lang", "en")),
|
||||
"bibliography_html": "\n".join(
|
||||
f'<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),
|
||||
}
|
||||
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"]}
|
||||
|
||||
print(f"✅ Site built in {output_dir}")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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="/{{ lang }}/apps/{{ app_slug }}/" class="btn button-link">Launch App</a>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<button class="expand-btn btn button-link" data-src="/{{ lang }}/notebook/{{ section_path }}">Show</button>
|
||||
<div class="content"></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
@ -0,0 +1,326 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
|
||||
import build
|
||||
import translate_site
|
||||
|
||||
from scisiteforge.content import SiteContent, load_citegeist_cards, load_didactopus_cards, load_doclift_cards, load_groundrecall_cards
|
||||
from scisiteforge.notebook import load_notebooks, render_notebooks
|
||||
from scisiteforge.themes import get_theme, materialize_theme
|
||||
from scisiteforge.translations import GenieHiveTranslator, TranslationConfig
|
||||
|
||||
|
||||
class SciSiteForgeTests(unittest.TestCase):
|
||||
def test_theme_materialization_copies_theme_assets(self) -> None:
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
with TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
theme = get_theme("talkorigins-modern")
|
||||
payload = materialize_theme(theme, tmp_path)
|
||||
|
||||
self.assertTrue((tmp_path / "theme" / "style.css").exists())
|
||||
self.assertTrue((tmp_path / "theme" / "main.js").exists())
|
||||
self.assertTrue((tmp_path / "theme" / "assets" / "toa.ico").exists())
|
||||
self.assertTrue((tmp_path / "theme" / "assets" / "toa_logo_001_edit_001.png").exists())
|
||||
self.assertEqual(payload["theme_name"], "talkorigins-modern")
|
||||
|
||||
def test_content_loaders_parse_local_repo_artifacts(self) -> None:
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
with TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
|
||||
doclift_root = tmp_path / "doclift"
|
||||
doclift_root.mkdir()
|
||||
(doclift_root / "manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [
|
||||
{
|
||||
"document_id": "doc-1",
|
||||
"title": "Legacy Document",
|
||||
"document_kind": "article",
|
||||
"markdown_path": "documents/doc-1/document.md",
|
||||
"source_path": "source/doc-1.html",
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(doclift_root / "documents" / "doc-1").mkdir(parents=True)
|
||||
(doclift_root / "documents" / "doc-1" / "document.md").write_text("First paragraph.\n\nSecond.", encoding="utf-8")
|
||||
|
||||
groundrecall_root = tmp_path / "groundrecall"
|
||||
groundrecall_root.mkdir()
|
||||
(groundrecall_root / "groundrecall_query_bundle.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"concept": {"concept_id": "concept::topic", "title": "GroundRecall Topic"},
|
||||
"summary": "Grounded summary.",
|
||||
"claims": [{"claim_id": "clm-1", "claim_text": "Claim one", "claim_kind": "summary"}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
didactopus_root = tmp_path / "didactopus"
|
||||
didactopus_root.mkdir()
|
||||
(didactopus_root / "pack.yaml").write_text("name: test-pack\ndisplay_name: Test Pack\n", encoding="utf-8")
|
||||
(didactopus_root / "concepts.yaml").write_text(
|
||||
"concepts:\n - id: prior\n title: Prior\n description: Previous knowledge.\n prerequisites: []\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
citegeist_root = tmp_path / "citegeist"
|
||||
citegeist_root.mkdir()
|
||||
(citegeist_root / "refs.bib").write_text(
|
||||
"""@article{smith2024,\n title = {A Study},\n author = {Smith, Jane and Roe, John},\n year = {2024}\n}\n""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
doclift_cards = load_doclift_cards(doclift_root)
|
||||
groundrecall_cards = load_groundrecall_cards(groundrecall_root)
|
||||
didactopus_cards = load_didactopus_cards(didactopus_root)
|
||||
citegeist_cards = load_citegeist_cards(citegeist_root)
|
||||
|
||||
self.assertEqual(doclift_cards[0].title, "Legacy Document")
|
||||
self.assertEqual(doclift_cards[0].body, "First paragraph.")
|
||||
self.assertEqual(groundrecall_cards[0].title, "GroundRecall Topic")
|
||||
self.assertEqual(groundrecall_cards[1].title, "Claim one")
|
||||
self.assertEqual(didactopus_cards[0].title, "Prior")
|
||||
self.assertEqual(citegeist_cards[0].title, "A Study")
|
||||
|
||||
def test_build_site_renders_selected_theme_and_content(self) -> None:
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
with TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
content_root = tmp_path / "content"
|
||||
content_root.mkdir()
|
||||
|
||||
doclift_root = content_root / "doclift"
|
||||
doclift_root.mkdir()
|
||||
(doclift_root / "manifest.json").write_text(
|
||||
json.dumps({"documents": [{"document_id": "doc-1", "title": "Legacy Document", "markdown_path": "documents/doc-1/document.md"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(doclift_root / "documents" / "doc-1").mkdir(parents=True)
|
||||
(doclift_root / "documents" / "doc-1" / "document.md").write_text("Doclift content.", encoding="utf-8")
|
||||
|
||||
didactopus_root = content_root / "didactopus"
|
||||
didactopus_root.mkdir()
|
||||
(didactopus_root / "pack.yaml").write_text("name: test-pack\ndisplay_name: Test Pack\n", encoding="utf-8")
|
||||
(didactopus_root / "concepts.yaml").write_text(
|
||||
"concepts:\n - id: prior\n title: Prior\n description: Previous knowledge.\n prerequisites: []\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
groundrecall_root = content_root / "groundrecall"
|
||||
groundrecall_root.mkdir()
|
||||
(groundrecall_root / "groundrecall_query_bundle.json").write_text(
|
||||
json.dumps({"concept": {"concept_id": "concept::topic", "title": "GroundRecall Topic"}, "summary": "Grounded summary."}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
citegeist_root = content_root / "citegeist"
|
||||
citegeist_root.mkdir()
|
||||
(citegeist_root / "refs.bib").write_text(
|
||||
"""@article{smith2024,\n title = {A Study},\n author = {Smith, Jane and Roe, John},\n year = {2024}\n}\n""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = {
|
||||
"lang": "en",
|
||||
"title": "TalkOrigins Preview",
|
||||
"site_title": "TalkOrigins Archive",
|
||||
"license": "CC BY-SA 4.0",
|
||||
"github_url": "https://example.invalid",
|
||||
"contact_email": "admin@example.invalid",
|
||||
"theme": "talkorigins-modern",
|
||||
"languages": [{"code": "en", "name": "English"}],
|
||||
"navigation": [{"label": "Home", "href": "/"}],
|
||||
"hero": {
|
||||
"kicker": "Archive Preview",
|
||||
"title": "Modernized, reviewable, and still archive-first.",
|
||||
"lede": "Proof-of-concept output from SciSiteForge.",
|
||||
"actions": [{"label": "Open", "href": "#overview", "primary": True}],
|
||||
},
|
||||
"content_sources": {
|
||||
"doclift_bundle": str(doclift_root),
|
||||
"groundrecall_bundle": str(groundrecall_root),
|
||||
"didactopus_pack": str(didactopus_root),
|
||||
"bibliography": str(citegeist_root),
|
||||
},
|
||||
}
|
||||
config_path = tmp_path / "site.json"
|
||||
config_path.write_text(json.dumps(config), encoding="utf-8")
|
||||
|
||||
out_dir = tmp_path / "out"
|
||||
result = build.build_site(config_path, out_dir)
|
||||
|
||||
html = (out_dir / "index.html").read_text(encoding="utf-8")
|
||||
self.assertEqual(result["theme"], "talkorigins-modern")
|
||||
self.assertIn("TalkOrigins Preview", html)
|
||||
self.assertIn("Legacy Document", html)
|
||||
self.assertIn("GroundRecall Topic", html)
|
||||
self.assertIn("Prior", html)
|
||||
self.assertIn("A Study", html)
|
||||
self.assertTrue((out_dir / "theme" / "assets" / "toa.ico").exists())
|
||||
|
||||
def test_build_site_filters_languages_by_coverage_and_shows_planned_list(self) -> None:
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
with TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
config = {
|
||||
"lang": "en",
|
||||
"title": "TalkOrigins Preview",
|
||||
"site_title": "TalkOrigins Archive",
|
||||
"license": "CC BY-SA 4.0",
|
||||
"github_url": "https://example.invalid",
|
||||
"contact_email": "admin@example.invalid",
|
||||
"theme": "talkorigins-modern",
|
||||
"languages": [
|
||||
{"code": "en", "name": "English", "coverage": True},
|
||||
{"code": "es", "name": "Español", "coverage": False},
|
||||
{"code": "fr", "name": "Français", "coverage": False},
|
||||
],
|
||||
"language_policy": {
|
||||
"planned_languages": [
|
||||
{"code": "es", "name": "Español"},
|
||||
{"code": "fr", "name": "Français"},
|
||||
]
|
||||
},
|
||||
"navigation": [{"label": "Home", "href": "/"}],
|
||||
"hero": {
|
||||
"kicker": "Archive Preview",
|
||||
"title": "Modernized, reviewable, and still archive-first.",
|
||||
"lede": "Proof-of-concept output from SciSiteForge.",
|
||||
"actions": [{"label": "Open", "href": "#overview", "primary": True}],
|
||||
},
|
||||
"content_sources": {},
|
||||
}
|
||||
config_path = tmp_path / "site.json"
|
||||
config_path.write_text(json.dumps(config), encoding="utf-8")
|
||||
|
||||
out_dir = tmp_path / "out"
|
||||
build.build_site(config_path, out_dir)
|
||||
|
||||
html = (out_dir / "index.html").read_text(encoding="utf-8")
|
||||
self.assertIn('<option value="en" selected>English</option>', html)
|
||||
self.assertNotIn('value="es"', html)
|
||||
self.assertNotIn('value="fr"', html)
|
||||
self.assertIn("Planned languages: Español, Français", html)
|
||||
|
||||
def test_notebook_pattern_groups_goals_apps_and_source_cards(self) -> None:
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
with TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
doclift_root = tmp_path / "doclift"
|
||||
doclift_root.mkdir()
|
||||
(doclift_root / "manifest.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"documents": [
|
||||
{
|
||||
"document_id": "doc-1",
|
||||
"title": "Legacy Reading",
|
||||
"document_kind": "article",
|
||||
"markdown_path": "documents/doc-1/document.md",
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(doclift_root / "documents" / "doc-1").mkdir(parents=True)
|
||||
(doclift_root / "documents" / "doc-1" / "document.md").write_text("Recovered source paragraph.", encoding="utf-8")
|
||||
|
||||
config = {
|
||||
"notebooks": [
|
||||
{
|
||||
"id": "digital-evolution",
|
||||
"title": "Digital Evolution Notebook",
|
||||
"summary": "Lab plus source-grounded study path.",
|
||||
"audience": "self-learners",
|
||||
"goals": ["Connect simulation output to evolutionary concepts"],
|
||||
"apps": [{"title": "Avida-ED", "href": "/app4/", "description": "Digital evolution lab"}],
|
||||
"source_kinds": ["notebook"],
|
||||
}
|
||||
]
|
||||
}
|
||||
notebooks = load_notebooks(config)
|
||||
content = SiteContent(section_cards=load_doclift_cards(doclift_root))
|
||||
html = render_notebooks(notebooks, content)
|
||||
|
||||
self.assertIn("Digital Evolution Notebook", html)
|
||||
self.assertIn("Avida-ED", html)
|
||||
self.assertIn("Legacy Reading", html)
|
||||
self.assertIn("Recovered source paragraph.", html)
|
||||
|
||||
def test_geniehive_translator_uses_openai_compatible_chat_payload(self) -> None:
|
||||
translator = GenieHiveTranslator(
|
||||
TranslationConfig(base_url="http://geniehive.local:8800", model="translation-role", api_key="abc123")
|
||||
)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_post_json(path: str, payload: dict) -> dict:
|
||||
captured["path"] = path
|
||||
captured["payload"] = payload
|
||||
return {"choices": [{"message": {"content": "Hola"}}]}
|
||||
|
||||
translator._post_json = fake_post_json # type: ignore[method-assign]
|
||||
result = translator.translate("Hello world", "Spanish", {"evolution": "evolución"})
|
||||
|
||||
self.assertEqual(result, "Hola")
|
||||
self.assertEqual(captured["path"], "/v1/chat/completions")
|
||||
payload = captured["payload"]
|
||||
self.assertEqual(payload["model"], "translation-role")
|
||||
user_text = payload["messages"][1]["content"]
|
||||
self.assertIn("Spanish", user_text)
|
||||
self.assertIn("evolución", user_text)
|
||||
|
||||
def test_translate_site_builds_translator_from_config(self) -> None:
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
with TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
config_path = tmp_path / "site.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"translation": {
|
||||
"base_url": "http://geniehive.local:8800",
|
||||
"model": "translation-role",
|
||||
"api_key": "abc123",
|
||||
"timeout": 33,
|
||||
"system_prompt": "Translate carefully.",
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
args = SimpleNamespace(base_url=None, model=None, api_key=None, timeout=None)
|
||||
translator = translate_site.build_translator(config_path, args)
|
||||
|
||||
self.assertEqual(translator.config.provider, "geniehive")
|
||||
self.assertEqual(translator.config.base_url, "http://geniehive.local:8800")
|
||||
self.assertEqual(translator.config.model, "translation-role")
|
||||
self.assertEqual(translator.config.api_key, "abc123")
|
||||
self.assertEqual(translator.config.timeout, 33)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 |
|
|
@ -0,0 +1,90 @@
|
|||
<!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 }}
|
||||
<select id="lang-switch" onchange="switchLanguage(this.value)">
|
||||
{{ language_options }}
|
||||
</select>
|
||||
{{ language_policy_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">Framework</p>
|
||||
<h2>Modular, static, and reviewable</h2>
|
||||
<p>This theme is intended to prove the modernization line without giving up the archive posture.</p>
|
||||
<ul class="link-list compact-list">
|
||||
<li>Responsive reading</li>
|
||||
<li>Reusable content blocks</li>
|
||||
<li>Search and bibliography ready</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="content-panel">
|
||||
<h2>Feature cards</h2>
|
||||
<div class="feature-grid">
|
||||
{{ feature_cards_html }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="content-panel">
|
||||
<h2>Notebook and app content</h2>
|
||||
{{ notebook_html }}
|
||||
<div class="section-grid">
|
||||
{{ section_cards_html }}
|
||||
{{ app_cards_html }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="content-panel">
|
||||
<h2>Bibliography</h2>
|
||||
<ul class="archive-list bibliography-list">
|
||||
{{ bibliography_html }}
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<p class="footer-note"><strong>{{ site_title }}</strong> is the current SciSiteForge proving ground for modern archive-style science sites.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,618 @@
|
|||
:root {
|
||||
--bg: #f2eee6;
|
||||
--panel: #fffdf9;
|
||||
--panel-warm: #fbf6ee;
|
||||
--panel-cool: #f4f8fc;
|
||||
--ink: #1b2330;
|
||||
--muted: #5b6471;
|
||||
--line: rgba(28, 35, 48, 0.12);
|
||||
--blue: #214f94;
|
||||
--blue-deep: #163659;
|
||||
--blue-soft: #dbe8f7;
|
||||
--gold: #b18d33;
|
||||
--gold-soft: #f3e4b3;
|
||||
--brick-soft: #efe1dc;
|
||||
--shadow: 0 22px 55px rgba(20, 33, 53, 0.1);
|
||||
--max-width: 1180px;
|
||||
--article-width: 760px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(33, 79, 148, 0.11), transparent 22%),
|
||||
radial-gradient(circle at top right, rgba(177, 141, 51, 0.12), transparent 18%),
|
||||
linear-gradient(180deg, #f7f3ea, var(--bg));
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--blue-deep);
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
left: -999px;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
left: 1rem;
|
||||
top: 1rem;
|
||||
background: var(--ink);
|
||||
color: white;
|
||||
padding: 0.7rem 0.9rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.site-shell {
|
||||
width: min(var(--max-width), calc(100vw - 28px));
|
||||
margin: 0 auto;
|
||||
padding: 20px 0 40px;
|
||||
}
|
||||
|
||||
.site-header,
|
||||
.hero-panel,
|
||||
.content-panel,
|
||||
.article-header,
|
||||
.article-body,
|
||||
.side-card,
|
||||
.site-footer,
|
||||
.continuity-note,
|
||||
.feature-card,
|
||||
.section-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 22px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.site-header,
|
||||
.site-footer,
|
||||
.hero-panel,
|
||||
.content-panel,
|
||||
.article-header,
|
||||
.article-body,
|
||||
.side-card {
|
||||
padding: 22px 24px;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
border-top: 4px solid var(--gold);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(255, 253, 249, 0.92));
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.brand-kicker,
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--gold);
|
||||
font: 700 0.78rem/1.2 "Helvetica Neue", Arial, sans-serif;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
font: 700 clamp(1.7rem, 3vw, 2.2rem)/1.05 "Helvetica Neue", Arial, sans-serif;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.brand-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: clamp(58px, 8vw, 82px);
|
||||
height: clamp(58px, 8vw, 82px);
|
||||
border-radius: 18px;
|
||||
padding: 6px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(244, 248, 252, 0.9));
|
||||
border: 1px solid rgba(22, 54, 89, 0.12);
|
||||
box-shadow: 0 14px 28px rgba(20, 33, 53, 0.1);
|
||||
}
|
||||
|
||||
.brand-mark img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-summary,
|
||||
.site-footer p,
|
||||
.continuity-note p,
|
||||
.feature-card p,
|
||||
.section-card p,
|
||||
.article-note,
|
||||
.article-meta,
|
||||
.article-sidebar li span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.top-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.top-nav a,
|
||||
.button-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 0.72rem 1rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
text-decoration: none;
|
||||
font: 600 0.95rem/1.2 "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.top-nav a,
|
||||
.button-link.button-link-secondary {
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.button-link,
|
||||
.top-nav a:first-child {
|
||||
background: linear-gradient(180deg, var(--blue), var(--blue-deep));
|
||||
border-color: transparent;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
margin-bottom: 18px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(33, 79, 148, 0.08), transparent 42%),
|
||||
linear-gradient(180deg, #fffefb, #faf6ee);
|
||||
border-top: 5px solid var(--blue);
|
||||
}
|
||||
|
||||
.hero-panel::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: auto -7% -32px auto;
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
background: radial-gradient(circle, rgba(177, 141, 51, 0.18), transparent 68%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-grid,
|
||||
.article-layout {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(260px, 0.8fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-panel h1,
|
||||
.article-header h1 {
|
||||
margin: 0 0 14px;
|
||||
font: 700 clamp(2rem, 4vw, 3rem)/1.05 "Helvetica Neue", Arial, sans-serif;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.lede {
|
||||
font-size: 1.08rem;
|
||||
line-height: 1.72;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.continuity-note {
|
||||
padding: 18px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(244, 248, 252, 0.9));
|
||||
border: 1px solid rgba(22, 54, 89, 0.12);
|
||||
}
|
||||
|
||||
.hero-note {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
margin-bottom: 18px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-panel h2,
|
||||
.continuity-note h2,
|
||||
.side-card h2 {
|
||||
margin-top: 0;
|
||||
font: 700 1.2rem/1.2 "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.lead-panel {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 250, 241, 0.98), rgba(251, 246, 238, 0.98));
|
||||
border-top: 5px solid var(--gold);
|
||||
}
|
||||
|
||||
.direction-panel {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(244, 248, 252, 0.98), rgba(255, 255, 255, 0.98));
|
||||
border-top: 5px solid var(--blue-soft);
|
||||
}
|
||||
|
||||
.roadmap-panel {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(247, 241, 233, 0.98));
|
||||
border-top: 5px solid rgba(177, 141, 51, 0.45);
|
||||
}
|
||||
|
||||
.preview-panel {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(244, 248, 252, 0.92), rgba(255, 255, 255, 0.96));
|
||||
}
|
||||
|
||||
.feature-grid,
|
||||
.section-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.section-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
}
|
||||
|
||||
.feature-card,
|
||||
.section-card {
|
||||
padding: 18px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feature-card h3,
|
||||
.section-card h3 {
|
||||
margin-top: 0;
|
||||
font: 700 1.02rem/1.3 "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.feature-card::before,
|
||||
.section-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--gold);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.lead-panel .feature-card:nth-child(4n + 1),
|
||||
.direction-panel .feature-card:nth-child(3n + 1),
|
||||
.section-card:nth-child(4n + 1) {
|
||||
background: linear-gradient(180deg, rgba(255, 250, 241, 0.98), white);
|
||||
}
|
||||
|
||||
.lead-panel .feature-card:nth-child(4n + 2),
|
||||
.direction-panel .feature-card:nth-child(3n + 2),
|
||||
.section-card:nth-child(4n + 2) {
|
||||
background: linear-gradient(180deg, rgba(244, 248, 252, 0.98), white);
|
||||
}
|
||||
|
||||
.lead-panel .feature-card:nth-child(4n + 3),
|
||||
.direction-panel .feature-card:nth-child(3n + 3),
|
||||
.section-card:nth-child(4n + 3) {
|
||||
background: linear-gradient(180deg, rgba(239, 225, 220, 0.55), white);
|
||||
}
|
||||
|
||||
.lead-panel .feature-card:nth-child(4n + 4),
|
||||
.section-card:nth-child(4n + 4) {
|
||||
background: linear-gradient(180deg, rgba(243, 228, 179, 0.32), white);
|
||||
}
|
||||
|
||||
.article-layout {
|
||||
grid-template-columns: minmax(0, var(--article-width)) minmax(250px, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.article-header {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 250, 241, 0.98), rgba(255, 255, 255, 0.98));
|
||||
border-top: 5px solid var(--gold);
|
||||
}
|
||||
|
||||
.article-body {
|
||||
line-height: 1.78;
|
||||
font-size: 1.08rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.99), rgba(251, 247, 240, 0.98));
|
||||
border-top: 4px solid rgba(33, 79, 148, 0.18);
|
||||
}
|
||||
|
||||
.article-body p,
|
||||
.article-body li,
|
||||
.article-body blockquote {
|
||||
max-width: 68ch;
|
||||
}
|
||||
|
||||
.article-body h2,
|
||||
.article-body h3 {
|
||||
margin-top: 2rem;
|
||||
font-family: "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.2;
|
||||
color: var(--blue-deep);
|
||||
}
|
||||
|
||||
.article-body h2 {
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.article-body h3 {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.article-body blockquote {
|
||||
margin: 1.4rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-left: 4px solid var(--gold);
|
||||
background: rgba(177, 141, 51, 0.08);
|
||||
}
|
||||
|
||||
.article-body code {
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
background: rgba(23, 56, 102, 0.08);
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.claim-block {
|
||||
margin-bottom: 1.8rem;
|
||||
padding: 1.35rem 1.4rem 1.1rem;
|
||||
border: 1px solid rgba(22, 54, 89, 0.12);
|
||||
border-left: 6px solid var(--blue);
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(219, 232, 247, 0.55), rgba(255, 255, 255, 0.96));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.claim-block h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.9rem;
|
||||
color: var(--blue-deep);
|
||||
}
|
||||
|
||||
.claim-block p:first-of-type {
|
||||
margin-top: 0;
|
||||
font-size: 1.22rem;
|
||||
line-height: 1.55;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.claim-block p:first-of-type::before {
|
||||
content: "Claim at issue";
|
||||
display: block;
|
||||
margin-bottom: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--gold);
|
||||
font: 700 0.76rem/1.2 "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.response-block {
|
||||
margin-bottom: 1.6rem;
|
||||
}
|
||||
|
||||
.article-sidebar {
|
||||
position: sticky;
|
||||
top: 18px;
|
||||
}
|
||||
|
||||
.side-card {
|
||||
margin-bottom: 16px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(244, 248, 252, 0.92));
|
||||
}
|
||||
|
||||
.side-card-links {
|
||||
border-top: 4px solid var(--blue);
|
||||
}
|
||||
|
||||
.side-card-hooks {
|
||||
border-top: 4px solid var(--gold);
|
||||
}
|
||||
|
||||
.link-list {
|
||||
padding-left: 1.1rem;
|
||||
}
|
||||
|
||||
.link-list li + li {
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.article-meta,
|
||||
.article-note,
|
||||
.footer-note {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.meta-link {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
background:
|
||||
linear-gradient(180deg, #223247, #142236);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.site-footer h2 {
|
||||
margin-top: 0;
|
||||
color: white;
|
||||
font: 700 1.1rem/1.2 "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.site-footer p {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
.site-footer a,
|
||||
.site-footer strong,
|
||||
.footer-note {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.compact-list {
|
||||
margin: 0;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.compact-list li + li {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.notebook-panel {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem;
|
||||
margin: 1rem 0 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
}
|
||||
|
||||
.notebook-panel h2,
|
||||
.notebook-panel h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.notebook-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.notebook-panel .meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.plain-list {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.plain-list li + li {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible {
|
||||
outline: 3px solid #e5b748;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.site-header,
|
||||
.hero-grid,
|
||||
.article-layout,
|
||||
.notebook-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.article-sidebar {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.site-shell {
|
||||
width: min(var(--max-width), calc(100vw - 18px));
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.site-header,
|
||||
.site-footer,
|
||||
.hero-panel,
|
||||
.content-panel,
|
||||
.article-header,
|
||||
.article-body,
|
||||
.side-card {
|
||||
padding: 18px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.top-nav {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.brand-row {
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.top-nav a,
|
||||
.button-link {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue