Vite + React marketing site for ShimoDocs, prerendered to static HTML at build time.
/— Home/ai-workspace— AI Workspace/blog— Blog/help-center— Help Center/docs— Documentation index/on-premises,/airgap— Commercial hubs/pricing— Pricing/contact-sales— Contact Sales
The documentation is published in German and Japanese as well. English keeps
the unprefixed tree (/docs/deployment/...); a translation sits under its own
prefix (/de/docs/..., /ja/docs/...). src/docs-languages.js is the switch
that decides which languages exist, and the build fails if one is listed with
no content to render.
npm install
npm run devnpm run build
npm run previewnpm run build runs three steps:
vite build— the client bundle intodist/.vite build --ssr src/entry-server.jsx— a throwaway SSR bundle.node scripts/prerender.mjs— renders every route insrc/routes.jsxtodist/<route>/index.html, writesdist/404.html,dist/robots.txtanddist/sitemap.xml, then verifies the result and fails the build if anything is missing.
Nginx serves dist/ with try_files $uri $uri/ =404, so an unknown path is a
real 404 rather than the home page returned with a 200.
The site is prerendered rather than client-only, so a crawler that does not run JavaScript still receives headings, copy and internal links.
src/seo.jsis the single source of truth for titles, descriptions, keywords, canonical URLs, the FAQ copy and all structured data. A route can never drift between the router and the crawler.src/routes.jsxis the shared route table used by both the client router and the prerenderer, so adding a page automatically gives it prerendered HTML. It throws at import time if the route list and the SEO table disagree.src/seo-dom.jsre-applies the same metadata during client-side navigation, so the title and canonical stay correct without a full page load.- The build emits
robots.txt,sitemap.xml, a branded 1200x630og-image.png, a favicon set and JSON-LD (Organization,WebSite,WebPage,SoftwareApplication,FAQPage,BreadcrumbList). TheSoftwareApplicationnode, with bothOffers, is emitted on the home page and on/pricingunder one@id, and the pricing page names it as itsmainEntity: the page a buyer reaches from "how much does this cost" states the price to a crawler, not only in prose. npm run buildfails on duplicate titles or descriptions and on titles over 62 characters or descriptions over 160, because search results truncate them.- The commercial facts — the free limit, the per-user price and the annual
discount — are written once in
src/pricing-facts.js, andscripts/check-pricing-facts.mjsreads every rendered page and fails when one of them states a different number. The articles state the free limit in hand-written Markdown, which no import can reach; this is what keeps them honest.npm run check:pricingruns it alone. - Every FAQ answer in the structured data has to be the same string a reader
sees.
scripts/check-faq.mjsstrips the markup first and compares, so an answer that was rewritten in one place and not the other fails the build instead of shipping.npm run check:faqruns it alone. - Sitemap
lastmodis the date of the commit that last changed the file behind each URL, not the build date (scripts/content-dates.mjs). A build stamp would claim all 235 URLs changed on every deploy, which teaches Google the field carries nothing here — worse than omitting it. It is why the workflows check out the full history. sitemap.xmlis an index andsitemap-core.xmlcomes first, so a crawler that reads one child after the index gets the commercial pages rather than the legal notices. The deploy asserts both that order and that the release serves real content dates.
For the conventions an editing agent has to respect, see AGENTS.md.
Two checks read the prerendered HTML back and compare it against the source, so copy that only exists in one place cannot drift from the copy that exists in another:
- FAQ (
scripts/check-faq.mjs). The FAQ exists twice on a page: the text a reader sees and theFAQPagemarkup a crawler reads, and Google requires the markup to describe visible content. Every question and answer is compared against the rendered text with the scripts stripped first — searching the raw HTML would match the markup against itself and always pass. The same question published with two different answers fails the build, and near-identical questions on different pages are reported as warnings. - Pricing facts (
scripts/check-pricing-facts.mjs).src/pricing-facts.jsholds the free-team limit, the per-user price and the annual discount once; the footer, the pricing cards, the FAQ answers, the structured data and the article CTAs read from it. The limit's word forms are keyed by the limit they belong to, so changing the number and leaving the words behind fails at import rather than shipping pages that say "6" beside prose that says "five". A large share of the articles states the free limit in hand-written Markdown, which no import reaches, so this check reads every rendered page and fails the build when one of them states a different number. Changing a constant therefore updates the templated pages and names the articles that still need editing.
Both run as part of npm run build and on their own against any built directory
or a live release (point them at a downloaded copy):
npm run check:faq # node scripts/check-faq.mjs dist
npm run check:pricing # node scripts/check-pricing-facts.mjs distCanonical URLs, the sitemap and the social images all derive from VITE_SITE_URL,
which the release workflow fills from the repository variable SITE_URL. That
variable is set to https://shimodocs.com, the live origin, so every release from
v1.3.3 on emits the domain. It defaults to http://43.172.115.22 — the origin
the site was served from before the domain was connected — when the variable is
unset, so a fresh clone still builds without one. Changing it only requires
tagging another release; no code change is needed.
Cloudflare proxies shimodocs.com to that origin with SSL/TLS mode Full (strict),
Always Use HTTPS and HSTS on. The origin certificate is issued by Let's Encrypt
and renewed by certbot.timer; see
deploy/README.md for the server side.
robots.txt is generated by the build. It names each search and assistant
crawler and allows it, then states the policy with a Content-Signal line:
retrieval is welcome (ai-input=yes), training is not (ai-train=no). The
allowances are per crawler rather than left to User-agent: * because
Cloudflare prepends a managed block to this file at the edge.
Known issue: that managed block is currently on, and it contradicts the file.
It adds Disallow: / for nine agents before our own rules. Five of them are
agents this repository explicitly allows:
| Disallowed by the CDN, allowed here | Consequence |
|---|---|
ClaudeBot, Amazonbot, meta-externalagent |
These fetch pages to answer a question, so they are the ones that cost us citations |
Google-Extended, Applebot-Extended |
These are training opt-out tokens; blocking them agrees with ai-train=no, so nothing is lost |
Search engines and the answering crawlers that matter most are not affected:
Googlebot, Bingbot, DuckDuckBot, Applebot, OAI-SearchBot,
ChatGPT-User, PerplexityBot and Claude-User are absent from the managed
block. The same block also emits its own Content-Signal line, which does not
carry ai-input=yes.
Turning it off needs zone-level access to Cloudflare: AI Crawl Control → Managed robots.txt (older dashboards: Security → Bots). Nobody with repository access can change it from here, and the deploy check cannot see it either, because the check fetches the origin directly and the block is added at the edge. To see the real policy:
curl -s https://shimodocs.com/robots.txt | grep -n "BEGIN Cloudflare Managed" -A 40Beside sitemap.xml, the build produces two things whose only job is to be
found:
/blog/feed.xml, an RSS 2.0 feed of the archive, advertised with<link rel="alternate">from the blog index and from every article, and listed inllms.txt. Aggregators and assistants that poll for new work have one URL to watch, and the build fails if the feed stops listing an article or stops being advertised.- An image sitemap: the core and pages sitemaps carry
<image:image>entries for the screenshots each page actually displays. The check refuses an image a page does not have and an image this build did not produce, because an image sitemap that overstates what a page contains is a spam signal rather than a shortcut.
npm run indexnow -- --dry-run # print what would be sent
npm run indexnow # submit every URL in the built sitemaps
npm run indexnow -- --live # submit what the live site servesThe first submission is normally rejected with SiteVerificationNotCompleted
while IndexNow fetches the key file, so --live exists to retry it from
anywhere without cutting a release: it reads the published sitemap index and
submits those URLs.
Google does not support IndexNow. Bing, Yandex, Seznam and Naver do, and Bing's
index is what grounds several assistants — so this is the shortest path from a
published guide to an answer that can cite it. Ownership is proved by
public/9f2a7c41d6b84e0fa3c5e18b7d60a294.txt, which is a key rather than a
secret by design: IndexNow fetches it over HTTP. The build fails if that file is
missing, the deploy verification asserts the live site serves it, and the deploy
job submits the URLs after the release is verified — with continue-on-error, so
a rejected submission is a visible red step rather than a failed release.
node scripts/cloudflare-daily.mjs --dry-run # print, write nothing
node scripts/cloudflare-daily.mjs # yesterday (UTC), into Feishu
node scripts/cloudflare-daily.mjs --date 2026-09-14 # a specific dayIt writes one row per day into the 流量观测 table of the same Feishu base the
download counts live in, through lark-cli and the operator's own
authorization — so the only secret is the Cloudflare token, which is read from
seo/data/cloudflare-token.txt (outside version control) and never stored
anywhere else.
It has to run daily, and that is not a preference. On the Free plan the per-request dataset is queryable for a one-day window and the daily aggregate returns two days: there is no history to back-fill, so a day that is not read is gone. Running twice is safe — the row is updated, not duplicated.
The columns are chosen for what Search Console cannot say: which crawlers
actually fetched the site, what they asked for, and which requests failed.
Googlebot at zero for a day is a fact worth waking up to; a 404 that starts
with /docs is a real broken link, while a 404 for /.env is a scanner. The
"爬虫请求" column is a heuristic over the user-agent string, not a verified-bot
count: the API field for that is not available on this plan.
node scripts/product-readme-links.mjs /path/to/shimodocs-checkout --check
node scripts/product-readme-links.mjs /path/to/shimodocs-checkoutThe README in shimodocs/shimodocs links to its own documentation with relative
paths, which GitHub renders as file views. Those links are the most valuable
inbound links the guides can have — the repository is where a reader arrives
first and it is crawled constantly — and every one of them currently points at a
blob page instead of the page. The script rewrites the English, German and
Japanese READMEs, which are the languages this site publishes, and leaves the
other translations alone rather than pointing them at pages that do not exist.
Every URL it writes is checked against the last build in dist/, so it fails on
a guide this site does not publish instead of shipping a 404.
The deployment-docs block sits between <!-- deployment-docs:start --> markers.
If something outside the product repository regenerates that block, the generator
needs the same change or the next run will put the file links back.
npm run assetsscripts/make-assets.py measures its own layout and refuses to write an Open
Graph card where text would overflow or collide with the screenshot.
The icon it writes is not drawn in code: brand/shimodocs-mark.svg and
brand/shimodocs-icon.png hold the designed artwork the previous site shipped
(the leaf mark in a circle for the favicon, in a tile for the touch icon), and
the script resamples them. Replace those two files to change the mark — the
header wordmark lives at public/assets/logo-shimodocs.svg.
pip install fonttools brotli # once
npm run fontsscripts/make-font-subsets.py turns the design TTFs in brand/fonts into the
Latin-subset WOFF2 the browser loads: 392 KB of TTF becomes 125 KB, and the two
weights the home page preloads drop from 115 KB to 36 KB. Coverage is Latin,
Latin Extended and Vietnamese — the Japanese and Korean guides deliberately fall
back to the reader's system font rather than pulling a CJK web font in behind
them. The script compares each subset against the source font and fails if a
glyph the source carried was lost, so a range that is too narrow is caught here
instead of on somebody's page.
The full TTFs live in brand/ rather than public/assets because
scripts/make-assets.py needs them to draw the Open Graph card while the
browser needs only the subsets; keeping the sources out of public/ keeps 392 KB
out of every deploy.
The home hero carries the two ways to get the product, both defined in
src/downloads.js:
- Download for Linux · amd64 links the self-hosted installer, with the
arm64build and an "All releases" fallback beside it. The footer links the releases page as well. - Get a free perpetual license opens a prefilled licence request addressed to
support.global@shimo.im, which the repository README documents as the official channel for the global build ("Request free by emailing support.global@shimo.im", free forever for five users, no credit card).
shimodocs.com has no working download surface — its /download page renders
"Coming Soon", no page on that site links to an installer and no download
hostname exists — so the artifacts come from the
shimodocs/shimodocs releases.
The Chinese enterprise download centre at shimo.net serves the -cn build and
the k3s release tarball instead.
Because the asset file name embeds the installer version, a new installer build
means bumping INSTALLER_VERSION in src/downloads.js. Two guards catch a
broken entry point: the prerenderer fails the build if the home page loses
either the installer link or the licence link, and the deploy workflow resolves
both installer URLs with a ranged request and fails the release if either stops
working.
The /contact-sales form writes each inquiry into a Teable table as one row. The
page posts straight to Teable's public share-form endpoint (src/contact.js),
which needs no credential and allows cross-origin requests, so the static build
needs no backend and the server needs no extra Nginx rule. Columns are addressed
by field id, so renaming one in Teable cannot break the form; the endpoint and
the field ids, plus how they were derived and how to rotate them, are documented
in deploy/README.md. Set
VITE_CONTACT_ENDPOINT to send the form somewhere else instead.
Articles are Markdown files in content/blog/. Adding a file is all that is
required: the build picks it up, prerenders it, links it from the archive and
adds it to the sitemap.
npm run content # regenerate src/generated/blog-posts.js only
npm run build # full build, includes the content stepFrontmatter is validated at build time. A post that would ship a bad title, a duplicate description or a broken internal link fails the build rather than going live.
---
title: "What Is Private Cloud Document Collaboration?" # the H1, written for a reader
seoTitle: "What Is Private Cloud..." # the search result, max 62 chars
description: "..." # 110-160 chars, unique site-wide
category: self-hosting # one of the ids in scripts/blog-content.mjs
date: 2026-01-12
updated: 2026-02-01 # optional
tags: [private cloud, self-hosted]
keywords: "..." # optional, falls back to tags
featured: true # optional, pins to the top of the archive
---seoTitle is optional when title plus " | ShimoDocs" fits in 62 characters.
Every article picks a layout in its frontmatter. Each one renders a different document structure rather than the same markup with different classes:
| Layout | Structure |
|---|---|
standard |
Breadcrumb, table of contents pinned left, two-column body |
feature |
Full-width hero band with a fact list, then a single narrow column, no sidebar |
briefing |
Intro separated out, every H2 becomes a numbered section, index rail on the right |
magazine |
Wide masthead with a metadata grid and a drop cap, full-width column, no sidebar |
The build fails if a layout is unused or if any layout covers more than 40% of articles, and it compares the class signature each layout emits so two layouts cannot quietly collapse into the same structure.
Articles carry diagrams and asides as fenced blocks, so an author writes data rather than markup. Figures are inlined as SVG at build time, which means no image requests and labels that crawlers can read.
```figure
type: flow
title: The four egress points
items: Retrieval | Prompt payload | Provider retention | The output
detail: Does the search respect permissions | Text sent to a model endpoint | Requests kept for evaluation | Output cached or written back
caption: Figure 1. If you cannot draw all four arrows, you do not know where your content goes.
```Figure types: flow, layers, compare, bars, matrix, timeline,
screenshot. Asides: callout (with tone: note | warning | tip),
keypoints, pullquote. Every article must have at least one figure.
Article pages are rendered to HTML at build time with the body already inlined, and the client bundle is then removed from them. A 1,500-word article therefore costs the browser no JavaScript at all, and because nothing hydrates an article there is no possibility of a hydration mismatch. The blog index stays a React page and hydrates normally.
- A unique search title, a unique description and a self-referencing canonical.
BlogPostingandBreadcrumbListstructured data.- A table of contents generated from the H2 and H3 headings.
- At least five internal blog links, plus related articles and older/newer paging.
- Presence in
sitemap.xmland a link from the/blogarchive. - A word count floor, so a stub cannot ship as an article.
The deployment, operations and troubleshooting guides are authored in
shimodocs/shimodocs and mirrored into
content/docs — 56 guides per language. The site is a consumer: nothing here
edits a guide.
npm run sync:docs # copy the published trees out of the product repo
node scripts/sync-docs.mjs --check # report drift and exit 1src/docs-languages.js is the single switch: a language appears on the site only
if it is listed there, and scripts/sync-docs.mjs copies exactly that list.
Today that list is English, German and Japanese — a deliberate pilot rather
than a limit of the pipeline. The upstream translations are complete mirrors but
they are machine output, and at least one of them had damaged an instruction: a
dropped + inside an inline code span turned a topology line into
3 master N worker in the German and Japanese copies. Two markets with strong
self-hosting demand and thin English-language competition are enough to measure
whether translated documentation earns traffic before the other five are
switched on. Turning one on is a change to that array followed by
npm run sync:docs; the trees are not kept here while they are unpublished, so
they cannot silently rot.
zh-CN is deliberately absent — the Chinese documentation belongs to
shimo.net, and publishing it here would have the two domains compete for the
same queries.
Damage found in a translation that has to be published anyway is corrected in
UPSTREAM_REPAIRS (in scripts/docs-content.mjs) rather than by editing the
mirror, because the mirror is overwritten on the next sync. An entry that no
longer matches anything fails the build, so the table cannot outlive the bug it
works around.
- A unique search title and description within its language. Comparing across languages would only flag translation, which is not a build failure; comparing within one is what stops two guides competing for the same query.
- A self-referencing canonical and
TechArticle+BreadcrumbListstructured data, withinLanguageset from the guide's own language. hreflangfor every language that publishes that guide, including a self-reference and anx-defaulton the English version. The set is derived from what was actually synced, so a language never points at a page that does not exist.<html lang="...">, and a visible language row linking the same guide in the other languages.- No client bundle. Guide pages are static HTML; only the English index hydrates, because it carries the search box.
- A link from that language's documentation index, so the tree is crawlable without going through a sitemap.
Internal links inside a guide are rewritten from repository paths to site
paths, and a link to a guide this site does not publish is dropped rather than
shipped as a dead anchor. GFM callouts (> [!TIP]) are rendered with their
label as real text, so they survive into a search snippet.
The direction that matters for ranking is the other one: every article points
at the runbooks that prove it. That mapping is editorial, in
scripts/article-docs.mjs — writing it beats matching on tags, which is how
you end up recommending Kubernetes under an article about retention policy.
The build fails if an id in it stops existing or if the link graph thins out.
sitemap.xml is a <sitemapindex>. The commercial pages live in
sitemap-core.xml, which is listed first so the first child a crawler fetches
after the index is the set of pages that carry the search demand; the rest of the
static pages are in sitemap-pages.xml, and there is one
sitemap-docs-<language>.xml per language, so Search Console reports index
coverage per language instead of as one lump where a single broken translation is
invisible. The index cannot list pages itself: a sitemap index may only contain
<sitemap> entries, and adding <url> to it invalidates the whole file.
Every entry carries a lastmod from the date its content changed — the commit
that last touched the guide Markdown for the documentation, ROUTE_UPDATED in
src/seo.js for the static pages, front matter for the articles — never the
build date. A lastmod that is always today is the same signal as no lastmod at
all. The build fails if a date is missing, malformed, in the future, or if the
whole site reports fewer than three distinct dates, which is what a build-clock
lastmod produces. Both workflows check out with fetch-depth: 0 so the history
those dates come from is present.
The canonical repository is shimodocs/website.
Pushes and pull requests to main run build checks only. Pushing a new v* tag
triggers the production deployment through GitHub Actions:
git push origin main
git tag -a v1.0.2 -m "Release v1.0.2"
git push origin v1.0.2Use a new, unused version for each release. See deployment instructions for server setup, Secrets, verification and rollback.