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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ The example gallery loads canonical `.stack` sources from the public specificati

The Playground publishes canonical, Open Graph, Twitter Card, and `WebApplication` JSON-LD metadata from `index.html`. [`public/ogp.png`](./public/ogp.png) is the shared 1200×630 social image for the Playground and documentation.

The pinned Docs homepage metadata supplies the canonical tagline, localized benefits,
and supporting description. Vite substitutes the tagline into the Playground's HTML
and JSON-LD placeholders with context-specific escaping; development retrieves the
same verified Docs input before starting. VitePress displays the supporting paragraph
below the tagline while keeping the H1 product-only, and generates its homepage
Markdown alternatives from that same metadata. Do not maintain a second homepage
story in Web or add pre-rendered example SVGs to illustrate it.

[`public/robots.txt`](./public/robots.txt) permits public search crawling and advertises both sitemaps. [`public/llms.txt`](./public/llms.txt) and [`docs/public/llms.txt`](./docs/public/llms.txt) provide curated agent entry points. The VitePress build also emits clean Markdown alternatives for every documentation page and generates `/llms-full.txt` from the complete English documentation, so the agent-facing content stays synchronized with its public source.

## Keeping CLI documentation current
Expand Down
48 changes: 10 additions & 38 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { mkdir, readFile, writeFile } from "node:fs/promises"
import { readFileSync } from "node:fs"
import { createRequire } from "node:module"
import path from "node:path"

Expand All @@ -7,50 +8,21 @@ import type { LanguageRegistration } from "@shikijs/core"
import type { DefaultTheme } from "vitepress"

import { documentationContract } from "../../scripts/docs-validation.config.mjs"
import { productHomeMarkdown, readProductHome } from "../../scripts/product-home.mjs"

const require = createRequire(import.meta.url)
const stackGrammar = require("@stack-sh/language/grammar") as LanguageRegistration
const siteOrigin = "https://stack-diagram.com"
const siteDescription = "Write your Technical Stack, Get beautiful diagram"
const siteDescription = readProductHome(readFileSync(path.resolve("docs/index.md"), "utf8")).hero
.tagline
const socialImageUrl = `${siteOrigin}/ogp.png`
const documentationHomeMarkdown: Record<string, string> = {
"index.md": `# Stack Documentation

Stack is a declarative language for writing static software-architecture and technical-stack diagrams as concise, reviewable source.

- [Getting started](./guide/getting-started.md)
- [Language reference](./language/syntax.md)
- [Diagnostics and limits](./reference/diagnostics-and-limits.md)
`,
"ja/index.md": `# Stackドキュメント

Stackは、静的なsoftware architecture diagramとtechnical stack diagramを簡潔でreview可能なsourceとして記述するための宣言的言語です。

- [はじめる](./guide/getting-started.md)
- [言語リファレンス](./language/syntax.md)
- [Diagnosticとlimit](./reference/diagnostics-and-limits.md)
`,
"zh/index.md": `# Stack 文档

Stack 是一种声明式语言,用简洁、可审查的源代码编写静态软件架构图和技术栈图。

- [快速开始](./guide/getting-started.md)
- [语言参考](./language/syntax.md)
- [诊断与限制](./reference/diagnostics-and-limits.md)
`,
"ko/index.md": `# Stack 문서

Stack은 정적 소프트웨어 아키텍처 및 기술 스택 다이어그램을 간결하고 검토 가능한 소스로 작성하는 선언적 언어입니다.

- [시작하기](./guide/getting-started.md)
- [언어 레퍼런스](./language/syntax.md)
- [진단과 제한](./reference/diagnostics-and-limits.md)
`,
}

function agentMarkdown(relativePath: string, source: string): string {
const content = source.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim()
return `${content || documentationHomeMarkdown[relativePath] || ""}\n`
if (content) return `${content}\n`
if (/^(?:(?:ja|zh|ko)\/)?index\.md$/.test(relativePath)) {
return productHomeMarkdown(readProductHome(source))
}
throw new Error(`Empty documentation page: ${relativePath}`)
}

