diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f627bab --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,10 @@ +name: test +on: [push, pull_request] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install + - run: bun run test diff --git a/.stylelintrc b/.stylelintrc index cfcd676..38c6d5d 100644 --- a/.stylelintrc +++ b/.stylelintrc @@ -15,6 +15,7 @@ "alpha-value-notation": null, "at-rule-no-vendor-prefix": null, "selector-max-id": 0, + "no-descending-specificity": null, "declaration-no-important": true } } diff --git a/TODO.md b/TODO.md index d2ccdea..167c124 100644 --- a/TODO.md +++ b/TODO.md @@ -24,9 +24,8 @@ addressed. - [ ] logo animation should be vertically centered at top of page - [ ] maybe indicate down to hint for scroll, z-index 9999 - [ ] experiment with lottiefile animation as a permanent background underneath content hmm -- [ ] 404 page +- [ ] 404 page - similar motion idea to homepage animation - [ ] ideas list page -- [ ] broken link + other smoke tests - [ ] idea individual pages (obv with coherent thoughts) - [ ] for ideas pages, generate og images at build time - see: https://github.com/vercel/satori#documentation - [ ] Workflow - each new project (especially the technical ones), get 2 posts: project page and idea/blog page announcing it. That then gets pushed to socials, etc. Tight workflow, and should help with SEO @@ -79,16 +78,6 @@ addressed. === -10k24 Studio Positioning: -Homepage (KPI framing): -"10k24 builds technical creative systems for organizations that need distinctive brands without traditional agency overhead. -We specialize in: +About page notes -Visual identity + technical implementation (design + code) -Generative design tools for creative teams -Brand systems for cultural organizations and startups - -Recent work: International festival reaching 1M+ people across 21 countries, luxury interiors brand, wellness expansion, consumer tech launch." -About page (KPI story): "Founded by Shakeel Mohamed, 10k24 combines 10 years of software engineering across developer tools, performance with graduate-level design training to create brand systems that work at scale. -Our approach: Technical rigor meets design intention. We build tools, not just deliverables." diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..98123b5 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +root = "./tests" diff --git a/docs/projects/clock/index.html b/docs/projects/clock/index.html index a3e1b55..4aec3ef 100644 --- a/docs/projects/clock/index.html +++ b/docs/projects/clock/index.html @@ -4,7 +4,7 @@ 10k24 Studio | Clocks - + @@ -15,14 +15,14 @@ - + - + @@ -105,7 +105,7 @@

Clocks

-

Ticking down the year on a 32x32 pixel grid.

+

Clocks based on a 32x32 pixel grid.

diff --git a/docs/projects/midjourney/index.html b/docs/projects/midjourney/index.html index 3b8c0b5..c065cd6 100644 --- a/docs/projects/midjourney/index.html +++ b/docs/projects/midjourney/index.html @@ -104,9 +104,9 @@

Midjourney

-

Midjourney has received tremendous press since 2021, yet non-technical audiences are often discouraged by their visual communication. This project positions them to remain competitive in a rapidly growing AI landscape by making the brand more approachable.

+

Midjourney has received tremendous press since 2021, yet non-technical audiences are often discouraged by their visual communication. This project positions them to remain competitive in a rapidly growing AI landscape by making the identity more approachable.


-

The strategy draws from the founder’s quotes and mission statement. Light refraction serves as the central metaphor — symbolizing the expansive creativity their tools make possible. Brand colors connect across cultures, ages, and industries, distinguishing Midjourney from the sea of tech-company blue.

+

The strategy draws from the founder’s quotes and mission statement. Light refraction serves as the central metaphor — symbolizing the expansive creativity their tools make possible. Visual identity colors connect across cultures, ages, and industries, distinguishing Midjourney from the sea of tech-company blue.

The new logo is an evolution of the previous sailboat symbol — distilled to its simplest abstraction.

@@ -115,13 +115,13 @@

Midjourney

-

The Sail. Through extensive identity exploration, this was the simplest abstraction of their sailboat logo.

+

The Sail. Through extensive logo exploration, this was the simplest abstraction of their sailboat logo.

-

Brand colors emerged from light refraction, symbolizing expansive creativity possible with their products.

+

Visual identity colors emerged from light refraction, symbolizing expansive creativity possible with their products.

@@ -130,7 +130,7 @@

