Miscellaneous code and documentation updates

This commit is contained in:
2026-04-05 16:27:42 +02:00
parent 00cff1eb7e
commit a4191658c5
32 changed files with 346 additions and 105 deletions
+2
View File
@@ -24,6 +24,8 @@ GOOGLE_API_KEY=
# Mistral Voxtral TTS — https://console.mistral.ai/api-keys # Mistral Voxtral TTS — https://console.mistral.ai/api-keys
# MISTRAL_API_KEY= # MISTRAL_API_KEY=
# Mistral voice ID — maak een stem aan op https://console.mistral.ai en plak het ID hier
# MISTRAL_VOICE_ID=
# OpenAI TTS-1 — https://platform.openai.com/api-keys # OpenAI TTS-1 — https://platform.openai.com/api-keys
# OPENAI_API_KEY= # OPENAI_API_KEY=
+7 -7
View File
@@ -3,11 +3,11 @@ services:
# image: git.hiddenden.cafe/hiddenden/cozy-den:latest # image: git.hiddenden.cafe/hiddenden/cozy-den:latest
build: . build: .
container_name: cozy-den container_name: cozy-den
# ports: ports:
# - "3000:3000" - "3000:3000"
restart: unless-stopped restart: unless-stopped
networks: # networks:
- proxy # - proxy
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- HOST=0.0.0.0 - HOST=0.0.0.0
@@ -22,6 +22,6 @@ services:
volumes: volumes:
guestbook_data: guestbook_data:
networks: # networks:
proxy: # proxy:
external: true # external: true
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+198 -77
View File
@@ -6,8 +6,8 @@ Usage:
python scripts/tts_generate.py src/content/blog/my-post.md python scripts/tts_generate.py src/content/blog/my-post.md
Environment variables: Environment variables:
TTS_PROVIDER - "google" (default), "mistral", or "openai" TTS_PROVIDER - "google" (default), "mistral", or "openai"
GOOGLE_API_KEY - Required when TTS_PROVIDER=google GOOGLE_API_KEY - Required when TTS_PROVIDER=google
MISTRAL_API_KEY - Required when TTS_PROVIDER=mistral MISTRAL_API_KEY - Required when TTS_PROVIDER=mistral
OPENAI_API_KEY - Required when TTS_PROVIDER=openai OPENAI_API_KEY - Required when TTS_PROVIDER=openai
@@ -21,6 +21,23 @@ import re
import sys import sys
def load_dotenv() -> None:
"""Load .env from the project root into os.environ (stdlib only, never overwrites)."""
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
env_path = os.path.join(project_root, ".env")
if not os.path.isfile(env_path):
return
with open(env_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
os.environ.setdefault(key, value)
def parse_frontmatter(text: str) -> tuple[dict, str]: def parse_frontmatter(text: str) -> tuple[dict, str]:
"""Extract YAML frontmatter and return (metadata_dict, body).""" """Extract YAML frontmatter and return (metadata_dict, body)."""
if not text.startswith("---"): if not text.startswith("---"):
@@ -31,7 +48,7 @@ def parse_frontmatter(text: str) -> tuple[dict, str]:
return {}, text return {}, text
front = text[3:end].strip() front = text[3:end].strip()
body = text[end + 4:].strip() body = text[end + 4 :].strip()
meta: dict = {} meta: dict = {}
for line in front.splitlines(): for line in front.splitlines():
@@ -69,99 +86,195 @@ def clean_markdown(text: str) -> str:
return text.strip() return text.strip()
def tts_google(text: str, slug: str, output_path: str) -> None: def split_into_chunks(text: str, max_chars: int = 4000) -> list[str]:
"""Generate audio with Google Cloud TTS (free tier: 1M chars/month).""" """Split text into chunks that fit within max_chars.
Splits on paragraph boundaries first; falls back to sentence boundaries
for paragraphs that are still too long. No text is ever discarded.
"""
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
# Flatten into atomic segments (paragraphs or individual sentences)
segments: list[str] = []
for para in paragraphs:
if len(para) <= max_chars:
segments.append(para)
else:
sentences = re.split(r"(?<=[.!?])\s+", para)
segments.extend(s for s in sentences if s.strip())
# Merge segments greedily into chunks under max_chars
chunks: list[str] = []
current = ""
for segment in segments:
if not current:
current = segment
elif len(current) + 2 + len(segment) <= max_chars:
current += "\n\n" + segment
else:
chunks.append(current)
current = segment
if current:
chunks.append(current)
return chunks
def merge_audio_chunks(chunks: list[bytes], output_path: str) -> None:
"""Concatenate MP3 byte chunks with a ~400ms silent pause between each.
MP3 frames are self-contained, so byte concatenation produces a valid file.
Silent frame: 128kbps/44100Hz frame header + null payload = 417 bytes.
16 frames * ~26.1ms each 418ms of silence.
"""
silent_frame = b"\xff\xfb\x90\x00" + b"\x00" * 413 # 417 bytes
silence = silent_frame * 16
with open(output_path, "wb") as f:
for i, chunk in enumerate(chunks):
f.write(chunk)
if i < len(chunks) - 1:
f.write(silence)
# ---------------------------------------------------------------------------
# Provider helpers — return raw MP3 bytes for a single text chunk
# ---------------------------------------------------------------------------
def _google_synthesize(text: str, api_key: str) -> bytes:
import base64
import json import json
import urllib.request import urllib.request
payload = json.dumps(
{
"input": {"text": text},
"voice": {
"languageCode": "nl-NL",
"name": "nl-NL-Wavenet-D",
"ssmlGender": "MALE",
},
"audioConfig": {"audioEncoding": "MP3"},
}
).encode()
url = f"https://texttospeech.googleapis.com/v1/text:synthesize?key={api_key}"
req = urllib.request.Request(
url, data=payload, headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req) as resp:
body = json.loads(resp.read())
return base64.b64decode(body["audioContent"])
def _mistral_synthesize(text: str, api_key: str) -> bytes:
import base64
import json
import urllib.error
import urllib.request
body: dict = {
"model": "voxtral-mini-tts-2603",
"input": text,
"response_format": "mp3",
}
voice_id = os.environ.get("MISTRAL_VOICE_ID", "").strip()
if not voice_id:
raise EnvironmentError(
"MISTRAL_VOICE_ID is not set. "
"Create a voice at https://console.mistral.ai and add its ID to .env"
)
body["voice"] = voice_id
req = urllib.request.Request(
"https://api.mistral.ai/v1/audio/speech",
data=json.dumps(body).encode(),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
},
)
try:
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
return base64.b64decode(data["audio_data"])
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"Mistral API {exc.code}: {detail}") from exc
def _openai_synthesize(text: str, api_key: str) -> bytes:
import json
import urllib.request
payload = json.dumps(
{
"model": "gpt-4o-mini-tts",
"input": text,
"voice": "ash",
"instructions": "Read aloud in a warm and friendly tone.",
}
).encode()
req = urllib.request.Request(
"https://api.openai.com/v1/audio/speech",
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
},
)
with urllib.request.urlopen(req) as resp:
return resp.read()
# ---------------------------------------------------------------------------
# Public provider functions — chunk, call API per chunk, merge
# ---------------------------------------------------------------------------
def tts_google(text: str, slug: str, output_path: str) -> None:
"""Generate audio with Google Cloud TTS (free tier: 1M chars/month)."""
api_key = os.environ.get("GOOGLE_API_KEY") api_key = os.environ.get("GOOGLE_API_KEY")
if not api_key: if not api_key:
raise EnvironmentError("GOOGLE_API_KEY is not set") raise EnvironmentError("GOOGLE_API_KEY is not set")
payload = json.dumps({ chunks = split_into_chunks(text)
"input": {"text": text}, audio_chunks: list[bytes] = []
"voice": { for i, chunk in enumerate(chunks, 1):
"languageCode": "nl-NL", print(f" Chunk {i}/{len(chunks)} ({len(chunk)} chars)...")
"name": "nl-NL-Wavenet-D", audio_chunks.append(_google_synthesize(chunk, api_key))
"ssmlGender": "FEMALE", merge_audio_chunks(audio_chunks, output_path)
},
"audioConfig": {"audioEncoding": "MP3"},
}).encode()
url = f"https://texttospeech.googleapis.com/v1/text:synthesize?key={api_key}"
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req) as resp:
body = json.loads(resp.read())
import base64
audio_bytes = base64.b64decode(body["audioContent"])
with open(output_path, "wb") as f:
f.write(audio_bytes)
def tts_mistral(text: str, slug: str, output_path: str) -> None: def tts_mistral(text: str, slug: str, output_path: str) -> None:
"""Generate audio with Mistral Voxtral TTS (~$16/M chars).""" """Generate audio with Mistral Voxtral TTS (~$16/M chars)."""
import json
import urllib.request
api_key = os.environ.get("MISTRAL_API_KEY") api_key = os.environ.get("MISTRAL_API_KEY")
if not api_key: if not api_key:
raise EnvironmentError("MISTRAL_API_KEY is not set") raise EnvironmentError("MISTRAL_API_KEY is not set")
payload = json.dumps({ chunks = split_into_chunks(text)
"model": "voxtral-mini-tts-2507", audio_chunks: list[bytes] = []
"input": text, for i, chunk in enumerate(chunks, 1):
"voice": "river", print(f" Chunk {i}/{len(chunks)} ({len(chunk)} chars)...")
}).encode() audio_chunks.append(_mistral_synthesize(chunk, api_key))
merge_audio_chunks(audio_chunks, output_path)
url = "https://api.mistral.ai/v1/audio/speech"
req = urllib.request.Request(
url,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
},
)
with urllib.request.urlopen(req) as resp:
audio_bytes = resp.read()
with open(output_path, "wb") as f:
f.write(audio_bytes)
def tts_openai(text: str, slug: str, output_path: str) -> None: def tts_openai(text: str, slug: str, output_path: str) -> None:
"""Generate audio with OpenAI TTS-1 (~$15/M chars).""" """Generate audio with OpenAI TTS-1 (~$15/M chars)."""
import json
import urllib.request
api_key = os.environ.get("OPENAI_API_KEY") api_key = os.environ.get("OPENAI_API_KEY")
if not api_key: if not api_key:
raise EnvironmentError("OPENAI_API_KEY is not set") raise EnvironmentError("OPENAI_API_KEY is not set")
payload = json.dumps({ chunks = split_into_chunks(text)
"model": "tts-1", audio_chunks: list[bytes] = []
"input": text, for i, chunk in enumerate(chunks, 1):
"voice": "nova", print(f" Chunk {i}/{len(chunks)} ({len(chunk)} chars)...")
}).encode() audio_chunks.append(_openai_synthesize(chunk, api_key))
merge_audio_chunks(audio_chunks, output_path)
url = "https://api.openai.com/v1/audio/speech"
req = urllib.request.Request(
url,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
},
)
with urllib.request.urlopen(req) as resp:
audio_bytes = resp.read()
with open(output_path, "wb") as f:
f.write(audio_bytes)
PROVIDERS = { PROVIDERS = {
@@ -172,9 +285,13 @@ PROVIDERS = {
def main() -> None: def main() -> None:
load_dotenv()
parser = argparse.ArgumentParser(description="Generate TTS audio for a blog post") parser = argparse.ArgumentParser(description="Generate TTS audio for a blog post")
parser.add_argument("file", help="Path to the .md blog post") parser.add_argument("file", help="Path to the .md blog post")
parser.add_argument("--output-dir", default=".", help="Directory to write the .mp3 (default: .)") parser.add_argument(
"--output-dir", default=".", help="Directory to write the .mp3 (default: .)"
)
args = parser.parse_args() args = parser.parse_args()
md_path = args.file md_path = args.file
@@ -190,17 +307,21 @@ def main() -> None:
slug = meta.get("slug") or os.path.splitext(os.path.basename(md_path))[0] slug = meta.get("slug") or os.path.splitext(os.path.basename(md_path))[0]
title = meta.get("title", "") title = meta.get("title", "")
# Prepend title so TTS reads it aloud
full_text = f"{title}.\n\n{clean_markdown(body)}" if title else clean_markdown(body) full_text = f"{title}.\n\n{clean_markdown(body)}" if title else clean_markdown(body)
provider_name = os.environ.get("TTS_PROVIDER", "google").lower() provider_name = os.environ.get("TTS_PROVIDER", "google").lower()
if provider_name not in PROVIDERS: if provider_name not in PROVIDERS:
print(f"ERROR: unknown TTS_PROVIDER '{provider_name}'. Choose from: {', '.join(PROVIDERS)}", file=sys.stderr) print(
f"ERROR: unknown TTS_PROVIDER '{provider_name}'. Choose from: {', '.join(PROVIDERS)}",
file=sys.stderr,
)
sys.exit(1) sys.exit(1)
output_path = os.path.join(args.output_dir, f"{slug}.mp3") output_path = os.path.join(args.output_dir, f"{slug}.mp3")
print(f"Generating audio for '{slug}' using provider '{provider_name}' ({len(full_text)} chars)...") print(
f"Generating audio for '{slug}' using provider '{provider_name}' ({len(full_text)} chars)..."
)
try: try:
PROVIDERS[provider_name](full_text, slug, output_path) PROVIDERS[provider_name](full_text, slug, output_path)
+2 -1
View File
@@ -1,10 +1,11 @@
--- ---
title: "After the Silence" title: "After the Silence"
description: "Reflections on what remains after a meaningful relationship ends, and how love can transform without disappearing." description: "Reflections on what remains after a meaningful relationship ends, and how love can transform without disappearing."
pubDate: 2026-03-04 pubDate: 2026-03-04
tags: ["love", "reflection", "healing", "relationships", "personal"] tags: ["love", "reflection", "healing", "relationships", "personal"]
category: "reflection" category: "reflection"
featuredEssay: true featuredEssay: true
audio: true
--- ---
When a relationship ends, the world does not become quiet immediately. When a relationship ends, the world does not become quiet immediately.
@@ -6,6 +6,7 @@ tags: ["reflection", "identity", "intimacy", "self-knowledge", "personal", "nuan
category: "reflection" category: "reflection"
featuredEssay: false featuredEssay: false
draft: false draft: false
audio: true
--- ---
*A quiet reflection on BDSM, nuance, and why my answers are rarely simple* *A quiet reflection on BDSM, nuance, and why my answers are rarely simple*
@@ -6,6 +6,7 @@ tags: ["reflection", "personal", "internet", "building", "devlog"]
category: "reflection" category: "reflection"
featuredEssay: false featuredEssay: false
draft: false draft: false
audio: true
--- ---
*by LATTE* *by LATTE*
@@ -9,6 +9,7 @@ readingOrder: 2
series: series:
name: "Coffee & Code" name: "Coffee & Code"
part: 1 part: 1
audio: true
--- ---
The modern internet is loud in a way that can be hard to notice until you step away from it. The modern internet is loud in a way that can be hard to notice until you step away from it.
+1
View File
@@ -7,6 +7,7 @@ category: "building"
featuredEssay: true featuredEssay: true
readingOrder: 1 readingOrder: 1
draft: false draft: false
audio: true
--- ---
So I finally got around to setting up a proper blog. Welcome. So I finally got around to setting up a proper blog. Welcome.
+2 -1
View File
@@ -1,8 +1,9 @@
--- ---
title: "Love Without Access" title: "Love Without Access"
description: "A reflection on a first love - what it meant, what it cost, and why distance was the most loving thing left." description: "A reflection on a first love - what it meant, what it cost, and why distance was the most loving thing left."
pubDate: 2026-03-01 pubDate: 2026-03-01
tags: ["love", "reflection", "healing", "relationships", "personal"] tags: ["love", "reflection", "healing", "relationships", "personal"]
audio: true
--- ---
*by LATTE* *by LATTE*
@@ -3,6 +3,7 @@ title: "Rebuilding Without Rushing"
description: "On slowing down after loss, choosing clarity over urgency, and learning that rebuilding does not have to be loud to be real." description: "On slowing down after loss, choosing clarity over urgency, and learning that rebuilding does not have to be loud to be real."
pubDate: 2026-03-22 pubDate: 2026-03-22
tags: ["reflection", "healing", "personal", "rebuilding", "growth"] tags: ["reflection", "healing", "personal", "rebuilding", "growth"]
audio: true
--- ---
## The idea of rebuilding ## The idea of rebuilding
+1
View File
@@ -6,6 +6,7 @@ tags: ["games", "reflection", "relationships", "internet", "personal"]
category: "reflection" category: "reflection"
featuredEssay: false featuredEssay: false
draft: false draft: false
audio: true
--- ---
*by LATTE* *by LATTE*
+1
View File
@@ -6,6 +6,7 @@ tags: ["reflection", "personal", "love", "healing"]
category: "reflection" category: "reflection"
featuredEssay: false featuredEssay: false
draft: false draft: false
audio: true
--- ---
*by LATTE* *by LATTE*
@@ -5,6 +5,7 @@ pubDate: 2026-03-19
tags: ["love", "grief", "reflection", "personal", "relationships"] tags: ["love", "grief", "reflection", "personal", "relationships"]
category: "personal" category: "personal"
featuredEssay: false featuredEssay: false
audio: true
--- ---
*by LATTE* *by LATTE*
@@ -6,6 +6,7 @@ tags: ["self-hosting", "privacy", "digital minimalism", "cozy web", "personal in
category: "reflection" category: "reflection"
featuredEssay: false featuredEssay: false
draft: false draft: false
audio: true
--- ---
There is a certain kind of quiet that has become rare on the internet. There is a certain kind of quiet that has become rare on the internet.
@@ -6,6 +6,7 @@ tags: ["love", "grief", "reflection", "personal"]
category: "personal" category: "personal"
featuredEssay: false featuredEssay: false
draft: false draft: false
audio: true
--- ---
*by LATTE* *by LATTE*
@@ -6,6 +6,7 @@ tags: ["love", "relationships", "reflection", "emotional growth", "intimacy"]
category: "personal" category: "personal"
featuredEssay: true featuredEssay: true
readingOrder: 3 readingOrder: 3
audio: true
--- ---
Some relationships change you in quiet but permanent ways. Some relationships change you in quiet but permanent ways.
+2 -1
View File
@@ -4,6 +4,7 @@ description: "On suddenly carrying too much, being seen by the wrong person at t
pubDate: 2026-04-04 pubDate: 2026-04-04
tags: ["personal", "work", "reflection", "healing", "life"] tags: ["personal", "work", "reflection", "healing", "life"]
category: "reflection" category: "reflection"
audio: true
--- ---
Some weeks arrive with more variables than expected. Some weeks arrive with more variables than expected.
@@ -17,7 +18,7 @@ And then, quietly, the configuration changes.
## Suddenly, Everything Is Your Problem ## Suddenly, Everything Is Your Problem
I work in tech support. Helpdesk. I work in tech support - Helpdesk.
Hardware. Laptops. Phones. SIM cards. Loaner devices. Hardware. Laptops. Phones. SIM cards. Loaner devices.
Accounts across multiple platforms and software. Accounts across multiple platforms and software.
+121 -17
View File
@@ -119,17 +119,22 @@ const readingTime = getReadingTime(post.body);
{ {
post.data.audio && ( post.data.audio && (
<div class="audio-player"> <div class="ap">
<audio <audio id="ap-audio" preload="none" src={`/audio/${post.slug}.mp3`}></audio>
controls <button class="ap-btn" id="ap-btn" aria-label="Afspelen">
preload="none" <svg class="ap-icon ap-play" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
aria-label={`Luister naar: ${post.data.title}`} <path d="M8 5v14l11-7z"/>
> </svg>
<source <svg class="ap-icon ap-pause" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
src={`https://audio.hiddenden.cafe/${post.slug}.mp3`} <path d="M6 19h4V5H6zm8-14v14h4V5z"/>
type="audio/mpeg" </svg>
/> </button>
</audio> <div class="ap-waveform" id="ap-waveform">
{[38,55,72,88,65,92,78,58,82,96,68,52,86,100,74,60,90,80,63,84,70,94,58,80,74,90,63,84,70,54,80,63,90,74,58,84].map((h, i) => (
<span class="ap-bar" style={`height:${h}%;animation-delay:${((i * 73) % 600)}ms`}></span>
))}
</div>
<span class="ap-time" id="ap-time">0:00</span>
</div> </div>
) )
} }
@@ -247,6 +252,33 @@ const readingTime = getReadingTime(post.body);
</footer> </footer>
</div> </div>
</main> </main>
<script>
const ap = document.querySelector(".ap") as HTMLElement | null;
const audio = document.getElementById("ap-audio") as HTMLAudioElement | null;
const btn = document.getElementById("ap-btn");
const timeEl = document.getElementById("ap-time");
function fmt(s: number) {
const m = Math.floor(s / 60);
const sec = String(Math.floor(s % 60)).padStart(2, "0");
return `${m}:${sec}`;
}
btn?.addEventListener("click", () => {
if (!audio) return;
audio.paused ? audio.play() : audio.pause();
});
audio?.addEventListener("play", () => ap?.setAttribute("data-playing", ""));
audio?.addEventListener("pause", () => ap?.removeAttribute("data-playing"));
audio?.addEventListener("ended", () => {
ap?.removeAttribute("data-playing");
if (timeEl) timeEl.textContent = "0:00";
});
audio?.addEventListener("timeupdate", () => {
if (timeEl && audio) timeEl.textContent = fmt(audio.currentTime);
});
</script>
</BaseLayout> </BaseLayout>
<style> <style>
@@ -362,15 +394,87 @@ const readingTime = getReadingTime(post.body);
border-color: color-mix(in srgb, var(--color-accent) 45%, transparent); border-color: color-mix(in srgb, var(--color-accent) 45%, transparent);
} }
.audio-player { .ap {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-top: var(--space-md); margin-top: var(--space-md);
padding: 10px 14px;
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--color-accent) 22%, transparent);
border-radius: 999px;
} }
.audio-player audio { .ap-btn {
width: 100%; flex-shrink: 0;
height: 36px; width: 38px;
accent-color: var(--color-accent); height: 38px;
border-radius: 4px; border-radius: 50%;
background: var(--color-accent);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-bg);
transition: background 0.2s, transform 0.15s;
}
.ap-btn:hover {
background: var(--color-accent-bright);
transform: scale(1.07);
}
.ap-icon {
width: 18px;
height: 18px;
}
.ap-pause {
display: none;
}
.ap[data-playing] .ap-play {
display: none;
}
.ap[data-playing] .ap-pause {
display: block;
}
.ap-waveform {
flex: 1;
display: flex;
align-items: center;
gap: 2px;
height: 32px;
}
.ap-bar {
flex: 1;
background: var(--color-accent);
border-radius: 999px;
opacity: 0.35;
transform-origin: center;
}
.ap[data-playing] .ap-bar {
opacity: 0.85;
animation: ap-wave 0.55s ease-in-out infinite alternate;
}
@keyframes ap-wave {
from { transform: scaleY(0.25); }
to { transform: scaleY(1); }
}
.ap-time {
flex-shrink: 0;
font-size: 0.75rem;
color: var(--color-text-dim);
min-width: 2.2rem;
text-align: right;
font-variant-numeric: tabular-nums;
} }
.content { .content {