function documentationUrl(relativePath: string): string {
Expand Down Expand Up @@ -390,7 +362,7 @@ export default defineConfig({
logo: {
light: "/favicon.svg",
dark: "/favicon.svg",
alt: "Stack",
alt: "",
},
i18nRouting: true,
externalLinkIcon: true,
Expand Down
16 changes: 16 additions & 0 deletions docs/.vitepress/theme/components/DocsLayout.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<script setup lang="ts">
import DefaultTheme from "vitepress/theme"
import { useData } from "vitepress"

const { frontmatter } = useData()
</script>

<template>
<DefaultTheme.Layout>
<template #home-hero-info-after>
<p v-if="frontmatter.description" class="stack-home-description">
{{ frontmatter.description }}
</p>
</template>
</DefaultTheme.Layout>
</template>
2 changes: 2 additions & 0 deletions docs/.vitepress/theme/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import "@fontsource-variable/ibm-plex-sans"
import "@fontsource/ibm-plex-mono/400.css"

import ExampleGallery from "./components/ExampleGallery.vue"
import DocsLayout from "./components/DocsLayout.vue"
import IconCatalog from "./components/IconCatalog.vue"
import ProviderCatalog from "./components/ProviderCatalog.vue"
import "./style.css"

export default {
extends: DefaultTheme,
Layout: DocsLayout,
enhanceApp({ app }) {
app.component("ExampleGallery", ExampleGallery)
app.component("IconCatalog", IconCatalog)
Expand Down
16 changes: 16 additions & 0 deletions docs/.vitepress/theme/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,22 @@ body {
text-rendering: optimizeLegibility;
}

.stack-home-description {
max-width: 36rem;
margin: 1rem auto 0;
color: var(--vp-c-text-2);
font-size: 1rem;
line-height: 1.6;
text-wrap: pretty;
}

@media (min-width: 960px) {
.stack-home-description {
margin-left: 0;
margin-right: 0;
}
}

.stack-provider-catalog .sr-only {
position: absolute;
width: 1px;
Expand Down
8 changes: 4 additions & 4 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
<link rel="describedby" href="/llms.txt" />
<meta name="color-scheme" content="light dark" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Write your Technical Stack, Get beautiful diagram" />
<meta name="description" content="__STACK_DESCRIPTION_HTML__" />
<meta name="robots" content="index, follow, max-image-preview:large" />
<meta name="theme-color" content="#111111" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Stack" />
<meta property="og:title" content="Stack" />
<meta property="og:description" content="Write your Technical Stack, Get beautiful diagram" />
<meta property="og:description" content="__STACK_DESCRIPTION_HTML__" />
<meta property="og:url" content="https://stack-diagram.com/" />
<meta property="og:image" content="https://stack-diagram.com/ogp.png" />
<meta property="og:image:type" content="image/png" />
Expand All @@ -23,15 +23,15 @@
<meta property="og:locale" content="en_US" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Stack" />
<meta name="twitter:description" content="Write your Technical Stack, Get beautiful diagram" />
<meta name="twitter:description" content="__STACK_DESCRIPTION_HTML__" />
<meta name="twitter:image" content="https://stack-diagram.com/ogp.png" />
<meta name="twitter:image:alt" content="Stack" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Stack",
"description": "Write your Technical Stack, Get beautiful diagram",
"description": "__STACK_DESCRIPTION_JSON__",
"url": "https://stack-diagram.com/",
"image": "https://stack-diagram.com/ogp.png",
"applicationCategory": "DeveloperApplication",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"build:playground": "tsc -b && vite build",
"cloudflare:check": "wrangler deploy --dry-run",
"deploy": "npm run build && wrangler deploy",
"predev": "npm run docs:source",
"dev": "vite",
"docs:build": "npm run docs:check && vitepress build docs && node scripts/validate-docs-output.mjs",
"docs:check": "node scripts/cli-release.mjs && node scripts/validate-docs.mjs && node scripts/validate-docs-examples.mjs && npm run examples:check",
Expand Down
4 changes: 2 additions & 2 deletions scripts/docs-source.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"repository": "stack-sh/docs",
"revision": "d61b1031cf54c90f10a009c804b77a9e3719c6e6",
"manifestSha256": "0c347301fd55ef39d24f02915d5ec0e78a313b072b37887d48b8ded69c28b7ae"
"revision": "29c9b54e3b711ce8e5954c40be13d0894383c15b",
"manifestSha256": "d28d90cba1e3eff970206fe7e260f31c2e0eac3231367cefbe945f0f76c5c5e2"
}
15 changes: 15 additions & 0 deletions scripts/product-home.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export interface ProductHome {
description: string
hero: {
name: "Stack"
tagline: string
image?: { light: string; dark: string; alt: string }
actions: Array<{ theme?: string; text: string; link: string }>
}
features: Array<{ title: string; details: string }>
}

export function readProductHome(source: string): ProductHome
export function productHomeMarkdown(copy: ProductHome): string
export function renderProductMetadata(html: string, copy: ProductHome): string
export function validateBuiltProductHome(html: string, copy: ProductHome): void
66 changes: 66 additions & 0 deletions scripts/product-home.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import assert from "node:assert/strict"

const escapeHtml = (value) =>
value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")

export function renderProductMetadata(html, copy) {
return html
.replaceAll("__STACK_DESCRIPTION_HTML__", escapeHtml(copy.hero.tagline))
.replaceAll(
'"__STACK_DESCRIPTION_JSON__"',
JSON.stringify(copy.hero.tagline).replaceAll("<", "\\u003c"),
)
}

export function readProductHome(source) {
const block = source.match(/^---\r?\n([\s\S]*?)\r?\n---/)
assert.ok(block, "Generated product home metadata is missing")
const copy = {}
// The provider emits one JSON-escaped YAML scalar/object per line.
for (const key of ["description", "hero", "features"]) {
const line = block[1].split(/\r?\n/).find((line) => line.startsWith(`${key}: `))
assert.ok(line, `Missing product home ${key}`)
copy[key] = JSON.parse(line.slice(key.length + 2))
}
assert.equal(copy.hero.name, "Stack")
assert.equal(copy.hero.text, undefined, "Only the product name belongs in the H1")
assert.equal(copy.features.length, 3)
assert.ok(copy.description.length > 0 && copy.hero.tagline.length > 0)
return copy
}

export function productHomeMarkdown(copy) {
return [
`# ${copy.hero.name}`,
"",
copy.hero.tagline,
"",
copy.description,
"",
...copy.features.flatMap(({ title, details }) => [`## ${title}`, "", details, ""]),
...copy.hero.actions.map(
({ text, link }) => `- [${text}](${link.startsWith("/") ? `/docs${link}` : link})`,
),
"",
].join("\n")
}

export function validateBuiltProductHome(html, copy) {
const headings = [...html.matchAll(/<h1\b[^>]*>([\s\S]*?)<\/h1>/g)]
assert.equal(headings.length, 1, "Product home must have one H1")
assert.equal(headings[0][1].replace(/<[^>]+>/g, "").trim(), "Stack")
assert.match(html, /class="stack-home-description"/)
assert.ok(html.includes(escapeHtml(copy.description)), "Visible product description is missing")
for (const feature of copy.features) {
assert.ok(html.includes(escapeHtml(feature.title)), `Missing benefit: ${feature.title}`)
assert.ok(
html.includes(escapeHtml(feature.details)),
`Missing benefit details: ${feature.title}`,
)
}
}
64 changes: 64 additions & 0 deletions scripts/product-home.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import assert from "node:assert/strict"
import test from "node:test"
import {
readProductHome,
productHomeMarkdown,
validateBuiltProductHome,
renderProductMetadata,
} from "./product-home.mjs"

const copy = {
description: "Describe services & connections.",
hero: {
name: "Stack",
tagline: "Write your stack.",
actions: [
{ text: "Start", link: "/ja/guide/getting-started" },
{ text: "Try", link: "https://stack-diagram.com/" },
],
},
features: ["Beautiful", "Consistent", "Local"].map((title) => ({
title,
details: `${title} details.`,
})),
}
const source = `---\nlayout: home\n${Object.entries(copy)
.map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
.join("\n")}\n---\n`
const html = `<h1><span>Stack</span></h1><p class="stack-home-description">Describe services &amp; connections.</p>${copy.features.map(({ title, details }) => `<h2>${title}</h2><p>${details}</p>`).join("")}`

test("visible and machine-readable home copy share the provider metadata", () => {
assert.deepEqual(readProductHome(source), copy)
assert.ok(productHomeMarkdown(copy).startsWith("# Stack\n\nWrite your stack."))
assert.ok(productHomeMarkdown(copy).includes("](/docs/ja/guide/getting-started)"))
assert.ok(productHomeMarkdown(copy).includes("](https://stack-diagram.com/)"))
validateBuiltProductHome(html, copy)
})

test("missing metadata, an expanded H1, or omitted benefits cannot pass the build gate", () => {
assert.throws(() => readProductHome("# Not generated"))
assert.throws(() =>
readProductHome(source.replace('"name":"Stack"', '"name":"Stack","text":"Architecture"')),
)
for (const candidate of [
html + "<h1>Extra</h1>",
html.replace("<span>Stack</span>", "Stack Architecture"),
html.replace("Consistent details.", ""),
html.replace("stack-home-description", "hidden-description"),
]) {
assert.throws(() => validateBuiltProductHome(candidate, copy))
}
})

test("canonical tagline is escaped independently for HTML and JSON metadata", () => {
const changed = structuredClone(copy)
changed.hero.tagline = 'Text "quotes" & </script>'
const rendered = renderProductMetadata(
'<meta content="__STACK_DESCRIPTION_HTML__"><script>{"description":"__STACK_DESCRIPTION_JSON__"}</script>',
changed,
)
assert.ok(rendered.includes('content="Text &quot;quotes&quot; &amp; &lt;/script&gt;"'))
const json = rendered.match(/<script>(.*)<\/script>/)[1]
assert.equal(JSON.parse(json).description, changed.hero.tagline)
assert.ok(!json.includes("</script>"))
})
10 changes: 10 additions & 0 deletions scripts/validate-docs-output.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from "node:path"
import { validateDocumentationContract } from "./docs-contract.mjs"
import { documentationContract } from "./docs-validation.config.mjs"
import { digest, readDocsManifest } from "./docs-source.mjs"
import { readProductHome, validateBuiltProductHome, productHomeMarkdown } from "./product-home.mjs"

const outputRoot = path.resolve("dist/docs")
const siteOutputRoot = path.resolve("dist")
Expand Down Expand Up @@ -41,6 +42,7 @@ const [playgroundHtml, sourceSocialImage, builtSocialImage, rootAgentIndex, robo

for (const metadata of [
"<title>Stack</title>",
`name="description" content="${readProductHome(await readFile(path.join(docsRoot, "index.md"), "utf8")).hero.tagline}"`,
'rel="canonical" href="https://stack-diagram.com/"',
'property="og:image" content="https://stack-diagram.com/ogp.png"',
'name="twitter:card" content="summary_large_image"',
Expand All @@ -50,6 +52,8 @@ for (const metadata of [
throw new Error(`Built Playground metadata is missing: ${metadata}`)
}
}
if (playgroundHtml.includes("__STACK_DESCRIPTION_"))
throw new Error("Unresolved product metadata token")

if (!sourceSocialImage.equals(builtSocialImage)) {
throw new Error("Built social image does not match public/ogp.png")
Expand All @@ -69,6 +73,12 @@ if (!rootSitemap.includes("<loc>https://stack-diagram.com/</loc>")) {

for (const [page, language] of localePages) {
const html = await readFile(path.join(outputRoot, page), "utf8")
const markdownPage = page.replace(/\.html$/, ".md")
const copy = readProductHome(await readFile(path.join(docsRoot, markdownPage), "utf8"))
validateBuiltProductHome(html, copy)
const agentCopy = await readFile(path.join(outputRoot, markdownPage), "utf8")
if (agentCopy !== productHomeMarkdown(copy))
throw new Error(`${page} agent copy diverges from the visible product story`)

if (!html.includes(`<html lang="${language}"`))
throw new Error(`${page} does not declare ${language}`)
Expand Down
Loading