Midjourney

- Logo family expressed through all brand colors: vertical lockup, icon, wordmark. + Logo family expressed through all visual identity colors: vertical lockup, icon, wordmark.
diff --git a/docs/projects/planetary-society/index.html b/docs/projects/planetary-society/index.html index 8374a61..9461404 100644 --- a/docs/projects/planetary-society/index.html +++ b/docs/projects/planetary-society/index.html @@ -106,10 +106,10 @@

Planetary Society

-

The Planetary Society is a non-profit advocacy organization for space exploration, based in Pasadena, California. The identity expands the audience beyond enthusiasts — exploring the profound human experience of our relationship to the cosmos. A primary serif typeface gives every brand touchpoint an elevated, timeless feeling. Removing “The” from the name created a cleaner shorthand.

+

The Planetary Society is a non-profit advocacy organization for space exploration, based in Pasadena, California. The identity expands the audience beyond enthusiasts — exploring the profound human experience of our relationship to the cosmos. A primary serif typeface gives every touchpoint an elevated, timeless feeling. Removing “The” from the name created a cleaner shorthand.

-

A special brand style was designed for the Society’s 45th anniversary, including a custom modular variable typeface inspired by planetary orbits. The anniversary identity envisions a weekend celebration on Catalina Island for members and donors.

+

A special campaign style was designed for the Society’s 45th anniversary, including a custom modular variable typeface inspired by planetary orbits. The anniversary identity envisions a weekend celebration on Catalina Island for members and donors.

@@ -125,7 +125,7 @@

Planetary Society

-

Overview of the identity system. A serif typeface breaks the retro-sci-fi standard in aerospace, giving all brand messaging a timeless, contemplative quality.

+

Overview of the identity system. A serif typeface breaks the retro-sci-fi standard in aerospace, giving all messaging a timeless, contemplative quality.

@@ -159,7 +159,7 @@

Planetary Society


45th Anniversary Campaign


-

The 45th anniversary identity carries a distinct brand and typographic voice. The highlight of the year would be a retreat on Catalina Island for members and donors, “Under the Stars.”

+

The 45th anniversary identity carries a distinct look and typographic voice. The highlight of the year would be a retreat on Catalina Island for members and donors, “Under the Stars.”

diff --git a/docs/projects/salgirah-festival/index.html b/docs/projects/salgirah-festival/index.html index dcf2dd8..44ab61e 100644 --- a/docs/projects/salgirah-festival/index.html +++ b/docs/projects/salgirah-festival/index.html @@ -121,7 +121,7 @@

Salgirah Festival



-

Global Brand Activation

+

Global Activation


