Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
dc4417e
feat(landing): front-matter parser for the site generator
chiliec Jul 6, 2026
8d3604a
feat(landing): minimal markdown converter for the site generator
chiliec Jul 6, 2026
8b13ec5
feat(landing): template renderer with partials for the site generator
chiliec Jul 6, 2026
2870952
feat(landing): shared layout, partials, site data, split CSS
chiliec Jul 6, 2026
21b0b02
feat(landing): add site.json data file; exempt landing src/data from …
chiliec Jul 6, 2026
b8c9fa8
feat(landing): static site generator + build wiring
chiliec Jul 6, 2026
7a507de
fix(landing): guard against path traversal in blog slugs
chiliec Jul 6, 2026
346b7cc
feat(landing): home hub page
chiliec Jul 6, 2026
f427125
feat(landing): gameplay, economy, faq pages
chiliec Jul 6, 2026
aa5ca1c
feat(landing): screens gallery page
chiliec Jul 6, 2026
3a7e38f
feat(landing): history timeline + v1/v2/v3 version stories
chiliec Jul 6, 2026
6a670c8
feat(landing): investors & partners page (3 tracks)
chiliec Jul 6, 2026
ea540fc
feat(landing): press kit, legal pages, seed devlog post
chiliec Jul 6, 2026
f34bfca
fix(landing): press page brand swatches use CSS vars
chiliec Jul 6, 2026
9fb4bab
feat(metrics): countActiveSince + totalPaidOut helpers
chiliec Jul 6, 2026
f01b261
feat(metrics): public metrics pure handler with 60s cache
chiliec Jul 6, 2026
15b03d1
feat(metrics): wire /api/public/metrics composer + registration
chiliec Jul 6, 2026
6318228
feat(landing): fetch and display live metrics with static fallbacks
chiliec Jul 6, 2026
5f39108
feat(landing): serve dist/ with clean-URL resolution and 404 page
chiliec Jul 6, 2026
5b6397e
fix(landing): strengthen path traversal guard in resolveLandingFile
chiliec Jul 6, 2026
c429fe5
fix(landing): use path.sep in traversal guard for cross-platform corr…
chiliec Jul 6, 2026
a1732d0
fix(landing): dev static assets, test gate, remove lean()/Buffer arti…
chiliec Jul 6, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ dist

# Data
data/
!src/landing/src/data/

*.env

Expand Down
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ COPY . .
RUN npm --prefix src/frontend ci
RUN npm --prefix src/frontend run build:frontend

# Build the static landing site into src/landing/dist
RUN npm run build:landing

USER node

