From 5b0f5fd33a5b34998cd64725b4dd5e6b147c5e4d Mon Sep 17 00:00:00 2001 From: Petr Makhnev <51853996+i582@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:09:27 +0400 Subject: [PATCH 1/6] changes --- README.md | 4 ++++ next.config.static.ts | 11 +++++++++-- package.json | 1 + scripts/common.mjs | 3 +++ scripts/post-build.mjs | 34 +++++++++++++++++++++++++++++++++- scripts/pre-build.mjs | 14 +++++++++++--- 6 files changed, 61 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4ddb5cf6b..145f10bd1 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,10 @@ See the [`snippets/` directory](./snippets) and the corresponding docs in [`cont Changes are deployed to [production](https://docs.ton.org) automatically after pushing to the default branch (`main`). +### Cloudflare Pages + +For a static Cloudflare Pages deployment, use `npm run build:cloudflare` as the build command and `out` as the output directory. The build writes `out/_redirects` from the redirect rules in `vercel.json`; set `NEXT_PUBLIC_SITE_URL` in the Pages environment when the public URL differs from `https://docs.ton.org`. + ## Need help? ### Troubleshooting diff --git a/next.config.static.ts b/next.config.static.ts index e1088544c..e2672ad3f 100644 --- a/next.config.static.ts +++ b/next.config.static.ts @@ -8,8 +8,9 @@ const withMDX = createMDX(); const isGitHubPagesBuild = process.env.GITHUB_ACTIONS === 'true' || process.env.GITHUB_PAGES === 'true'; const isVercelBuild = process.env.VERCEL === '1'; +const isCloudflarePagesBuild = process.env.CF_PAGES === '1'; const isVercelProd = isVercelBuild && resolveBaseUrl().startsWith('https://docs.ton.org'); -const isLocalBuild = !isGitHubPagesBuild && !isVercelBuild; +const isLocalBuild = !isGitHubPagesBuild && !isVercelBuild && !isCloudflarePagesBuild; let gitRepoMatch: RegExpMatchArray | null = null; try { const gitUrl = execSync('git config --get remote.origin.url', { @@ -30,6 +31,10 @@ function resolveBaseUrl() { return ghPagesUrl; } + if (isCloudflarePagesBuild) { + return process.env.CF_PAGES_URL ?? 'https://docs.ton.org'; + } + return 'http://localhost:3000'; } @@ -54,7 +59,9 @@ const config: NextConfig = { : 'vercel-dev' : isGitHubPagesBuild ? 'github' - : 'unknown', + : isCloudflarePagesBuild + ? 'cloudflare' + : 'unknown', NEXT_PUBLIC_BASE_URL: resolveBaseUrl(), NEXT_PUBLIC_BASE_PATH: resolveBasePath() ?? '', NEXT_GIT_USER: gitRepoMatch?.at(1) ?? 'ton-blockchain', diff --git a/package.json b/package.json index f0e83cc07..2d174abe5 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "start": "next dev", "start:vercel": "NEXT_CONFIG=vercel next start", "build": "node scripts/pre-build.mjs && cross-env NODE_OPTIONS=--max_old_space_size=4096 next build && node scripts/post-build.mjs", + "build:cloudflare": "cross-env CF_PAGES=1 NEXT_CONFIG=static npm run build", "build:vercel": "node scripts/pre-build.mjs && NEXT_CONFIG=vercel next build", "build:serve": "serve out", "check:types": "fumadocs-mdx && next typegen && tsc --noEmit", diff --git a/scripts/common.mjs b/scripts/common.mjs index a8ef2fca6..4df197f68 100644 --- a/scripts/common.mjs +++ b/scripts/common.mjs @@ -36,6 +36,9 @@ export const prefix = '/docs'; export const isGitHubPagesBuild = process.env.GITHUB_ACTIONS === 'true' || process.env.GITHUB_PAGES === 'true'; +// WARN: Must match next.config.static.ts isCloudflarePagesBuild +export const isCloudflarePagesBuild = process.env.CF_PAGES === '1'; + /** @param src {string} */ export function ansiRed(src) { return `\x1b[31m${src}\x1b[0m`; diff --git a/scripts/post-build.mjs b/scripts/post-build.mjs index 2987ea1b6..249c40619 100644 --- a/scripts/post-build.mjs +++ b/scripts/post-build.mjs @@ -14,7 +14,14 @@ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs'; import { join, extname, dirname } from 'node:path'; // Common -import { prefix, outDir, isGitHubPagesBuild, getConfig, getRedirects } from './common.mjs'; +import { + prefix, + outDir, + isGitHubPagesBuild, + isCloudflarePagesBuild, + getConfig, + getRedirects, +} from './common.mjs'; /** * @param {string} path - file path @@ -135,6 +142,20 @@ const generateStaticRedirects = (dir) => { return stats; }; +/** @param {string} dir */ +const generateCloudflareRedirects = (dir) => { + const redirects = getRedirects(getConfig()).map((redirect) => { + const { source, destination, permanent } = redirect; + if (/\s/.test(source) || /\s/.test(destination)) { + throw new Error(`Cloudflare redirect contains whitespace: ${source} → ${destination}`); + } + return `${source} ${destination} ${permanent === false ? 307 : 301}`; + }); + + writeFileWithDirs(join(dir, '_redirects'), `${redirects.join('\n')}\n`); + return { redirects: redirects.length }; +}; + /** @param {string} dir */ const generateSiblingMarkdownFiles = (dir) => { const llms = join(dir, 'llms'); @@ -169,6 +190,17 @@ const main = (dir) => { const { files: mdFiles } = generateSiblingMarkdownFiles(dir); console.log(pfx, `${mdFiles} markdown files`); + if (isCloudflarePagesBuild) { + if (!existsSync(dir) || !statSync(dir).isDirectory()) { + console.log(pfx, `skipped — ${dir}/ directory not found`); + process.exit(1); + } + + console.log(pfx, 'generating Cloudflare Pages _redirects...'); + const { redirects } = generateCloudflareRedirects(dir); + console.log(pfx, `${redirects} redirects`); + } + if (!isGitHubPagesBuild) { console.log(pfx, 'skipped GitHub Pages-only steps'); process.exit(0); diff --git a/scripts/pre-build.mjs b/scripts/pre-build.mjs index d9f956ec8..1552381c6 100644 --- a/scripts/pre-build.mjs +++ b/scripts/pre-build.mjs @@ -11,18 +11,26 @@ ╚─────────────────────────────────────────────────────────────────────────────*/ // Common utils -import { $, ansiGreen } from './common.mjs'; +import { $, ansiGreen, isCloudflarePagesBuild } from './common.mjs'; const main = () => { const pfx = 'pre-build:'; const isGitHubPagesBuild = process.env.GITHUB_ACTIONS === 'true' || process.env.GITHUB_PAGES === 'true'; const isVercelBuild = process.env.VERCEL === '1'; - const isLocalBuild = !isGitHubPagesBuild && !isVercelBuild; + const isLocalBuild = !isGitHubPagesBuild && !isVercelBuild && !isCloudflarePagesBuild; console.log( pfx, 'build type is —', - isLocalBuild ? 'local' : isVercelBuild ? 'vercel' : isGitHubPagesBuild ? 'github' : 'unknown', + isLocalBuild + ? 'local' + : isVercelBuild + ? 'vercel' + : isGitHubPagesBuild + ? 'github' + : isCloudflarePagesBuild + ? 'cloudflare' + : 'unknown', ); const scripts = [ 'check:types', From 2a8a49852bffe9aeaeb26249e2dfa751564e4338 Mon Sep 17 00:00:00 2001 From: Petr Makhnev <51853996+i582@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:15:53 +0400 Subject: [PATCH 2/6] disable search --- src/app/api/search/route.ts | 25 ------------------------- src/app/layout.tsx | 3 +-- src/components/provider.tsx | 19 +++---------------- 3 files changed, 4 insertions(+), 43 deletions(-) delete mode 100644 src/app/api/search/route.ts diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts deleted file mode 100644 index 4b1e64641..000000000 --- a/src/app/api/search/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { source, getSearchablePages } from '@/lib/source'; -import { flexsearchFromSource } from 'fumadocs-core/search/flexsearch'; - -const searchSource: typeof source = { - ...source, - getPages: getSearchablePages, -}; - -// https://www.fumadocs.dev/docs/headless/search/flexsearch#static-export -const searchAPI = flexsearchFromSource(searchSource, { - // async buildIndex(page) { - // return { - // title: page.data.title, - // description: page.data.description, - // url: page.url, - // id: page.url, - // structuredData: await page.data.structuredData(), - // breadcrumbs: page.slugs.slice(0, -1), - // // tag: undefined, - // }; - // } -}); - -export const revalidate = false; -export const GET = process.env.NEXT_CONFIG === 'vercel' ? searchAPI.GET : searchAPI.staticGET; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b552303b6..f2d57adae 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,5 @@ import { Inter } from 'next/font/google'; import { Provider } from '@/components/provider'; -import { getQuickJumpPages } from '@/lib/source'; import './global.css'; import 'katex/dist/katex.css'; @@ -36,7 +35,7 @@ export default function Layout({ children }: LayoutProps<'/'>) { return ( - {children} + {children} ); diff --git a/src/components/provider.tsx b/src/components/provider.tsx index d2c133288..a70825c17 100644 --- a/src/components/provider.tsx +++ b/src/components/provider.tsx @@ -1,20 +1,7 @@ 'use client'; -import SearchDialog, { type QuickJumpPage } from '@/components/search'; import { RootProvider } from 'fumadocs-ui/provider/next'; -import type { SharedProps } from 'fumadocs-ui/components/dialog/search'; -import { type ReactNode, useCallback } from 'react'; +import type { ReactNode } from 'react'; -export function Provider({ - children, - quickJumpPages, -}: { - children: ReactNode; - quickJumpPages: QuickJumpPage[]; -}) { - const ConfiguredSearchDialog = useCallback( - (props: SharedProps) => , - [quickJumpPages], - ); - - return {children}; +export function Provider({ children }: { children: ReactNode }) { + return {children}; } From 427a49d95277953f933a8ecc87e9b5bf51b6bd83 Mon Sep 17 00:00:00 2001 From: Petr Makhnev <51853996+i582@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:35:18 +0400 Subject: [PATCH 3/6] search with workers --- README.md | 2 + functions/api/search.js | 141 ++++++++++++++++++++++++++++++++++ scripts/post-build.mjs | 105 ++++++++++++++++++++++++- src/app/layout.tsx | 3 +- src/app/search-index/route.ts | 18 +++++ src/components/provider.tsx | 24 +++++- src/components/search.tsx | 13 +--- 7 files changed, 294 insertions(+), 12 deletions(-) create mode 100644 functions/api/search.js create mode 100644 src/app/search-index/route.ts diff --git a/README.md b/README.md index 145f10bd1..c3b0c6884 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ Changes are deployed to [production](https://docs.ton.org) automatically after p For a static Cloudflare Pages deployment, use `npm run build:cloudflare` as the build command and `out` as the output directory. The build writes `out/_redirects` from the redirect rules in `vercel.json`; set `NEXT_PUBLIC_SITE_URL` in the Pages environment when the public URL differs from `https://docs.ton.org`. +The Cloudflare build also enables search through the Pages Function in `functions/api/search.js`. The build creates a compact search catalog under `out/search-index`; no D1 or R2 binding is required. `out/_routes.json` limits Function invocations to `/api/search`, so the documentation pages remain static asset requests. + ## Need help? ### Troubleshooting diff --git a/functions/api/search.js b/functions/api/search.js new file mode 100644 index 000000000..b6b134d75 --- /dev/null +++ b/functions/api/search.js @@ -0,0 +1,141 @@ +let documentsPromise; +const shardPromises = new Map(); + +const normalize = (value) => value.normalize('NFKC').toLocaleLowerCase(); + +const tokenize = (value) => normalize(value).match(/[\p{L}\p{N}]+/gu) ?? []; + +const getShardName = (term) => { + const first = term[0] ?? ''; + if (/^[a-z]$/.test(first)) return first; + if (/^[0-9]$/.test(first)) return 'digits'; + return 'other'; +}; + +const loadJsonAsset = async (context, path) => { + const assetUrl = new URL(path, context.request.url); + const response = await context.env.ASSETS.fetch(assetUrl); + if (!response.ok) { + throw new Error(`Search asset request failed with ${response.status}: ${path}`); + } + + return response.json(); +}; + +const loadDocuments = (context) => { + if (!documentsPromise) { + documentsPromise = loadJsonAsset(context, '/search-index/documents').catch((error) => { + documentsPromise = undefined; + throw error; + }); + } + + return documentsPromise; +}; + +const loadShard = (context, shardName) => { + if (!shardPromises.has(shardName)) { + const promise = loadJsonAsset(context, `/search-index/${shardName}`).catch((error) => { + shardPromises.delete(shardName); + throw error; + }); + shardPromises.set(shardName, promise); + } + + return shardPromises.get(shardName); +}; + +const getSnippet = (document) => { + const snippet = document.description ?? document.excerpt; + if (!snippet) return undefined; + return snippet.replace(/\s+/g, ' ').trim().slice(0, 240); +}; + +const searchDocuments = async (context, query) => { + const terms = [...new Set(tokenize(query))].filter((term) => term.length > 1).slice(0, 8); + if (terms.length === 0) return []; + + const shardNames = [...new Set(terms.map(getShardName))]; + const [catalog, ...shards] = await Promise.all([ + loadDocuments(context), + ...shardNames.map((shardName) => loadShard(context, shardName)), + ]); + const shardByName = new Map(shardNames.map((shardName, index) => [shardName, shards[index]])); + const candidates = new Map(); + + for (const term of terms) { + const shard = shardByName.get(getShardName(term)); + if (!shard?.terms) continue; + + for (const [indexedTerm, documentIds] of Object.entries(shard.terms)) { + if (indexedTerm !== term && !indexedTerm.startsWith(term)) continue; + + for (const documentId of documentIds) { + const document = catalog.documents[documentId]; + if (!document) continue; + + let candidate = candidates.get(documentId); + if (!candidate) { + candidate = { document, matchedTerms: new Set(), score: 0 }; + candidates.set(documentId, candidate); + } + + if (candidate.matchedTerms.has(term)) continue; + candidate.matchedTerms.add(term); + + const title = normalize(document.title); + const description = normalize(document.description ?? ''); + if (title.includes(term)) candidate.score += 120; + else if (description.includes(term)) candidate.score += 45; + else candidate.score += indexedTerm === term ? 10 : 5; + } + } + } + + const normalizedQuery = normalize(query).trim(); + const scored = [...candidates.values()]; + for (const candidate of scored) { + if (candidate.matchedTerms.size === terms.length) candidate.score += 50; + if (normalize(candidate.document.title).includes(normalizedQuery)) candidate.score += 100; + } + + scored.sort((a, b) => b.score - a.score || a.document.title.localeCompare(b.document.title)); + + return scored.slice(0, 30).flatMap(({ document }) => { + const snippet = getSnippet(document); + return [ + { + id: `${document.id}:page`, + type: 'page', + content: document.title, + url: document.url, + }, + ...(snippet + ? [ + { + id: `${document.id}:text`, + type: 'text', + content: snippet, + url: document.url, + }, + ] + : []), + ]; + }); +}; + +export async function onRequestGet(context) { + const query = new URL(context.request.url).searchParams.get('query') ?? ''; + if (!query.trim()) return Response.json([]); + + try { + return Response.json(await searchDocuments(context, query), { + headers: { + 'cache-control': 'public, max-age=60, s-maxage=3600', + }, + }); + } catch (error) { + console.error('Search request failed', error); + return Response.json({ error: 'Search is temporarily unavailable' }, { status: 500 }); + } +} diff --git a/scripts/post-build.mjs b/scripts/post-build.mjs index 249c40619..4ccafc299 100644 --- a/scripts/post-build.mjs +++ b/scripts/post-build.mjs @@ -11,7 +11,15 @@ ╚─────────────────────────────────────────────────────────────────────────────*/ // Node.js -import { existsSync, readdirSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + statSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; import { join, extname, dirname } from 'node:path'; // Common import { @@ -156,6 +164,94 @@ const generateCloudflareRedirects = (dir) => { return { redirects: redirects.length }; }; +/** @param {string} dir */ +const generateCloudflareRoutes = (dir) => { + const routes = { + version: 1, + include: ['/api/search', '/api/search/*'], + exclude: [], + }; + + writeFileWithDirs(join(dir, '_routes.json'), `${JSON.stringify(routes, null, 2)}\n`); +}; + +const cloudflareSearchShards = [...'abcdefghijklmnopqrstuvwxyz', 'digits', 'other']; + +/** @param {string} term */ +const getCloudflareSearchShard = (term) => { + const first = term[0] ?? ''; + if (/^[a-z]$/.test(first)) return first; + if (/^[0-9]$/.test(first)) return 'digits'; + return 'other'; +}; + +/** @param {string} value */ +const getSearchTokens = (value) => + [ + ...new Set( + value + .normalize('NFKC') + .toLocaleLowerCase() + .match(/[\p{L}\p{N}]+/gu) ?? [], + ), + ].filter((token) => token.length > 1); + +/** @param {string} dir */ +const generateCloudflareSearchIndex = (dir) => { + const sourcePath = join(dir, 'search-index'); + if (!existsSync(sourcePath)) { + throw new Error(`Cloudflare search index source is missing: ${sourcePath}`); + } + + const source = JSON.parse(readFileSync(sourcePath, 'utf8')); + /** @type {Record} */ + const documents = Object.create(null); + /** @type {Map }>} */ + const shards = new Map( + cloudflareSearchShards.map((shard) => [shard, { version: 1, terms: Object.create(null) }]), + ); + + for (const document of source.documents ?? []) { + const text = String(document.text ?? '') + .replace(/\s+/g, ' ') + .trim(); + documents[document.id] = { + id: document.id, + url: document.url, + title: document.title, + ...(document.description ? { description: document.description } : {}), + ...(text ? { excerpt: text.slice(0, 240) } : {}), + }; + + const searchableText = [document.title, document.description, document.text] + .filter(Boolean) + .join(' '); + for (const token of getSearchTokens(searchableText)) { + const shard = shards.get(getCloudflareSearchShard(token)); + if (!shard) continue; + const posting = shard.terms[token] ?? []; + posting.push(document.id); + shard.terms[token] = posting; + } + } + + // Next's static route creates this temporary, full-text JSON file. Replace it + // with the small catalog plus token shards consumed by the Pages Function. + unlinkSync(sourcePath); + writeFileWithDirs( + join(dir, 'search-index', 'documents'), + `${JSON.stringify({ version: 1, documents })}\n`, + ); + for (const [shardName, shard] of shards) { + writeFileWithDirs(join(dir, 'search-index', shardName), `${JSON.stringify(shard)}\n`); + } + + return { + documents: Object.keys(documents).length, + shards: shards.size, + }; +}; + /** @param {string} dir */ const generateSiblingMarkdownFiles = (dir) => { const llms = join(dir, 'llms'); @@ -199,6 +295,13 @@ const main = (dir) => { console.log(pfx, 'generating Cloudflare Pages _redirects...'); const { redirects } = generateCloudflareRedirects(dir); console.log(pfx, `${redirects} redirects`); + + console.log(pfx, 'limiting Cloudflare Pages Functions to /api/search...'); + generateCloudflareRoutes(dir); + + console.log(pfx, 'compacting Cloudflare search index into token shards...'); + const searchIndex = generateCloudflareSearchIndex(dir); + console.log(pfx, `${searchIndex.documents} documents, ${searchIndex.shards} shards`); } if (!isGitHubPagesBuild) { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f2d57adae..b552303b6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import { Inter } from 'next/font/google'; import { Provider } from '@/components/provider'; +import { getQuickJumpPages } from '@/lib/source'; import './global.css'; import 'katex/dist/katex.css'; @@ -35,7 +36,7 @@ export default function Layout({ children }: LayoutProps<'/'>) { return ( - {children} + {children} ); diff --git a/src/app/search-index/route.ts b/src/app/search-index/route.ts new file mode 100644 index 000000000..3bbfa5b5f --- /dev/null +++ b/src/app/search-index/route.ts @@ -0,0 +1,18 @@ +import { getSearchablePages } from '@/lib/source'; + +export const dynamic = 'force-static'; +export const revalidate = false; + +export async function GET() { + const documents = await Promise.all( + getSearchablePages().map(async (page) => ({ + id: page.url, + url: page.url, + title: page.data.title.replace(/`/g, ''), + description: page.data.description, + text: await page.data.getText('processed'), + })), + ); + + return Response.json({ version: 1, documents }); +} diff --git a/src/components/provider.tsx b/src/components/provider.tsx index a70825c17..62550cfdb 100644 --- a/src/components/provider.tsx +++ b/src/components/provider.tsx @@ -1,7 +1,29 @@ 'use client'; import { RootProvider } from 'fumadocs-ui/provider/next'; +import type { SharedProps } from 'fumadocs-ui/components/dialog/search'; import type { ReactNode } from 'react'; +import DefaultSearchDialog, { type QuickJumpPage } from '@/components/search'; + +export function Provider({ + children, + quickJumpPages, +}: { + children: ReactNode; + quickJumpPages: QuickJumpPage[]; +}) { + if (process.env.NEXT_BUILD_TYPE === 'cloudflare') { + return ( + ( + + ), + }} + > + {children} + + ); + } -export function Provider({ children }: { children: ReactNode }) { return {children}; } diff --git a/src/components/search.tsx b/src/components/search.tsx index ec5d916ba..ec1d74546 100644 --- a/src/components/search.tsx +++ b/src/components/search.tsx @@ -20,7 +20,6 @@ import { } from 'fumadocs-ui/components/dialog/search'; import { useDocsSearch } from 'fumadocs-core/search/client'; import { fetchClient } from 'fumadocs-core/search/client/fetch'; -import { flexsearchStaticClient } from 'fumadocs-core/search/client/flexsearch-static'; import { useI18n } from 'fumadocs-ui/contexts/i18n'; export interface QuickJumpPage { @@ -35,14 +34,10 @@ export default function DefaultSearchDialog({ }: SharedProps & { quickJumpPages: QuickJumpPage[] }) { // const [tag] = useState(); const { locale } = useI18n(); // (optional) for i18n - const client = - process.env.NEXT_CONFIG === 'vercel' - ? fetchClient({ locale }) - : flexsearchStaticClient({ - locale, - from: `${process.env.NEXT_PUBLIC_BASE_PATH ?? ''}/api/search`, - // tag, - }); + const client = fetchClient({ + api: `${process.env.NEXT_PUBLIC_BASE_PATH ?? ''}/api/search`, + locale, + }); const { search, setSearch, query } = useDocsSearch({ client }); const router = useRouter(); const quickJumpAction = useMemo(() => { From 1be02a8a36ea3ca6ea5f8a2a777f19da35251e25 Mon Sep 17 00:00:00 2001 From: Petr Makhnev <51853996+i582@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:41:26 +0400 Subject: [PATCH 4/6] add .md links rewrite --- scripts/post-build.mjs | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/scripts/post-build.mjs b/scripts/post-build.mjs index 4ccafc299..2ab07ff1b 100644 --- a/scripts/post-build.mjs +++ b/scripts/post-build.mjs @@ -151,7 +151,7 @@ const generateStaticRedirects = (dir) => { }; /** @param {string} dir */ -const generateCloudflareRedirects = (dir) => { +const generateCloudflareRedirects = (dir, markdownRoutes = []) => { const redirects = getRedirects(getConfig()).map((redirect) => { const { source, destination, permanent } = redirect; if (/\s/.test(source) || /\s/.test(destination)) { @@ -159,9 +159,17 @@ const generateCloudflareRedirects = (dir) => { } return `${source} ${destination} ${permanent === false ? 307 : 301}`; }); + const markdownRewrites = markdownRoutes + .filter((route) => !/^(?:llms|og|api|_next)(?:\/|$)/.test(route)) + .map((route) => `/${route}.md /llms/${route}/content.md 200`); + const lines = [...redirects, ...markdownRewrites]; - writeFileWithDirs(join(dir, '_redirects'), `${redirects.join('\n')}\n`); - return { redirects: redirects.length }; + if (lines.length > 2000) { + throw new Error(`Cloudflare _redirects has ${lines.length} static rules; the limit is 2000`); + } + + writeFileWithDirs(join(dir, '_redirects'), `${lines.join('\n')}\n`); + return { redirects: redirects.length, rewrites: markdownRewrites.length }; }; /** @param {string} dir */ @@ -255,8 +263,9 @@ const generateCloudflareSearchIndex = (dir) => { /** @param {string} dir */ const generateSiblingMarkdownFiles = (dir) => { const llms = join(dir, 'llms'); - if (!existsSync(llms)) return { files: 0 }; + if (!existsSync(llms)) return { files: 0, routes: [] }; let files = 0; + const routes = []; /** @param {string} cur */ const walk = (cur) => { for (const entry of readdirSync(cur, { withFileTypes: true })) { @@ -272,18 +281,19 @@ const generateSiblingMarkdownFiles = (dir) => { if (!existsSync(html)) continue; writeFileWithDirs(target, readFileSync(path, 'utf8')); files += 1; + routes.push(route); } }; walk(llms); - return { files }; + return { files, routes }; }; /** @param {string} dir */ const main = (dir) => { const pfx = 'post-build:'; console.log(pfx, 'generating sibling LLM markdown files...'); - const { files: mdFiles } = generateSiblingMarkdownFiles(dir); + const { files: mdFiles, routes: markdownRoutes } = generateSiblingMarkdownFiles(dir); console.log(pfx, `${mdFiles} markdown files`); if (isCloudflarePagesBuild) { @@ -293,8 +303,8 @@ const main = (dir) => { } console.log(pfx, 'generating Cloudflare Pages _redirects...'); - const { redirects } = generateCloudflareRedirects(dir); - console.log(pfx, `${redirects} redirects`); + const { redirects, rewrites } = generateCloudflareRedirects(dir, markdownRoutes); + console.log(pfx, `${redirects} redirects, ${rewrites} markdown rewrites`); console.log(pfx, 'limiting Cloudflare Pages Functions to /api/search...'); generateCloudflareRoutes(dir); From a7cb71d4c7ad2d216ae6857f5c845d56893118b1 Mon Sep 17 00:00:00 2001 From: Petr Makhnev <51853996+i582@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:56:04 +0400 Subject: [PATCH 5/6] headers and fixes in redirects --- scripts/post-build.mjs | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/scripts/post-build.mjs b/scripts/post-build.mjs index 2ab07ff1b..2e5f4f01b 100644 --- a/scripts/post-build.mjs +++ b/scripts/post-build.mjs @@ -157,7 +157,7 @@ const generateCloudflareRedirects = (dir, markdownRoutes = []) => { if (/\s/.test(source) || /\s/.test(destination)) { throw new Error(`Cloudflare redirect contains whitespace: ${source} → ${destination}`); } - return `${source} ${destination} ${permanent === false ? 307 : 301}`; + return `${source} ${destination} ${permanent === false ? 307 : 308}`; }); const markdownRewrites = markdownRoutes .filter((route) => !/^(?:llms|og|api|_next)(?:\/|$)/.test(route)) @@ -172,6 +172,34 @@ const generateCloudflareRedirects = (dir, markdownRoutes = []) => { return { redirects: redirects.length, rewrites: markdownRewrites.length }; }; +/** @param {string} dir */ +const generateCloudflareHeaders = (dir) => { + const headerRules = getConfig().headers ?? []; + const lines = []; + let headers = 0; + + for (const rule of headerRules) { + const { source, headers: ruleHeaders } = rule; + if (!source || /[\r\n]/.test(source)) { + throw new Error(`Cloudflare header contains an invalid source: ${source}`); + } + + lines.push(source); + for (const header of ruleHeaders ?? []) { + const { key, value } = header; + if (!key || value === undefined || /[\r\n]/.test(String(value))) { + throw new Error(`Cloudflare header contains an invalid definition for ${source}`); + } + lines.push(` ${key}: ${value}`); + headers += 1; + } + lines.push(''); + } + + writeFileWithDirs(join(dir, '_headers'), `${lines.join('\n').trimEnd()}\n`); + return { rules: headerRules.length, headers }; +}; + /** @param {string} dir */ const generateCloudflareRoutes = (dir) => { const routes = { @@ -306,6 +334,10 @@ const main = (dir) => { const { redirects, rewrites } = generateCloudflareRedirects(dir, markdownRoutes); console.log(pfx, `${redirects} redirects, ${rewrites} markdown rewrites`); + console.log(pfx, 'generating Cloudflare Pages _headers...'); + const headers = generateCloudflareHeaders(dir); + console.log(pfx, `${headers.rules} header rules, ${headers.headers} headers`); + console.log(pfx, 'limiting Cloudflare Pages Functions to /api/search...'); generateCloudflareRoutes(dir); From bbb2a903cf87877c5e06c319d2353275d66a76c4 Mon Sep 17 00:00:00 2001 From: Petr Makhnev <51853996+i582@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:56:56 +0400 Subject: [PATCH 6/6] fix --- content/contracts/overview.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/content/contracts/overview.mdx b/content/contracts/overview.mdx index b8997e914..a5c037bd6 100644 --- a/content/contracts/overview.mdx +++ b/content/contracts/overview.mdx @@ -4,6 +4,8 @@ sidebarTitle: "Overview" description: "How to build, test, deploy, debug, and otherwise interact with TON smart contracts" --- +Just use Acton. + This section covers the recommended toolchain, editor support, standard contracts, reusable techniques, and the legacy TypeScript environment.