diff --git a/docs/styles.css b/docs/styles.css index 7c3c051..feec310 100644 --- a/docs/styles.css +++ b/docs/styles.css @@ -37,7 +37,6 @@ a { text-decoration: none; } -/* TODO: bring back once project pages are added */ a:hover { text-decoration: underline; text-underline-offset: 0.25rem; @@ -179,8 +178,6 @@ section { .section-item { max-width: 50%; - - /* margin-bottom: 1.5rem; */ margin-bottom: 4rem; } @@ -191,7 +188,6 @@ section { } .hero { - /*min-height: 100vh;*/ margin-top: -4rem; } @@ -249,10 +245,6 @@ h3.project-title, h3.idea-title { text-decoration: none; } -/* h3.project-title:hover { - text-decoration: underline; -} */ - p.project-meta, p.idea-meta { margin-top: 0.125rem; margin-bottom: 0.125rem; @@ -271,10 +263,6 @@ p.project-meta, p.idea-meta { max-width: 60vw; } -.portfolio-img { - width: 100%; -} - .video-16-9 { width: 100%; height: auto; diff --git a/package.json b/package.json index a9daeaa..17135c1 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,9 @@ "lint:fix": "bunx stylelint --fix src/*.css", "fix": "bun run lint:fix", "optimize": "node scripts/optimize.js", - "test:words": "bunx cspell lint 'docs/**/*.html' --no-progress --no-summary --quiet && echo 'test:words: clean'", - "test": "bun run test:words" + "pretest": "bun run build && bun run lint", + "posttest": "bunx cspell lint 'docs/**/*.html' --no-progress --no-summary --quiet && echo 'posttest: clean'", + "test": "bun test" }, "private": true, "devDependencies": { diff --git a/src/projects/clock/index.liquid b/src/projects/clock/index.liquid index 994a6c9..2d2207d 100644 --- a/src/projects/clock/index.liquid +++ b/src/projects/clock/index.liquid @@ -7,6 +7,7 @@ tags: - creative coding cover: cover.png +description: "Clocks based on a 32x32 pixel grid." media: Creative Coding location: Los Angeles, CA @@ -17,6 +18,6 @@ hideCover: false -

Ticking down the year on a 32x32 pixel grid.

+

Clocks based on a 32x32 pixel grid.

{% include "_includes/partials/square-p5-sketch", sketch:"./clock.js" %} diff --git a/src/projects/midjourney/index.liquid b/src/projects/midjourney/index.liquid index da5ba02..9986dea 100644 --- a/src/projects/midjourney/index.liquid +++ b/src/projects/midjourney/index.liquid @@ -12,9 +12,9 @@ client: Conceptual
-

Midjourney has received tremendous press since 2021, yet non-technical audiences are often discouraged by their visual communication. This project positions them to remain competitive in a rapidly growing AI landscape by making the brand more approachable.

+

Midjourney has received tremendous press since 2021, yet non-technical audiences are often discouraged by their visual communication. This project positions them to remain competitive in a rapidly growing AI landscape by making the identity more approachable.


-

The strategy draws from the founder’s quotes and mission statement. Light refraction serves as the central metaphor — symbolizing the expansive creativity their tools make possible. Brand colors connect across cultures, ages, and industries, distinguishing Midjourney from the sea of tech-company blue.

+

The strategy draws from the founder’s quotes and mission statement. Light refraction serves as the central metaphor — symbolizing the expansive creativity their tools make possible. Visual identity colors connect across cultures, ages, and industries, distinguishing Midjourney from the sea of tech-company blue.

The new logo is an evolution of the previous sailboat symbol — distilled to its simplest abstraction.

@@ -23,13 +23,13 @@ client: Conceptual
-

The Sail. Through extensive identity exploration, this was the simplest abstraction of their sailboat logo.

+

The Sail. Through extensive logo exploration, this was the simplest abstraction of their sailboat logo.

-

Brand colors emerged from light refraction, symbolizing expansive creativity possible with their products.

+

Visual identity colors emerged from light refraction, symbolizing expansive creativity possible with their products.

@@ -38,7 +38,7 @@ client: Conceptual
- Logo family expressed through all brand colors: vertical lockup, icon, wordmark. + Logo family expressed through all visual identity colors: vertical lockup, icon, wordmark.
diff --git a/src/projects/planetary-society/index.liquid b/src/projects/planetary-society/index.liquid index eb337e7..d407279 100644 --- a/src/projects/planetary-society/index.liquid +++ b/src/projects/planetary-society/index.liquid @@ -12,10 +12,10 @@ client: The Planetary Society (proposed)
-

The Planetary Society is a non-profit advocacy organization for space exploration, based in Pasadena, California. The identity expands the audience beyond enthusiasts — exploring the profound human experience of our relationship to the cosmos. A primary serif typeface gives every brand touchpoint an elevated, timeless feeling. Removing “The” from the name created a cleaner shorthand.

+

The Planetary Society is a non-profit advocacy organization for space exploration, based in Pasadena, California. The identity expands the audience beyond enthusiasts — exploring the profound human experience of our relationship to the cosmos. A primary serif typeface gives every touchpoint an elevated, timeless feeling. Removing “The” from the name created a cleaner shorthand.

-

A special brand style was designed for the Society’s 45th anniversary, including a custom modular variable typeface inspired by planetary orbits. The anniversary identity envisions a weekend celebration on Catalina Island for members and donors.

+

A special campaign style was designed for the Society’s 45th anniversary, including a custom modular variable typeface inspired by planetary orbits. The anniversary identity envisions a weekend celebration on Catalina Island for members and donors.

@@ -31,7 +31,7 @@ client: The Planetary Society (proposed)
-

Overview of the identity system. A serif typeface breaks the retro-sci-fi standard in aerospace, giving all brand messaging a timeless, contemplative quality.

+

Overview of the identity system. A serif typeface breaks the retro-sci-fi standard in aerospace, giving all messaging a timeless, contemplative quality.

@@ -65,7 +65,7 @@ client: The Planetary Society (proposed)

45th Anniversary Campaign


-

The 45th anniversary identity carries a distinct brand and typographic voice. The highlight of the year would be a retreat on Catalina Island for members and donors, “Under the Stars.”

+

The 45th anniversary identity carries a distinct look and typographic voice. The highlight of the year would be a retreat on Catalina Island for members and donors, “Under the Stars.”

diff --git a/src/projects/salgirah-festival/index.liquid b/src/projects/salgirah-festival/index.liquid index 7ed6aae..5e9ab15 100644 --- a/src/projects/salgirah-festival/index.liquid +++ b/src/projects/salgirah-festival/index.liquid @@ -27,7 +27,7 @@ client: The Ismaili Community

-

Global Brand Activation

+

Global Activation


diff --git a/src/styles.css b/src/styles.css index 7c3c051..feec310 100644 --- a/src/styles.css +++ b/src/styles.css @@ -37,7 +37,6 @@ a { text-decoration: none; } -/* TODO: bring back once project pages are added */ a:hover { text-decoration: underline; text-underline-offset: 0.25rem; @@ -179,8 +178,6 @@ section { .section-item { max-width: 50%; - - /* margin-bottom: 1.5rem; */ margin-bottom: 4rem; } @@ -191,7 +188,6 @@ section { } .hero { - /*min-height: 100vh;*/ margin-top: -4rem; } @@ -249,10 +245,6 @@ h3.project-title, h3.idea-title { text-decoration: none; } -/* h3.project-title:hover { - text-decoration: underline; -} */ - p.project-meta, p.idea-meta { margin-top: 0.125rem; margin-bottom: 0.125rem; @@ -271,10 +263,6 @@ p.project-meta, p.idea-meta { max-width: 60vw; } -.portfolio-img { - width: 100%; -} - .video-16-9 { width: 100%; height: auto; diff --git a/tests/smoke/data.test.js b/tests/smoke/data.test.js new file mode 100644 index 0000000..35eea6f --- /dev/null +++ b/tests/smoke/data.test.js @@ -0,0 +1,35 @@ +const { describe, expect, test } = require("bun:test"); +const { readFileSync } = require("node:fs"); +const { resolve, join } = require("node:path"); + +const DATA_DIR = resolve(__dirname, "../..", "src/_data"); + +function readJson(filename) { + return JSON.parse(readFileSync(join(DATA_DIR, filename), "utf8")); +} + +describe("metadata.json", () => { + const data = readJson("metadata.json"); + + test("baseURL is a non-empty string", () => { + expect(typeof data.baseURL).toBe("string"); + expect(data.baseURL.length).toBeGreaterThan(0); + }); + + test("author.name is a non-empty string", () => { + expect(typeof data.author?.name).toBe("string"); + expect(data.author.name.length).toBeGreaterThan(0); + }); + + test("author.email is a non-empty string", () => { + expect(typeof data.author?.email).toBe("string"); + expect(data.author.email.length).toBeGreaterThan(0); + }); + + test("social links are non-empty strings", () => { + for (const [key, value] of Object.entries(data.social || {})) { + expect(typeof value, `social.${key} must be a string`).toBe("string"); + expect(value.length, `social.${key} must not be empty`).toBeGreaterThan(0); + } + }); +}); diff --git a/tests/smoke/links.test.js b/tests/smoke/links.test.js new file mode 100644 index 0000000..7a78f5f --- /dev/null +++ b/tests/smoke/links.test.js @@ -0,0 +1,195 @@ +const { describe, expect, test } = require("bun:test"); +const { readdirSync, readFileSync, existsSync } = require("node:fs"); +const { resolve, join, relative, dirname } = require("node:path"); + +const DOCS = resolve(__dirname, "../..", "docs"); + +function collectHtmlFiles(dir) { + const files = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...collectHtmlFiles(full)); + } else if (entry.name === "index.html") { + files.push(full); + } + } + return files; +} + +function isExternal(value) { + return /^(https?:|mailto:|tel:|javascript:|#)/.test(value) || value.startsWith("//"); +} + +function hrefsFromHtml(html) { + const hrefs = []; + const re = /]*\bhref=["']([^"']+)["']/gi; + let match; + while ((match = re.exec(html)) !== null) { + hrefs.push(match[1]); + } + return hrefs; +} + +function assetsFromHtml(html) { + const assets = []; + const patterns = [ + /]*\bsrc=["']([^"']+)["']/gi, + /]*\bhref=["']([^"']+)["']/gi, + /]*\bsrc=["']([^"']+)["']/gi, + /]*\bsrc=["']([^"']+)["']/gi, + /]*\bsrc=["']([^"']+)["']/gi, + /]*\bsrc=["']([^"']+)["']/gi, + /data-sketch-src=["']([^"']+)["']/gi, + ]; + for (const re of patterns) { + let match; + while ((match = re.exec(html)) !== null) { + assets.push(match[1]); + } + } + return assets; +} + +function idsFromHtml(html) { + const ids = []; + const re = /\sid=["']([^"']+)["']/gi; + let match; + while ((match = re.exec(html)) !== null) { + ids.push(match[1]); + } + return ids; +} + +function resolveRef(ref, sourceFile) { + if (ref.startsWith("#")) { + return { kind: "anchor", target: ref }; + } + if (ref.startsWith("/")) { + const rootRelative = join(DOCS, ref); + if (existsSync(rootRelative)) { + return { kind: "file", file: rootRelative }; + } + if (!ref.endsWith("/") && !ref.includes(".")) { + const withIndex = join(DOCS, ref, "index.html"); + if (existsSync(withIndex)) { + return { kind: "file", file: withIndex }; + } + } + if (!ref.includes(".")) { + const withIndex = join(DOCS, ref.slice(1), "index.html"); + if (existsSync(withIndex)) { + return { kind: "file", file: withIndex }; + } + } + return { kind: "file", file: rootRelative }; + } + if (ref.startsWith(".")) { + const resolved = resolve(dirname(sourceFile), ref); + return { kind: "file", file: resolved }; + } + const resolved = resolve(dirname(sourceFile), ref); + return { kind: "file", file: resolved }; +} + +function isInComment(html, index) { + const before = html.slice(0, index); + const lastCommentStart = before.lastIndexOf(""); + if (lastCommentStart === -1) return false; + return lastCommentStart > lastCommentEnd; +} + +function findRefPositions(html, ref) { + const positions = []; + let idx = 0; + while (true) { + idx = html.indexOf(ref, idx); + if (idx === -1) break; + positions.push(idx); + idx += ref.length; + } + return positions; +} + +function isInAnyComment(html, ref) { + const positions = findRefPositions(html, ref); + for (const pos of positions) { + if (!isInComment(html, pos)) return false; + } + return positions.length > 0; +} + +describe("internal links", () => { + const htmlFiles = collectHtmlFiles(DOCS); + const broken = []; + + for (const file of htmlFiles) { + const html = readFileSync(file, "utf8"); + const hrefs = hrefsFromHtml(html); + const rel = relative(DOCS, file); + + for (const href of hrefs) { + if (isExternal(href)) continue; + if (isInAnyComment(html, href)) continue; + + const result = resolveRef(href, file); + if (result.kind === "file" && !existsSync(result.file)) { + broken.push(`${rel}: ${href} → ${relative(DOCS, result.file)} (not found)`); + } + } + } + + test("all internal hrefs resolve to existing files", () => { + expect(broken, `Broken internal links:\n${broken.join("\n")}`).toEqual([]); + }); +}); + +describe("anchor fragments", () => { + const htmlFiles = collectHtmlFiles(DOCS); + + for (const file of htmlFiles) { + const html = readFileSync(file, "utf8"); + const ids = new Set(idsFromHtml(html)); + const hrefs = hrefsFromHtml(html); + const rel = relative(DOCS, file); + + const anchors = hrefs + .map(h => h.match(/^#(.+)/)) + .filter(Boolean) + .map(m => m[1]); + + const missingAnchors = [...new Set(anchors.filter(id => !ids.has(id)))]; + + test(`${rel} anchor fragments exist`, () => { + expect(missingAnchors, + `${rel} missing anchor targets: ${missingAnchors.join(", ")}` + ).toEqual([]); + }); + } +}); + +describe("asset references", () => { + const htmlFiles = collectHtmlFiles(DOCS); + const broken = []; + + for (const file of htmlFiles) { + const html = readFileSync(file, "utf8"); + const refs = assetsFromHtml(html); + const rel = relative(DOCS, file); + + for (const ref of refs) { + if (isExternal(ref)) continue; + if (isInAnyComment(html, ref)) continue; + + const result = resolveRef(ref, file); + if (result.kind === "file" && !existsSync(result.file)) { + broken.push(`${rel}: ${ref} → ${relative(DOCS, result.file)} (not found)`); + } + } + } + + test("all internal asset references resolve to existing files", () => { + expect(broken, `Broken asset references:\n${broken.join("\n")}`).toEqual([]); + }); +}); diff --git a/tests/smoke/smoke.test.js b/tests/smoke/smoke.test.js new file mode 100644 index 0000000..ca4ba11 --- /dev/null +++ b/tests/smoke/smoke.test.js @@ -0,0 +1,114 @@ +const { describe, expect, test } = require("bun:test"); +const { readdirSync, readFileSync, existsSync } = require("node:fs"); +const { resolve, join, relative } = require("node:path"); + +const DOCS = resolve(__dirname, "../..", "docs"); +const SITEMAP_PATH = join(DOCS, "sitemap.txt"); +const ORIGIN = "https://10k24.com"; + +const EXPECTED_SITEMAP_URLS = [ + `${ORIGIN}`, + `${ORIGIN}/projects/planetary-society/`, + `${ORIGIN}/projects/salgirah-festival/`, + `${ORIGIN}/projects/midjourney/`, +]; + +function collectHtmlFiles(dir) { + const files = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...collectHtmlFiles(full)); + } else if (entry.name === "index.html") { + files.push(full); + } + } + return files; +} + +function sitemapUrlToPath(url) { + const pathname = new URL(url).pathname; + const relativePath = pathname === "/" ? "index.html" : join(pathname.slice(1), "index.html"); + return join(DOCS, relativePath); +} + +describe("sitemap", () => { + test("contains expected URLs in order", () => { + const content = readFileSync(SITEMAP_PATH, "utf8"); + const urls = content.trim().split("\n"); + expect(urls).toEqual(EXPECTED_SITEMAP_URLS); + }); + + test("no empty lines or trailing whitespace", () => { + const content = readFileSync(SITEMAP_PATH, "utf8"); + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + expect(lines[i], `line ${i + 1} has trailing whitespace`).toBe(lines[i].trimEnd()); + } + expect(content.endsWith("\n")).toBe(true); + }); + + test("all URLs share the same origin", () => { + const content = readFileSync(SITEMAP_PATH, "utf8"); + const urls = content.trim().split("\n"); + for (const url of urls) { + expect(new URL(url).origin).toBe(ORIGIN); + } + }); +}); + +describe("route-to-file mapping", () => { + test("every sitemap URL has a corresponding index.html", () => { + const content = readFileSync(SITEMAP_PATH, "utf8"); + const urls = content.trim().split("\n"); + for (const url of urls) { + const filePath = sitemapUrlToPath(url); + expect(existsSync(filePath), `Missing file for ${url}`).toBe(true); + } + }); +}); + +describe("HTML integrity", () => { + const htmlFiles = collectHtmlFiles(DOCS); + + test("at least one HTML file exists", () => { + expect(htmlFiles.length).toBeGreaterThan(0); + }); + + for (const file of htmlFiles) { + const rel = relative(DOCS, file); + + test(`${rel} has doctype`, () => { + const html = readFileSync(file, "utf8"); + expect(html.startsWith("")).toBe(true); + }); + + test(`${rel} has non-empty title`, () => { + const html = readFileSync(file, "utf8"); + const match = html.match(/([^<]*)<\/title>/); + expect(match, "Missing <title>").not.toBeNull(); + expect(match[1].trim().length).toBeGreaterThan(0); + }); + + test(`${rel} has non-empty description meta`, () => { + const html = readFileSync(file, "utf8"); + const match = html.match(/<meta\s+name="description"\s+content="([^"]*)"/); + expect(match, 'Missing <meta name="description">').not.toBeNull(); + expect(match[1].trim().length).toBeGreaterThan(0); + }); + } +}); + +describe("static assets", () => { + const assets = [ + "styles.css", + "robots.txt", + "img/opengraph.png", + ]; + + for (const asset of assets) { + test(`${asset} exists`, () => { + expect(existsSync(join(DOCS, asset)), `Missing ${asset}`).toBe(true); + }); + } +});