EXPOSE 80
Expand Down
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,25 @@
"npm": ">=8.0.0"
},
"scripts": {
"dev": "tsx watch src/main.ts",
"dev": "npm run build:landing && tsx watch src/main.ts",
"tunnel": "ngrok http --domain dominant-annually-lobster.ngrok-free.app 3000",
"xtunnel": "xtunnel http 3000",
"build:bot": "tsc --noEmit false",
"build:frontend": "npm --prefix src/frontend run build",
"build:all": "npm run build:bot && npm run build:frontend",
"build:landing": "tsx scripts/build-landing.ts",
"build:all": "npm run build:bot && npm run build:landing && npm run build:frontend",
"update:bot": "npx npm-check-updates -u && npm install",
"update:frontend": "cd src/frontend && npx npm-check-updates -u && npm install",
"update:all": "npm run update:bot && npm run update:frontend",
"prepare": "npx husky",
"lint": "eslint . --ignore-pattern .github/ --ignore-pattern docs/",
"lint": "eslint . --ignore-pattern .github/ --ignore-pattern docs/ --ignore-pattern .superpowers/",
"format": "prettier --write \"**/*.{ts,js,vue,json,css,scss,md}\"",
"format:check": "prettier --check \"**/*.{ts,js,vue,json,css,scss,md}\"",
"start": "tsc && tsx ./src/main.ts",
"start:force": "tsx ./src/main.ts",
"typecheck": "tsc",
"test": "npm run test:backend",
"test:backend": "NODE_ENV=test node --import tsx --test $(find src -name '*.test.ts' -not -path 'src/frontend/*')",
"test:backend": "NODE_ENV=test node --import tsx --test scripts/landing/*.test.ts $(find src -name '*.test.ts' -not -path 'src/frontend/*')",
"test:coverage": "NODE_ENV=test node --import tsx --test --experimental-test-coverage --test-coverage-include='src/**/*.ts' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/frontend/**' $(find src -name '*.test.ts' -not -path 'src/frontend/*')"
},
"dependencies": {
Expand Down
203 changes: 203 additions & 0 deletions scripts/build-landing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { cp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { parseFrontMatter } from './landing/frontmatter'
import { renderMarkdown } from './landing/markdown'
import { renderTemplate } from './landing/template'

const ROOT = path.dirname(fileURLToPath(import.meta.url))
const SRC = path.join(ROOT, '../src/landing/src')
const PUBLIC = path.join(ROOT, '../src/landing/public')
const OUT = path.join(ROOT, '../src/landing/dist')

interface Site {
brand: string
telegramBot: string
contact: string
baseUrl: string
nav: { slug: string, label: string }[]
footer: { slug: string, label: string }[]
metricsFallback: Record<string, string>
}

async function readText(p: string): Promise<string> {
return readFile(p, 'utf-8')
}

function href(slug: string): string {
return slug === '' ? '/' : `/${slug}/`
}

function navLinksHtml(site: Site, active: string): string {
return site.nav
.map((n) => {
const cls = n.slug === active ? ' class="active"' : ''
return `<a href="${href(n.slug)}"${cls}>${n.label}</a>`
})
.join('\n ')
}

function footerLinksHtml(site: Site): string {
return site.footer
.map(n => `<a href="${href(n.slug)}">${n.label}</a>`)
.join('\n ')
}

async function main() {
const site: Site = JSON.parse(await readText(path.join(SRC, 'data/site.json')))
const layout = await readText(path.join(SRC, 'layout.html'))
const partials = {
nav: await readText(path.join(SRC, 'partials/nav.html')),
footer: await readText(path.join(SRC, 'partials/footer.html')),
}

await rm(OUT, { recursive: true, force: true })
await mkdir(OUT, { recursive: true })

const urls: string[] = []

const emit = async (
outRel: string,
content: string,
meta: { title: string, description: string, active?: string, extraCss?: string },
) => {
const html = renderTemplate(
layout,
{
title: meta.title,
description: meta.description,
extraCss: meta.extraCss ?? '',
content,
navLinks: navLinksHtml(site, meta.active ?? ''),
footerLinks: footerLinksHtml(site),
},
partials,
)
const outPath = path.join(OUT, outRel)
await mkdir(path.dirname(outPath), { recursive: true })
await writeFile(outPath, html)
const url = outRel.replace(/index\.html$/, '').replace(/\\/g, '/')
urls.push(`/${url}`)
}

// 1) HTML page fragments in pages/
const pageDir = path.join(SRC, 'pages')
for (const file of await readdir(pageDir)) {
if (!file.endsWith('.html')) {
continue
}
const name = file.replace(/\.html$/, '')
const raw = await readText(path.join(pageDir, file))
const { data, body } = parseFrontMatter(raw)
const outRel = name === 'home' ? 'index.html' : path.join(name, 'index.html')
await emit(outRel, body, {
title: data.title ?? site.brand,
description: data.description ?? '',
active: data.active,
extraCss: data.extraCss === 'app'
? '<link rel="stylesheet" href="/styles/app-preview.css">'
: '',
})
}

// 2) Markdown content: history + legal (single files → <slug>/index.html)
const renderMdFile = async (file: string, outRel: string, active: string) => {
const raw = await readText(file)
const { data, body } = parseFrontMatter(raw)
const article = `<section><div class="wrap" style="max-width:820px;">${renderMarkdown(body)}</div></section>`
await emit(outRel, article, {
title: data.title ?? site.brand,
description: data.description ?? '',
active,
extraCss: data.extraCss === 'app'
? '<link rel="stylesheet" href="/styles/app-preview.css">'
: '',
})
}

const historyDir = path.join(SRC, 'content/history')
for (const file of await readdir(historyDir)) {
if (!file.endsWith('.md')) {
continue
}
const name = file.replace(/\.md$/, '')
const outRel = name === 'index' ? path.join('history', 'index.html') : path.join('history', name, 'index.html')
await renderMdFile(path.join(historyDir, file), outRel, 'history')
}

const legalDir = path.join(SRC, 'content/legal')
for (const file of await readdir(legalDir)) {
if (!file.endsWith('.md')) {
continue
}
const name = file.replace(/\.md$/, '')
await renderMdFile(path.join(legalDir, file), path.join(name, 'index.html'), '')
}

// 3) Blog: posts + generated index
const blogDir = path.join(SRC, 'content/blog')
const posts: { slug: string, title: string, date: string, description: string }[] = []
for (const file of await readdir(blogDir)) {
if (!file.endsWith('.md')) {
continue
}
const raw = await readText(path.join(blogDir, file))
const { data, body } = parseFrontMatter(raw)
const slug = data.slug ?? file.replace(/\.md$/, '')
const outPath = path.join(OUT, 'blog', slug, 'index.html')
if (!outPath.startsWith(OUT + path.sep)) {
throw new Error(`Unsafe slug in ${file}: ${slug}`)
}
posts.push({ slug, title: data.title ?? slug, date: data.date ?? '', description: data.description ?? '' })
const article = `<section><div class="wrap" style="max-width:820px;">`
+ `<p class="hero-sub" style="margin:0 0 1rem;">${data.date ?? ''}</p>`
+ `${renderMarkdown(body)}`
+ `<p style="margin-top:2rem;"><a href="/blog/">← Back to devlog</a></p></div></section>`
await emit(path.join('blog', slug, 'index.html'), article, {
title: `${data.title ?? slug} — Cube Worlds`,
description: data.description ?? '',
active: 'blog',
})
}
posts.sort((a, b) => (a.date < b.date ? 1 : -1))
const blogList = posts
.map(p => `<a class="feature" style="text-decoration:none;display:block;" href="/blog/${p.slug}/">`
+ `<h3>${p.title}</h3><p class="hero-sub" style="margin:0 0 0.5rem;">${p.date}</p>`
+ `<p>${p.description}</p></a>`)
.join('\n')
const blogIndex = `<section><div class="wrap">`
+ `<div class="section-head"><div class="eyebrow">Devlog</div><h2>Build updates</h2></div>`
+ `<div class="feature-grid">${blogList}</div></div></section>`
await emit(path.join('blog', 'index.html'), blogIndex, {
title: 'Devlog — Cube Worlds',
description: 'Development updates from Cube Worlds.',
active: 'blog',
})

// 4) Static assets
await cp(path.join(SRC, 'styles'), path.join(OUT, 'styles'), { recursive: true })
await cp(PUBLIC, OUT, { recursive: true })

// 5) 404 page (styled with chrome)
const notFound = `<header class="hero"><div class="wrap">`
+ `<h1>404</h1><p class="lead">That page drifted off into the cosmos.</p>`
+ `<div class="hero-cta"><a class="btn btn-primary" href="/">Back home</a></div></div></header>`
await emit('404.html', notFound, { title: 'Not found — Cube Worlds', description: 'Page not found.' })

// 6) robots + sitemap
await writeFile(path.join(OUT, 'robots.txt'), `User-agent: *\nAllow: /\nSitemap: ${site.baseUrl}/sitemap.xml\n`)
const sitemapUrls = [...new Set(urls)]
.filter(u => !u.endsWith('404.html'))
.map(u => ` <url><loc>${site.baseUrl}${u}</loc></url>`)
.join('\n')
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${sitemapUrls}\n</urlset>\n`
await writeFile(path.join(OUT, 'sitemap.xml'), sitemap)

process.stdout.write(`Landing built: ${new Set(urls).size} pages → ${path.relative(process.cwd(), OUT)}\n`)
}

main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`)
process.exitCode = 1
})
23 changes: 23 additions & 0 deletions scripts/landing/frontmatter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/* eslint-disable test/no-import-node-test */
import assert from 'node:assert/strict'
import test from 'node:test'
import { parseFrontMatter } from './frontmatter'

