Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.direnv
dist
.venv
.venv
__pycache__/
4 changes: 4 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"recommendations": ["charliermarsh.ruff", "astral-sh.ty"],
"unwantedRecommendations": []
}
7 changes: 7 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"editor.inlayHints.enabled": "off",
"python.linting.ruffEnabled": true,
"python.linting.enabled": true,
"python.formatting.provider": "none",
"editor.formatOnSave": true
}
7 changes: 7 additions & 0 deletions content/chapters/1.variables.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# variables

a variable is a named value stored in memory

```stepcode
n := 5
```
8 changes: 8 additions & 0 deletions content/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# stepcode
## introduction
stepcode is a tool for generating static books with support for step-by-step pseudocode execution

## chapters
now programming concepts

[variables](./chapters/1.variables.html)
50 changes: 50 additions & 0 deletions src/generate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import os
import shutil
from parse_content import Page

LAYOUT = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<link rel="stylesheet" href="{css_path}">
</head>
<body>
<main>
{content}
</main>
</body>
</html>"""


def write_output(parsed: dict[str, Page], output_path: str) -> None:
if os.path.exists(output_path):
shutil.rmtree(output_path)
os.makedirs(output_path)

for relative_path, page in parsed.items():
output_file = os.path.join(output_path, relative_path).replace(".md", ".html")
os.makedirs(os.path.dirname(output_file), exist_ok=True)

depth = relative_path.count(os.sep)
css_relative_path = "../" * depth + "base.css"

title = page.meta.get(
"title", os.path.splitext(os.path.basename(relative_path))[0]
)

html = LAYOUT.format(
title=title, content=page.render(), css_path=css_relative_path
)

with open(output_file, "w", encoding="utf-8") as f:
f.write(html)

print(f"written {relative_path} -> {output_file}")

if os.path.exists("static/base.css"):
shutil.copy("static/base.css", os.path.join(output_path, "base.css"))
print("copied base.css")
else:
print("Warning: base.css not found in root directory")
11 changes: 10 additions & 1 deletion src/main.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,11 @@
from parse_content import parse_content_folder
from generate import write_output


def main() -> None:
parsed = parse_content_folder("content")
write_output(parsed, "dist")


if __name__ == "__main__":
print("Hello, World!")
main()
94 changes: 94 additions & 0 deletions src/parse_content.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import os
import re
import sys
import tomllib
from dataclasses import dataclass
from frontmatter import Frontmatter
from markdown import markdown


@dataclass
class SiteConfig:
name: str
author: str


def parse_config(config_path: str) -> SiteConfig:
toml_path = os.path.join(config_path, "stepcode.toml")

if not os.path.exists(toml_path):
print(f"Error: config file not found at '{toml_path}'")
sys.exit(1)

try:
with open(toml_path, "rb") as f:
data = tomllib.load(f)
except tomllib.TOMLDecodeError as e:
print(f"Error: could not parse '{toml_path}': {e}")
sys.exit(1)

errors = []

site_data = data.get("site")
if not site_data:
errors.append("missing [site] section")
else:
if "name" not in site_data:
errors.append("missing 'name' field in [site]")
if "author" not in site_data:
errors.append("missing 'author' field in [site]")

if errors:
print("Error: invalid config file:")
for err in errors:
print(f" - {err}")
sys.exit(1)

assert site_data is not None

return SiteConfig(
name=site_data["name"],
author=site_data["author"],
)


@dataclass
class Page:
meta: dict[str, str]
content: str

def render(self) -> str:
if not self.meta:
return self.content

def replacer(match: re.Match) -> str:
key = match.group(1).strip()
return self.meta.get(key, match.group(0))

return re.sub(r"\{\{(.+?)\}\}", replacer, self.content)


def parse_content_folder(content_path: str) -> dict[str, Page]:
results = {}
for root, dirs, files in os.walk(content_path):
for file in files:
if file.endswith(".md"):
filepath = os.path.join(root, file)
relative_path = os.path.relpath(filepath, content_path)

with open(filepath, "r", encoding="utf-8") as f:
raw = f.read()

fm = Frontmatter.read(raw)
body = fm["body"] if fm["body"] else raw
html = markdown(body, extensions=["fenced_code", "tables"])

page = Page(
meta=fm["attributes"] or {},
content=html,
)

results[relative_path] = page
print(f"parsed {relative_path}")

return results
137 changes: 137 additions & 0 deletions static/base.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
:root {
--bg-color: #fdfbf7;
--text-color: #333333;
--accent-color: #d9463e;
--heading-color: #111111;
--code-bg: #ecebe6;
--quote-border: #dcdcdc;

--font-body: "Merriweather", "Georgia", serif;
--font-heading:
-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;

--max-width: 740px;
--line-height: 1.7;
}

@media (prefers-color-scheme: dark) {
:root {
--bg-color: #1a1a1a;
--text-color: #e0e0e0;
--accent-color: #ff857f;
--heading-color: #ffffff;
--code-bg: #2b2b2b;
--quote-border: #444;
}
}

* {
box-sizing: border-box;
margin: 0;
padding: 0;
}

body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: var(--font-body);
font-size: 18px;
line-height: var(--line-height);
transition:
background-color 0.3s,
color 0.3s;
}

main {
max-width: var(--max-width);
margin: 0 auto;
padding: 3rem 1.5rem;
}

h1,
h2,
h3,
h4 {
font-family: var(--font-heading);
color: var(--heading-color);
margin-top: 2.5rem;
margin-bottom: 1rem;
line-height: 1.3;
font-weight: 700;
}

h1 {
font-size: 2.5rem;
border-bottom: 2px solid var(--accent-color);
padding-bottom: 0.5rem;
margin-top: 0;
}

h2 {
font-size: 1.8rem;
}
h3 {
font-size: 1.4rem;
}

a {
color: var(--accent-color);
text-decoration: none;
border-bottom: 1px dotted var(--accent-color);
transition: all 0.2s;
}
a:hover {
border-bottom-style: solid;
}

p {
margin-bottom: 1.5rem;
text-align: justify;
hyphens: auto;
}

ul,
ol {
margin-bottom: 1.5rem;
padding-left: 2rem;
}
li {
margin-bottom: 0.5rem;
}

blockquote {
font-style: italic;
border-left: 4px solid var(--quote-border);
margin: 2rem 0;
padding: 0.5rem 0 0.5rem 1.5rem;
opacity: 0.85;
}

code {
font-family: "Consolas", "Monaco", monospace;
font-size: 0.9em;
background-color: var(--code-bg);
padding: 0.2rem 0.4rem;
border-radius: 4px;
}

pre {
background-color: var(--code-bg);
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
margin-bottom: 1.5rem;
}
pre code {
background-color: transparent;
padding: 0;
}

img {
max-width: 100%;
height: auto;
display: block;
margin: 2rem auto;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}