test('parses a front-matter block and returns the body', () => {
const raw = '---\ntitle: Hello\ndescription: A page\n---\n# Body\ntext'
const { data, body } = parseFrontMatter(raw)
assert.equal(data.title, 'Hello')
assert.equal(data.description, 'A page')
assert.equal(body, '# Body\ntext')
})

test('returns empty data when there is no front matter', () => {
const { data, body } = parseFrontMatter('# Just body')
assert.deepEqual(data, {})
assert.equal(body, '# Just body')
})

test('values containing colons are preserved after the first colon', () => {
const { data } = parseFrontMatter('---\ntitle: A: B: C\n---\nx')
assert.equal(data.title, 'A: B: C')
})
24 changes: 24 additions & 0 deletions scripts/landing/frontmatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export interface FrontMatter {
data: Record<string, string>
body: string
}

export function parseFrontMatter(raw: string): FrontMatter {
const match = /^---\n([\s\S]*?)\n---\n?/.exec(raw)
if (!match) {
return { data: {}, body: raw }
}
const data: Record<string, string> = {}
for (const line of match[1].split('\n')) {
const idx = line.indexOf(':')
if (idx === -1) {
continue
}
const key = line.slice(0, idx).trim()
const value = line.slice(idx + 1).trim()
if (key) {
data[key] = value
}
}
return { data, body: raw.slice(match[0].length) }
}
33 changes: 33 additions & 0 deletions scripts/landing/markdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/* eslint-disable test/no-import-node-test */
import assert from 'node:assert/strict'
import test from 'node:test'
import { renderMarkdown } from './markdown'

test('renders headings', () => {
assert.equal(renderMarkdown('## Title'), '<h2>Title</h2>')
})

test('renders a paragraph with inline formatting', () => {
assert.equal(
renderMarkdown('A **bold** and *italic* and `code` word'),
'<p>A <strong>bold</strong> and <em>italic</em> and <code>code</code> word</p>',
)
})

test('renders links and images', () => {
assert.equal(renderMarkdown('[T](https://x.io)'), '<p><a href="https://x.io">T</a></p>')
assert.equal(renderMarkdown('![alt](/a.png)'), '<p><img src="/a.png" alt="alt"></p>')
})

test('renders an unordered list', () => {
assert.equal(renderMarkdown('- one\n- two'), '<ul><li>one</li><li>two</li></ul>')
})

test('renders blockquote and hr', () => {
assert.equal(renderMarkdown('> quote'), '<blockquote>quote</blockquote>')
assert.equal(renderMarkdown('---'), '<hr>')
})

test('escapes raw HTML in text', () => {
assert.equal(renderMarkdown('a < b & c'), '<p>a &lt; b &amp; c</p>')
})
Loading