mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-28 19:35:27 +08:00
[verified] feat: synchronize Blog articles into Wiki (#113)
Co-authored-by: dadachann <[email protected]>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
# LangBot Blog → Wiki article synchronization
|
||||
|
||||
`langbot-app/langbot-landing-page/src/content/blog` is the canonical article source. The Wiki publishes generated copies under `{en,zh,ja}/articles/` and stores every rendered image locally under `images/articles/` so Wiki pages do not depend on third-party hotlinks.
|
||||
|
||||
## Run locally
|
||||
|
||||
From the Wiki checkout, with the landing-page repository available as a sibling checkout:
|
||||
|
||||
```bash
|
||||
node scripts/sync-blog-articles.mjs --source ../langbot-landing-page
|
||||
node scripts/sync-blog-articles.mjs --check --source ../langbot-landing-page
|
||||
node scripts/sync-blog-articles.mjs --check
|
||||
npm test
|
||||
```
|
||||
|
||||
The synchronizer:
|
||||
|
||||
- requires matching English and Chinese slug sets;
|
||||
- uses the canonical Japanese article when one exists and a clearly marked English fallback otherwise;
|
||||
- groups navigation into product updates, engineering, tutorials/integrations, and announcements with localized labels;
|
||||
- copies source images and downloads external images into the Wiki repository;
|
||||
- rewrites Markdown image URLs to Wiki-local paths;
|
||||
- escapes prose that would otherwise be parsed as MDX expressions;
|
||||
- replaces the generated article directories and updates the `docs.json` article tabs deterministically.
|
||||
|
||||
Do not edit generated article pages or mirrored images manually. Fix the canonical Blog post or the synchronizer, then regenerate.
|
||||
|
||||
A daily GitHub Actions workflow provides drift recovery. Article publishing work should still synchronize and verify the Wiki in the same task so a new Blog post does not wait for the scheduled run.
|
||||
@@ -0,0 +1,594 @@
|
||||
{
|
||||
"source": "https://github.com/langbot-app/langbot-landing-page/tree/main/src/content/blog",
|
||||
"articles": [
|
||||
{
|
||||
"locale": "en",
|
||||
"slug": "connect-deepseek-to-wechat",
|
||||
"page": "en/articles/connect-deepseek-to-wechat",
|
||||
"sourceLocale": "en",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "en",
|
||||
"slug": "deploy-ai-bot-in-5-minutes",
|
||||
"page": "en/articles/deploy-ai-bot-in-5-minutes",
|
||||
"sourceLocale": "en",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "en",
|
||||
"slug": "dify-agent-discord-telegram-slack",
|
||||
"page": "en/articles/dify-agent-discord-telegram-slack",
|
||||
"sourceLocale": "en",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "en",
|
||||
"slug": "dify-langbot-rag-knowledge-base",
|
||||
"page": "en/articles/dify-langbot-rag-knowledge-base",
|
||||
"sourceLocale": "en",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "en",
|
||||
"slug": "langbot-cloud-multi-user-workspaces",
|
||||
"page": "en/articles/langbot-cloud-multi-user-workspaces",
|
||||
"sourceLocale": "en",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "en",
|
||||
"slug": "langbot-plugin-system-deep-dive",
|
||||
"page": "en/articles/langbot-plugin-system-deep-dive",
|
||||
"sourceLocale": "en",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "en",
|
||||
"slug": "langbot-v4100-sandbox-skills",
|
||||
"page": "en/articles/langbot-v4100-sandbox-skills",
|
||||
"sourceLocale": "en",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "en",
|
||||
"slug": "langbot-v490-rag-plugin-architecture",
|
||||
"page": "en/articles/langbot-v490-rag-plugin-architecture",
|
||||
"sourceLocale": "en",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "en",
|
||||
"slug": "langflow-drag-and-drop-ai-bot",
|
||||
"page": "en/articles/langflow-drag-and-drop-ai-bot",
|
||||
"sourceLocale": "en",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "en",
|
||||
"slug": "langtars-remote-pc-control-dify-n8n",
|
||||
"page": "en/articles/langtars-remote-pc-control-dify-n8n",
|
||||
"sourceLocale": "en",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "en",
|
||||
"slug": "n8n-multi-platform-ai-chatbot",
|
||||
"page": "en/articles/n8n-multi-platform-ai-chatbot",
|
||||
"sourceLocale": "en",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "en",
|
||||
"slug": "welcome",
|
||||
"page": "en/articles/welcome",
|
||||
"sourceLocale": "en",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "zh",
|
||||
"slug": "connect-deepseek-to-wechat",
|
||||
"page": "zh/articles/connect-deepseek-to-wechat",
|
||||
"sourceLocale": "zh",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "zh",
|
||||
"slug": "deploy-ai-bot-in-5-minutes",
|
||||
"page": "zh/articles/deploy-ai-bot-in-5-minutes",
|
||||
"sourceLocale": "zh",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "zh",
|
||||
"slug": "dify-agent-discord-telegram-slack",
|
||||
"page": "zh/articles/dify-agent-discord-telegram-slack",
|
||||
"sourceLocale": "zh",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "zh",
|
||||
"slug": "dify-langbot-rag-knowledge-base",
|
||||
"page": "zh/articles/dify-langbot-rag-knowledge-base",
|
||||
"sourceLocale": "zh",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "zh",
|
||||
"slug": "langbot-cloud-multi-user-workspaces",
|
||||
"page": "zh/articles/langbot-cloud-multi-user-workspaces",
|
||||
"sourceLocale": "zh",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "zh",
|
||||
"slug": "langbot-plugin-system-deep-dive",
|
||||
"page": "zh/articles/langbot-plugin-system-deep-dive",
|
||||
"sourceLocale": "zh",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "zh",
|
||||
"slug": "langbot-v4100-sandbox-skills",
|
||||
"page": "zh/articles/langbot-v4100-sandbox-skills",
|
||||
"sourceLocale": "zh",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "zh",
|
||||
"slug": "langbot-v490-rag-plugin-architecture",
|
||||
"page": "zh/articles/langbot-v490-rag-plugin-architecture",
|
||||
"sourceLocale": "zh",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "zh",
|
||||
"slug": "langflow-drag-and-drop-ai-bot",
|
||||
"page": "zh/articles/langflow-drag-and-drop-ai-bot",
|
||||
"sourceLocale": "zh",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "zh",
|
||||
"slug": "langtars-remote-pc-control-dify-n8n",
|
||||
"page": "zh/articles/langtars-remote-pc-control-dify-n8n",
|
||||
"sourceLocale": "zh",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "zh",
|
||||
"slug": "n8n-multi-platform-ai-chatbot",
|
||||
"page": "zh/articles/n8n-multi-platform-ai-chatbot",
|
||||
"sourceLocale": "zh",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "zh",
|
||||
"slug": "welcome",
|
||||
"page": "zh/articles/welcome",
|
||||
"sourceLocale": "zh",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "ja",
|
||||
"slug": "connect-deepseek-to-wechat",
|
||||
"page": "ja/articles/connect-deepseek-to-wechat",
|
||||
"sourceLocale": "en",
|
||||
"fallback": true
|
||||
},
|
||||
{
|
||||
"locale": "ja",
|
||||
"slug": "deploy-ai-bot-in-5-minutes",
|
||||
"page": "ja/articles/deploy-ai-bot-in-5-minutes",
|
||||
"sourceLocale": "en",
|
||||
"fallback": true
|
||||
},
|
||||
{
|
||||
"locale": "ja",
|
||||
"slug": "dify-agent-discord-telegram-slack",
|
||||
"page": "ja/articles/dify-agent-discord-telegram-slack",
|
||||
"sourceLocale": "en",
|
||||
"fallback": true
|
||||
},
|
||||
{
|
||||
"locale": "ja",
|
||||
"slug": "dify-langbot-rag-knowledge-base",
|
||||
"page": "ja/articles/dify-langbot-rag-knowledge-base",
|
||||
"sourceLocale": "en",
|
||||
"fallback": true
|
||||
},
|
||||
{
|
||||
"locale": "ja",
|
||||
"slug": "langbot-cloud-multi-user-workspaces",
|
||||
"page": "ja/articles/langbot-cloud-multi-user-workspaces",
|
||||
"sourceLocale": "ja",
|
||||
"fallback": false
|
||||
},
|
||||
{
|
||||
"locale": "ja",
|
||||
"slug": "langbot-plugin-system-deep-dive",
|
||||
"page": "ja/articles/langbot-plugin-system-deep-dive",
|
||||
"sourceLocale": "en",
|
||||
"fallback": true
|
||||
},
|
||||
{
|
||||
"locale": "ja",
|
||||
"slug": "langbot-v4100-sandbox-skills",
|
||||
"page": "ja/articles/langbot-v4100-sandbox-skills",
|
||||
"sourceLocale": "en",
|
||||
"fallback": true
|
||||
},
|
||||
{
|
||||
"locale": "ja",
|
||||
"slug": "langbot-v490-rag-plugin-architecture",
|
||||
"page": "ja/articles/langbot-v490-rag-plugin-architecture",
|
||||
"sourceLocale": "en",
|
||||
"fallback": true
|
||||
},
|
||||
{
|
||||
"locale": "ja",
|
||||
"slug": "langflow-drag-and-drop-ai-bot",
|
||||
"page": "ja/articles/langflow-drag-and-drop-ai-bot",
|
||||
"sourceLocale": "en",
|
||||
"fallback": true
|
||||
},
|
||||
{
|
||||
"locale": "ja",
|
||||
"slug": "langtars-remote-pc-control-dify-n8n",
|
||||
"page": "ja/articles/langtars-remote-pc-control-dify-n8n",
|
||||
"sourceLocale": "en",
|
||||
"fallback": true
|
||||
},
|
||||
{
|
||||
"locale": "ja",
|
||||
"slug": "n8n-multi-platform-ai-chatbot",
|
||||
"page": "ja/articles/n8n-multi-platform-ai-chatbot",
|
||||
"sourceLocale": "en",
|
||||
"fallback": true
|
||||
},
|
||||
{
|
||||
"locale": "ja",
|
||||
"slug": "welcome",
|
||||
"page": "ja/articles/welcome",
|
||||
"sourceLocale": "en",
|
||||
"fallback": true
|
||||
}
|
||||
],
|
||||
"images": [
|
||||
{
|
||||
"path": "/images/articles/external/03443176ae5c-08-n8n-webhook-node.png",
|
||||
"sha256": "8b05859a0d3c77a11755e493b464eb12a04f1abd341f982432f06be5e737d72f",
|
||||
"bytes": 92552
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/0c8aa2c940ab-01-langbot-initial-page.png",
|
||||
"sha256": "5dadd6447bca26f2aea014cdb63d075f655c30194c0e46b15b3f79c7dab768ee",
|
||||
"bytes": 38365
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/12d0e04600c5-05-n8n-setup.png",
|
||||
"sha256": "52cc42c6f1e12c4df29ac5a646888154e9193b33b28cdd678e8d88a6123bf0eb",
|
||||
"bytes": 30953
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/21fc47358066-05-langflow-api-keys-page.png",
|
||||
"sha256": "758bf810158bf3fe5ec1b1571086371db457de809de91743ade776c2c7497693",
|
||||
"bytes": 54223
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/2a3a0d5f4ae9-install-flow.png",
|
||||
"sha256": "7fe8844aeb196a3cb3c6338b4b6b7cf8c5255780a37a7aedd2cd5e2adaf3f99c",
|
||||
"bytes": 138088
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/2fb54ce0f7a2-11-langbot-langflow-config-filled.png",
|
||||
"sha256": "3da8e3faf3984091df9b176c5d05d341cbd3427ac35a61047cb6353d20edf418",
|
||||
"bytes": 100965
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/3c7d6bc96738-08-langbot-pipeline-ai-tab.png",
|
||||
"sha256": "a0c20a44d45f20d7588f611eb1ac006be68360f5289b2a767f36a6a35b16449b",
|
||||
"bytes": 88489
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/3d7fa393f210-13-langbot-chat-with-response.png",
|
||||
"sha256": "e1c8d41b8efb17a31fc9b41206b44e0d2a1bf83099a2d4d29d3b2c59ed9142e9",
|
||||
"bytes": 62219
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/3e5b1d8ec6ec-07-dify-create-app-types.png",
|
||||
"sha256": "6f161d5de119721af085ed40ce86c50aca4d6566f97241f7eeba1f83aefd3f0d",
|
||||
"bytes": 165144
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/3fe5bdc18716-icon.svg",
|
||||
"sha256": "430b8fc2bbcd1799231285f9f9645becce0d4cf7bef4412769db425f52cfac23",
|
||||
"bytes": 734399
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/471b316018e5-13-dify-api-key-created.png",
|
||||
"sha256": "776930876f0c50ef7bd70ff81faa4fdae2a66de4dbec4a3b595d2db3213c7ff3",
|
||||
"bytes": 128227
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/4f1f5c28fbfc-03-langbot-dashboard.png",
|
||||
"sha256": "3d6769445112e80fddf079028f81e4df67d28374b20c4f3a94086a535ed38fa3",
|
||||
"bytes": 38244
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/621f3d526678-09-n8n-webhook-configured.png",
|
||||
"sha256": "e19bb4356edfb75f463928528f702ed8a765d879ea377bf9721c2a24088f457a",
|
||||
"bytes": 48125
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/6613b3025b8b-usage-example.png",
|
||||
"sha256": "387e811bf57f83b121842a0e9066f2c045ad18e2666f6c04ae5eb568c288efc1",
|
||||
"bytes": 126628
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/6e24402fd4a8-03-langflow-workflow-editor.png",
|
||||
"sha256": "53069258d131ed4e87e6467eb95e8b90764684d5c948bdae9b4109f07667b158",
|
||||
"bytes": 212230
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/70b26e660a60-tars.gif",
|
||||
"sha256": "ec59948d2c335c17f373186b1913703017c8664554b53f7fb49c3eae0ee19ed5",
|
||||
"bytes": 1544475
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/70ee40a6d232-01-langbot-initialization.png",
|
||||
"sha256": "b9f45c78b49bdfdb51e278b2e491b88ca35d1a4ed05f33fc4c15085baacf2391",
|
||||
"bytes": 42702
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/74f71a8bf1dc-17-langbot-dify-api-key-entered.png",
|
||||
"sha256": "225397739ad8458f38002b75dfbbdc6eea268e9e3d1dd478e8a498b7a302e0d4",
|
||||
"bytes": 101173
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/7e7ebfc3ba58-01-langflow-welcome.png",
|
||||
"sha256": "62876dd239a917eb88ba7de369f9444e1c2fa169f3a347715c86c9bbaa4fba36",
|
||||
"bytes": 50425
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/82d5e2ffb9a0-10-langbot-langflow-config-form.png",
|
||||
"sha256": "56d2046d64179008e6d0384bce1dd4797f9818d866df86bf20ce16f9db322bbd",
|
||||
"bytes": 92112
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/8f04c7b00759-06-langflow-create-api-key-dialog.png",
|
||||
"sha256": "19a51834168c350185b0d2de1dd83a025d14d75bd3dfe3278997f755a258b3ae",
|
||||
"bytes": 72422
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/8f4b3820ed5b-14-langbot-pipeline-ai-capability.png",
|
||||
"sha256": "b751c1d8eacca6b9f5ffe6645d3d2b2d2557dacb88002b85dd1bae2f9d81f6ed",
|
||||
"bytes": 86022
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/9066d99b172c-11-dify-tools-panel.png",
|
||||
"sha256": "b099d333ded4cdf4e3392de8f444b3e7e7be81804e717b9c0a9735f0aff35844",
|
||||
"bytes": 176867
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/9999c12907eb-20-langbot-chat-test.png",
|
||||
"sha256": "ee409e91f03415bda392e8d51d31ffea16a4489773255f8c3690a63d6d67ced7",
|
||||
"bytes": 61568
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/a618d3d41416-security.png",
|
||||
"sha256": "18bf5b073596354b64c0abd4692c79f73da24b2e69b400c9164e3b62da76e361",
|
||||
"bytes": 91790
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/b304cebf8ce9-02-langflow-template-selection.png",
|
||||
"sha256": "d28ff1118326a43cb089ce21fd7fd4bb8c181bd037ac33b461dbf6e10a00dd3c",
|
||||
"bytes": 455482
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/b36d6dea3702-12-langbot-debug-chat-interface.png",
|
||||
"sha256": "30a260b07cc96584a39de1419d53b17991b9d86c10a956ac5896a91ab5617f49",
|
||||
"bytes": 55005
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/b93d97b5b830-04-langbot-dashboard.png",
|
||||
"sha256": "ae9dfe886f8802e1d35fa185b94465d40a229145ed65279d9b89f7bb63bd430b",
|
||||
"bytes": 35641
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/b9a33a8551a7-8dd37939-4f10-4046-bd84-cce798005cd4.png",
|
||||
"sha256": "1dbae106a3306820c6364faa7e21cf57fee5ea76b1187ed809c6faa8f3985863",
|
||||
"bytes": 140727
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/bcd91ef5e4b3-14-langflow-openai-api-key-configured.png",
|
||||
"sha256": "785b4cf39a62539eb334a750bc8e53466077198f120786fdb9c078b7e062d0d6",
|
||||
"bytes": 209881
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/be278ca435e8-12-dify-agent-configured.png",
|
||||
"sha256": "5feda01e6419a40658786e906a59b5e1c7e27503842e5b08fe3990d2d5b44a1c",
|
||||
"bytes": 141049
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/c6d52212ba91-02-langbot-dashboard.png",
|
||||
"sha256": "43a4eabed41c1ac9a356d33b8a160afac7a74ad1d06452827b3dd6c03017195a",
|
||||
"bytes": 36354
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/e36d04ca4a5b-07-n8n-workflow-editor.png",
|
||||
"sha256": "f845f0d7fd7553af2406c0e3bc1b461b4a5dd76ebd108f8ff3b48c3de3804037",
|
||||
"bytes": 41296
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/e5a019491ef8-07-langflow-api-key-generated.png",
|
||||
"sha256": "50c2e5d99ad7c1bbd40e6f467b0bd3edc85454a8867b9c11077f9f5f2ddececa",
|
||||
"bytes": 82662
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/ef2b2f83bd16-04-langbot-pipelines.png",
|
||||
"sha256": "f1edae3941ae6b55085a84e9c290a95744015ef4db1bd9978e97109b8ab320d4",
|
||||
"bytes": 58250
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/ef9bbef78165-09-langbot-runner-dropdown.png",
|
||||
"sha256": "3fc0d26c86715a1e859925727bdd27b84671da158185ff6860a73fd4f5f3466f",
|
||||
"bytes": 94483
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/efa6fcae9c58-15-langbot-runner-dropdown.png",
|
||||
"sha256": "826a60ffe5198b5cef2620f9f4ba553b006c6b3ad2ee2e0b041f044b6536bc56",
|
||||
"bytes": 92060
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/external/ff42857251ab-planner-mode.png",
|
||||
"sha256": "1e2ee21c697ab9c2320094874889e2ac0bd2fc44efb529eafa4d895393ca5691",
|
||||
"bytes": 161907
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/article-mcp/01-init-page.png",
|
||||
"sha256": "b392e42d582c8c1723a2eb63596801b9d459df0465377246d9a4015edcae1ca0",
|
||||
"bytes": 45011
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/article-mcp/05-pipelines.png",
|
||||
"sha256": "66891ef15942b5c11c92a60208ffed491414a488a8352b1f255b65b4d95cd912",
|
||||
"bytes": 54047
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/article-mcp/06-pipeline-ai.png",
|
||||
"sha256": "41b60a85a29e4fa75c8cb59c7f55fe51912eddb961340b04153201fdc5ae9d06",
|
||||
"bytes": 72714
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/article-mcp/07-debug-chat.png",
|
||||
"sha256": "6bb15b2ebb29878b83882eaf17e6f5a39785dd17572b7f864e7e653bdfd59fce",
|
||||
"bytes": 57403
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/article-mcp/08-model-select.png",
|
||||
"sha256": "27100d6a84663da8b227a460841698233ed5f8e4323b23fec2608d0acbec5682",
|
||||
"bytes": 89524
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/article-mcp/10-group-chat.png",
|
||||
"sha256": "b87b7d219527aebedfb48e6269796bc2ecc6eb68ec1f661dd1b1b4903c5f0f66",
|
||||
"bytes": 58454
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/article-mcp/11-private-chat.png",
|
||||
"sha256": "3e79edd893da3cfc983d2d8ff451c828e5bcd704f3f75c36711f01a94058902b",
|
||||
"bytes": 53834
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/article-mcp/13-bot-page.png",
|
||||
"sha256": "1a2dfbf25b164df63cb87d7490474d45b273e5e33c121e75490d923eaa41fb8f",
|
||||
"bytes": 341107
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/article-mcp/14-space-market.png",
|
||||
"sha256": "49ccec9d7f98f74020573a9e1da30c3fc3729ebb32610dad672ccc800c1455ef",
|
||||
"bytes": 110469
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/cloud-multi-user/cloud-invitation.png",
|
||||
"sha256": "8102e7f3d2babb38d5f8f211167c23d119858924913c0e576d3cbe1f9cca02ca",
|
||||
"bytes": 23712
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/cloud-multi-user/cloud-login.png",
|
||||
"sha256": "aa49e8120707c2e4d2fb7fde346afb7aa0eaf46bfa985027c079f3984d76287b",
|
||||
"bytes": 33810
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/cloud-multi-user/oss-dashboard.png",
|
||||
"sha256": "c9f8a0402c2077571826fafe7c3587c695c7aaed12186fa4f86823f6fa99da98",
|
||||
"bytes": 103726
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/covers/deepseek-wechat.png",
|
||||
"sha256": "fd0f772d9cd0bfa4c33e2ea38a6ab09710f2df08c004af91f17e4882c60be146",
|
||||
"bytes": 20896
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/covers/deploy-ai-bot.png",
|
||||
"sha256": "2bc212631b8b072e214bb433e4da241aca0e4c99087bef9ea24f5fd368bf6c7d",
|
||||
"bytes": 20798
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/covers/dify-agent.png",
|
||||
"sha256": "114d06c78593aa66e693768435822cdde7bec356d39e17b74a78d012c123a3a0",
|
||||
"bytes": 11651
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/covers/dify-rag.png",
|
||||
"sha256": "7746be4bb44228165f00a65ca5b147559058a5c79f9a91ac80fb3f1277c34dfe",
|
||||
"bytes": 15799
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/covers/langflow.png",
|
||||
"sha256": "b577d13d95e4b5428a076fb68f17a020f75a5d5f0b581f51e94323f83fd922b1",
|
||||
"bytes": 14706
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/covers/n8n.png",
|
||||
"sha256": "bea4ff8a15517784ab0035483a43ace25b0b2ce5099d7c39f0f817520801750b",
|
||||
"bytes": 14297
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/covers/plugin-system.png",
|
||||
"sha256": "cdf9a0e2249901835f98fe94faa9071ef8ffaa57831a8af30dd69155aeac953c",
|
||||
"bytes": 17004
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/covers/rag-plugin.png",
|
||||
"sha256": "c4e24290b57b30f55d086b2557425f7e790bedd2c8fa17d09c9cbc23686bdab3",
|
||||
"bytes": 14661
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/covers/sandbox-skills.png",
|
||||
"sha256": "2a89789d61ffe229aeb20c069a0e63102374abcdfe0f99e799fc5563cfb1910d",
|
||||
"bytes": 86846
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/covers/welcome.png",
|
||||
"sha256": "5ff0078633e30e8d8d8efa5abf686fbe13993b06cee383d2e7b9afec16639712",
|
||||
"bytes": 11535
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/plugin-system/architecture.png",
|
||||
"sha256": "85028534d0a6246bd25f110189a6734f6176e1765fc64bf5eeb3248137e7292d",
|
||||
"bytes": 30445
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/plugin-system/event-dispatch.png",
|
||||
"sha256": "ba6780bfda4c250da30575e87570e6b3d1a2bd682265340c365b4d3ba575ea13",
|
||||
"bytes": 31013
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/rag-plugin/data-flow.png",
|
||||
"sha256": "9f6f331d4ed508a983e7cc9aeb34914ba47cf0e00a1bd2df87c7424eab93c156",
|
||||
"bytes": 31760
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/rag-plugin/dir-structure.png",
|
||||
"sha256": "f167f813eda287ba11b492adc2626f1340f54b2cb03fe77f496ab5f3bbc65db3",
|
||||
"bytes": 20301
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/sandbox-skills/sandbox-tools.png",
|
||||
"sha256": "3137100959db409a0fa5b70e25bded4bb47df20c90c3a65a578d7862a4e64a3f",
|
||||
"bytes": 169802
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/sandbox-skills/sandbox.png",
|
||||
"sha256": "3a1fd443f4f8008f58144c9e344c2dc50f6faa343c8f65732d9df7d35d93744a",
|
||||
"bytes": 46498
|
||||
},
|
||||
{
|
||||
"path": "/images/articles/source/images/sandbox-skills/skills.png",
|
||||
"sha256": "6ad86d972c7438fbef898d9d339f3f17a30277bf1626e8a9dd11b0b46583c817",
|
||||
"bytes": 362173
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+429
@@ -0,0 +1,429 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, "..");
|
||||
const ARTICLE_LOCALES = ["en", "zh", "ja"];
|
||||
const MAX_IMAGE_BYTES = 25 * 1024 * 1024;
|
||||
const LANGUAGE_CONFIG = {
|
||||
en: { mintlify: "en", tab: "Articles", introTitle: "LangBot Articles", introDescription: "Product updates, engineering deep dives, tutorials, integrations, and announcements from the LangBot Blog.", sourceLabel: "This article is synchronized from the LangBot Blog.", sourceAction: "Read the canonical version", fallbackLabel: "", overviewGroup: "Browse", groups: { updates: "Product Updates", engineering: "Engineering", tutorials: "Tutorials & Integrations", announcements: "Announcements" } },
|
||||
zh: { mintlify: "cn", tab: "文章", introTitle: "LangBot 文章", introDescription: "同步自 LangBot Blog 的产品动态、技术解析、教程、集成实践与公告。", sourceLabel: "本文同步自 LangBot Blog。", sourceAction: "阅读 Blog 原文", fallbackLabel: "", overviewGroup: "浏览", groups: { updates: "产品动态", engineering: "技术解析", tutorials: "教程与集成", announcements: "公告" } },
|
||||
ja: { mintlify: "jp", tab: "記事", introTitle: "LangBot 記事", introDescription: "LangBot Blog の製品アップデート、技術解説、チュートリアル、連携事例、お知らせをまとめています。", sourceLabel: "この記事は LangBot Blog から同期されています。", sourceAction: "Blog の原文を読む", fallbackLabel: "この記事の日本語版はまだ公開されていないため、英語版を掲載しています。", overviewGroup: "一覧", groups: { updates: "製品アップデート", engineering: "エンジニアリング", tutorials: "チュートリアルと連携", announcements: "お知らせ" } },
|
||||
};
|
||||
const JAPANESE_TITLES = {
|
||||
"connect-deepseek-to-wechat": "DeepSeek R1 を WeChat・Discord・Telegram に5分で接続する方法",
|
||||
"deploy-ai-bot-in-5-minutes": "AI Bot を Discord・Telegram・WeChat に5分でデプロイ",
|
||||
"dify-agent-discord-telegram-slack": "Dify Agent を Discord・Telegram・Slack で動かす",
|
||||
"dify-langbot-rag-knowledge-base": "LangBot 4.6.0 外部ナレッジベース入門:Dify と連携した RAG 会話",
|
||||
"langbot-plugin-system-deep-dive": "LangBot プラグインシステム詳解:プロセス分離・イベント駆動・コンポーネント設計",
|
||||
"langbot-v4100-sandbox-skills": "LangBot v4.10.0:Agent Sandbox と Skills",
|
||||
"langbot-v490-rag-plugin-architecture": "LangBot v4.9.0:完全プラグイン化された RAG アーキテクチャ",
|
||||
"langflow-drag-and-drop-ai-bot": "Langflow のドラッグ&ドロップでマルチプラットフォーム AI Bot を構築",
|
||||
"langtars-remote-pc-control-dify-n8n": "LangTARS:Dify・n8n と連携するオープンソース PC 操作 Agent",
|
||||
"n8n-multi-platform-ai-chatbot": "n8n と LangBot でマルチプラットフォーム AI Chatbot を構築",
|
||||
welcome: "LangBot Blog のご紹介",
|
||||
};
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = { check: false, source: process.env.LANGBOT_LANDING_REPO || "" };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
if (argv[i] === "--check") result.check = true;
|
||||
else if (argv[i] === "--source") result.source = argv[++i];
|
||||
else throw new Error(`Unknown argument: ${argv[i]}`);
|
||||
}
|
||||
if (result.source) result.source = path.resolve(result.source);
|
||||
return result;
|
||||
}
|
||||
function parseScalar(value) {
|
||||
const trimmed = value.trim();
|
||||
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) return trimmed.slice(1, -1).replaceAll('\\"', '"');
|
||||
return trimmed;
|
||||
}
|
||||
function parseFrontmatter(markdown, file) {
|
||||
if (!markdown.startsWith("---\n")) throw new Error(`${file}: missing frontmatter`);
|
||||
const end = markdown.indexOf("\n---\n", 4);
|
||||
if (end < 0) throw new Error(`${file}: unterminated frontmatter`);
|
||||
const raw = markdown.slice(4, end);
|
||||
const body = markdown.slice(end + 5).trimStart();
|
||||
const lines = raw.split("\n");
|
||||
const data = {};
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const match = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(lines[i]);
|
||||
if (!match) continue;
|
||||
const [, key, value] = match;
|
||||
if (key === "cover") {
|
||||
const cover = {};
|
||||
while (i + 1 < lines.length && /^\s+/.test(lines[i + 1])) {
|
||||
const nested = /^\s+([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(lines[++i]);
|
||||
if (nested) cover[nested[1]] = parseScalar(nested[2]);
|
||||
}
|
||||
data.cover = cover;
|
||||
} else if (value.startsWith("[") && value.endsWith("]")) {
|
||||
data[key] = [...value.matchAll(/"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'/g)].map((item) => item[1] ?? item[2]);
|
||||
} else data[key] = parseScalar(value);
|
||||
}
|
||||
for (const required of ["title", "date", "description", "author"]) if (!data[required]) throw new Error(`${file}: missing ${required}`);
|
||||
return { data, body };
|
||||
}
|
||||
function yamlQuote(value) { return JSON.stringify(String(value)); }
|
||||
function classify(post) {
|
||||
const category = String(post.data.categories?.[0] || "").toLowerCase();
|
||||
if (category.includes("product") || category.includes("产品") || category.includes("製品")) return "updates";
|
||||
if (category.includes("engineering") || category.includes("technical") || category.includes("技术") || category.includes("技術")) return "engineering";
|
||||
if (category.includes("announcement") || category.includes("公告") || category.includes("お知らせ")) return "announcements";
|
||||
return "tutorials";
|
||||
}
|
||||
function canonicalUrl(locale, slug) { return `https://langbot.app/${locale}/blog/${slug}`; }
|
||||
function attribution(locale, post) {
|
||||
if (locale === "zh") return `发布于 ${post.data.date} · 作者:${post.data.author}`;
|
||||
if (locale === "ja") return `公開日:${post.data.date} · 著者:${post.data.author}`;
|
||||
return `Published ${post.data.date} · Author: ${post.data.author}`;
|
||||
}
|
||||
function escapeMdxProse(markdown) {
|
||||
const lines = markdown.split("\n");
|
||||
let fenced = false;
|
||||
return lines.map((line) => {
|
||||
if (/^\s*```/.test(line) || /^\s*~~~/.test(line)) { fenced = !fenced; return line; }
|
||||
if (fenced) return line.trimEnd();
|
||||
return line.split(/(`[^`]*`)/g).map((piece, index) => index % 2 === 1 ? piece : piece.replaceAll("{", "{").replaceAll("}", "}")).join("").trimEnd();
|
||||
}).join("\n");
|
||||
}
|
||||
function imageReferences(markdown) { return [...markdown.matchAll(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g)].map((match) => ({ alt: match[1], url: match[2], full: match[0] })); }
|
||||
function contentTypeExtension(contentType) {
|
||||
if (contentType.includes("image/png")) return ".png";
|
||||
if (contentType.includes("image/jpeg")) return ".jpg";
|
||||
if (contentType.includes("image/gif")) return ".gif";
|
||||
if (contentType.includes("image/svg")) return ".svg";
|
||||
if (contentType.includes("image/webp")) return ".webp";
|
||||
return "";
|
||||
}
|
||||
function localImageTarget(sourceUrl) {
|
||||
if (sourceUrl.startsWith("/")) return `/images/articles/source${sourceUrl}`;
|
||||
const parsed = new URL(sourceUrl);
|
||||
let base = path.posix.basename(parsed.pathname) || "image";
|
||||
const digest = createHash("sha256").update(sourceUrl).digest("hex").slice(0, 12);
|
||||
base = base.replace(/[^A-Za-z0-9._-]/g, "-");
|
||||
return `/images/articles/external/${digest}-${base}`;
|
||||
}
|
||||
function assertWithin(base, candidate, label) {
|
||||
const normalizedBase = path.resolve(base);
|
||||
const normalizedCandidate = path.resolve(candidate);
|
||||
if (!normalizedCandidate.startsWith(`${normalizedBase}${path.sep}`)) throw new Error(`${label} escapes ${normalizedBase}: ${normalizedCandidate}`);
|
||||
}
|
||||
async function assertDirectoryWithoutSymlinks(directory, label) {
|
||||
if (!existsSync(directory)) throw new Error(`${label} does not exist: ${directory}`);
|
||||
const info = await lstat(directory);
|
||||
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`${label} must be a real non-symlink directory: ${directory}`);
|
||||
const resolved = await realpath(directory);
|
||||
if (resolved !== path.resolve(directory)) throw new Error(`${label} path must not traverse symlinks: ${directory}`);
|
||||
return resolved;
|
||||
}
|
||||
async function readRegularFileWithin(base, file, label, encoding = null) {
|
||||
const baseReal = await assertDirectoryWithoutSymlinks(base, `${label} directory`);
|
||||
if (!existsSync(file)) throw new Error(`${label} does not exist: ${file}`);
|
||||
const info = await lstat(file);
|
||||
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`${label} must be a regular non-symlink file: ${file}`);
|
||||
const fileReal = await realpath(file);
|
||||
assertWithin(baseReal, fileReal, label);
|
||||
return encoding ? readFile(fileReal, encoding) : readFile(fileReal);
|
||||
}
|
||||
function validateImageBytes(bytes, label) {
|
||||
const ascii = bytes.subarray(0, 16).toString("ascii");
|
||||
const trimmed = bytes.toString("utf8", 0, Math.min(bytes.length, 1024)).trimStart();
|
||||
const valid =
|
||||
bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) ||
|
||||
(bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) ||
|
||||
ascii.startsWith("GIF87a") || ascii.startsWith("GIF89a") ||
|
||||
(ascii.startsWith("RIFF") && ascii.slice(8, 12) === "WEBP") ||
|
||||
((trimmed.startsWith("<svg") || trimmed.startsWith("<?xml")) && trimmed.includes("<svg"));
|
||||
if (!valid) throw new Error(`Invalid image signature: ${label}`);
|
||||
}
|
||||
async function prepareImageTarget(assetRoot, target) {
|
||||
const imageRoot = path.join(assetRoot, "images/articles");
|
||||
assertWithin(imageRoot, target, "Article image target");
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
const imageRootReal = await realpath(imageRoot);
|
||||
const targetParentReal = await realpath(path.dirname(target));
|
||||
if (targetParentReal !== path.resolve(path.dirname(target))) throw new Error(`Article image target directory must not be a symlink: ${path.dirname(target)}`);
|
||||
assertWithin(imageRootReal, path.join(targetParentReal, path.basename(target)), "Resolved article image target");
|
||||
if (existsSync(target) && (await lstat(target)).isSymbolicLink()) throw new Error(`Article image target must not be a symlink: ${target}`);
|
||||
}
|
||||
async function materializeImage(sourceRoot, sourceUrl, assetRoot) {
|
||||
let targetUrl = localImageTarget(sourceUrl);
|
||||
let target = path.join(assetRoot, targetUrl.slice(1));
|
||||
await prepareImageTarget(assetRoot, target);
|
||||
if (sourceUrl.startsWith("/")) {
|
||||
const publicRoot = path.join(sourceRoot, "public");
|
||||
const source = path.join(publicRoot, sourceUrl.slice(1));
|
||||
assertWithin(publicRoot, source, "Blog image source");
|
||||
if (!existsSync(source)) throw new Error(`Missing source image: ${sourceUrl} (${source})`);
|
||||
const sourceStat = await lstat(source);
|
||||
if (!sourceStat.isFile() || sourceStat.isSymbolicLink()) throw new Error(`Blog image source must be a regular non-symlink file: ${source}`);
|
||||
const publicRootReal = await realpath(publicRoot);
|
||||
const sourceReal = await realpath(source);
|
||||
assertWithin(publicRootReal, sourceReal, "Resolved Blog image source");
|
||||
const bytes = await readFile(sourceReal);
|
||||
validateImageBytes(bytes, sourceUrl);
|
||||
await writeFile(target, bytes);
|
||||
return targetUrl;
|
||||
}
|
||||
const parsedSource = new URL(sourceUrl);
|
||||
if (parsedSource.protocol !== "https:") throw new Error(`External images must use HTTPS: ${sourceUrl}`);
|
||||
const response = await fetch(sourceUrl, { headers: { "User-Agent": "LangBot-Wiki-Article-Sync/1.0" }, redirect: "follow" });
|
||||
if (!response.ok) throw new Error(`Failed to fetch image ${sourceUrl}: HTTP ${response.status}`);
|
||||
const declaredSize = Number(response.headers.get("content-length") || 0);
|
||||
if (declaredSize > MAX_IMAGE_BYTES) throw new Error(`Image exceeds ${MAX_IMAGE_BYTES} bytes: ${sourceUrl}`);
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (!bytes.length || bytes.length > MAX_IMAGE_BYTES || !contentType.toLowerCase().startsWith("image/")) throw new Error(`Invalid image ${sourceUrl}: ${contentType}, ${bytes.length} bytes`);
|
||||
validateImageBytes(bytes, sourceUrl);
|
||||
if (!path.extname(target)) {
|
||||
const extension = contentTypeExtension(contentType);
|
||||
if (!extension) throw new Error(`Unsupported image content type for extensionless URL: ${contentType}`);
|
||||
target += extension;
|
||||
targetUrl += extension;
|
||||
await prepareImageTarget(assetRoot, target);
|
||||
}
|
||||
if (contentType.toLowerCase().includes("image/svg")) {
|
||||
await writeFile(target, bytes.toString("utf8").replace(/[ \t]+$/gm, ""));
|
||||
} else {
|
||||
await writeFile(target, bytes);
|
||||
}
|
||||
return targetUrl;
|
||||
}
|
||||
async function loadPosts(sourceRoot) {
|
||||
const byLocale = new Map();
|
||||
const blogRoot = path.join(sourceRoot, "src/content/blog");
|
||||
const blogRootReal = await assertDirectoryWithoutSymlinks(blogRoot, "Blog content root");
|
||||
for (const locale of ARTICLE_LOCALES) {
|
||||
const dir = path.join(blogRoot, locale);
|
||||
const files = existsSync(dir) ? await readdir(dir) : [];
|
||||
if (existsSync(dir)) {
|
||||
const localeRootReal = await assertDirectoryWithoutSymlinks(dir, `Blog ${locale} content root`);
|
||||
assertWithin(blogRootReal, localeRootReal, `Blog ${locale} content root`);
|
||||
}
|
||||
const posts = new Map();
|
||||
for (const name of files.filter((item) => item.endsWith(".md")).sort()) {
|
||||
const file = path.join(dir, name);
|
||||
const parsed = parseFrontmatter(await readRegularFileWithin(dir, file, `Blog ${locale} article`, "utf8"), file);
|
||||
const slug = name.slice(0, -3);
|
||||
posts.set(slug, { ...parsed, slug, locale, file });
|
||||
}
|
||||
byLocale.set(locale, posts);
|
||||
}
|
||||
const enSlugs = [...byLocale.get("en").keys()].sort();
|
||||
const zhSlugs = [...byLocale.get("zh").keys()].sort();
|
||||
if (JSON.stringify(enSlugs) !== JSON.stringify(zhSlugs)) throw new Error("English and Chinese blog slug sets differ");
|
||||
return { byLocale, slugs: enSlugs };
|
||||
}
|
||||
async function renderArticle(sourceRoot, post, locale, fallback, assetRoot, imageCache) {
|
||||
const config = LANGUAGE_CONFIG[locale];
|
||||
const title = locale === "ja" && fallback ? (JAPANESE_TITLES[post.slug] || post.data.title) : post.data.title;
|
||||
let body = post.body;
|
||||
const refs = imageReferences(body);
|
||||
for (const ref of refs) if (!imageCache.has(ref.url)) imageCache.set(ref.url, await materializeImage(sourceRoot, ref.url, assetRoot));
|
||||
if (post.data.cover?.image && !imageCache.has(post.data.cover.image)) imageCache.set(post.data.cover.image, await materializeImage(sourceRoot, post.data.cover.image, assetRoot));
|
||||
for (const ref of refs) body = body.replaceAll(ref.full, `})`);
|
||||
body = escapeMdxProse(body);
|
||||
const cover = post.data.cover?.image ? imageCache.get(post.data.cover.image) : "";
|
||||
const bodyHasCover = cover && body.includes(`](${cover})`);
|
||||
const notice = fallback ? `\n> ${config.fallbackLabel}\n` : "";
|
||||
const coverBlock = cover && !bodyHasCover ? `\n\n` : "";
|
||||
return `---\ntitle: ${yamlQuote(title)}\ndescription: ${yamlQuote(post.data.description)}\n---\n\n${config.sourceLabel} [${config.sourceAction}](${canonicalUrl(post.locale, post.slug)}).\n\n${attribution(locale, post)}\n${notice}${coverBlock}\n${body.trim()}\n`;
|
||||
}
|
||||
function articlePage(locale, slug) { return `${locale}/articles/${slug}`; }
|
||||
function renderIndex(locale, posts) {
|
||||
const config = LANGUAGE_CONFIG[locale];
|
||||
const lines = ["---", `title: ${yamlQuote(config.introTitle)}`, `description: ${yamlQuote(config.introDescription)}`, "---", "", config.introDescription, ""];
|
||||
for (const key of ["updates", "engineering", "tutorials", "announcements"]) {
|
||||
const inGroup = posts.filter((post) => post.group === key);
|
||||
if (!inGroup.length) continue;
|
||||
lines.push(`## ${config.groups[key]}`, "");
|
||||
for (const post of inGroup) lines.push(`- [${post.title}](/${articlePage(locale, post.slug)}) — ${post.date}`);
|
||||
lines.push("");
|
||||
}
|
||||
return `${lines.join("\n").trim()}\n`;
|
||||
}
|
||||
function navigationTab(locale, posts) {
|
||||
const config = LANGUAGE_CONFIG[locale];
|
||||
const groups = [{ group: config.overviewGroup, pages: [`${locale}/articles/index`] }];
|
||||
for (const key of ["updates", "engineering", "tutorials", "announcements"]) {
|
||||
const pages = posts.filter((post) => post.group === key).map((post) => articlePage(locale, post.slug));
|
||||
if (pages.length) groups.push({ group: config.groups[key], pages });
|
||||
}
|
||||
return { tab: config.tab, groups };
|
||||
}
|
||||
async function validateGenerated(manifest) {
|
||||
const docs = JSON.parse(await readFile(path.join(ROOT, "docs.json"), "utf8"));
|
||||
const failures = [];
|
||||
const referencedImages = new Set();
|
||||
for (const locale of ARTICLE_LOCALES) {
|
||||
const config = LANGUAGE_CONFIG[locale];
|
||||
const articleRoot = path.join(ROOT, locale, "articles");
|
||||
await assertDirectoryWithoutSymlinks(articleRoot, `${locale} generated article root`);
|
||||
const language = docs.navigation.languages.find((item) => item.language === config.mintlify);
|
||||
const tabs = language?.tabs.filter((item) => item.tab === config.tab) || [];
|
||||
if (tabs.length !== 1) failures.push(`${locale}: expected exactly one ${config.tab} navigation tab, found ${tabs.length}`);
|
||||
for (const article of manifest.articles.filter((item) => item.locale === locale)) {
|
||||
const file = path.join(ROOT, `${article.page}.mdx`);
|
||||
if (!existsSync(file)) failures.push(`missing article: ${article.page}.mdx`);
|
||||
else {
|
||||
const content = await readRegularFileWithin(articleRoot, file, `${locale} generated article`, "utf8");
|
||||
for (const match of content.matchAll(/!\[[^\]]*\]\((\/images\/[^)]+)\)/g)) {
|
||||
referencedImages.add(match[1]);
|
||||
const image = path.join(ROOT, match[1].slice(1));
|
||||
if (!existsSync(image) || (await stat(image)).size === 0) failures.push(`${article.page}: missing image ${match[1]}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const mirroredMetadata = await imageMetadata(ROOT);
|
||||
const mirroredImages = new Set(mirroredMetadata.map((image) => image.path));
|
||||
for (const image of mirroredImages) if (!referencedImages.has(image)) failures.push(`unreferenced mirrored image: ${image}`);
|
||||
for (const image of referencedImages) if (!mirroredImages.has(image)) failures.push(`referenced image is not mirrored: ${image}`);
|
||||
const expectedImages = new Map((manifest.images || []).map((image) => [image.path, image]));
|
||||
for (const image of mirroredMetadata) {
|
||||
const expected = expectedImages.get(image.path);
|
||||
if (!expected) failures.push(`mirrored image missing from manifest: ${image.path}`);
|
||||
else if (expected.sha256 !== image.sha256 || expected.bytes !== image.bytes) failures.push(`mirrored image digest mismatch: ${image.path}`);
|
||||
}
|
||||
for (const image of expectedImages.keys()) if (!mirroredImages.has(image)) failures.push(`manifest image is missing: ${image}`);
|
||||
if (failures.length) throw new Error(`Generated article validation failed:\n${failures.join("\n")}`);
|
||||
}
|
||||
async function listFiles(directory) {
|
||||
if (!existsSync(directory)) return [];
|
||||
const files = [];
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const full = path.join(directory, entry.name);
|
||||
if (entry.isSymbolicLink()) throw new Error(`Generated content tree must not contain symlinks: ${full}`);
|
||||
if (entry.isDirectory()) files.push(...await listFiles(full));
|
||||
else if (entry.isFile()) files.push(full);
|
||||
else throw new Error(`Generated content tree must contain only directories and regular files: ${full}`);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
async function imageMetadata(assetRoot) {
|
||||
const metadata = [];
|
||||
for (const file of await listFiles(path.join(assetRoot, "images/articles"))) {
|
||||
const bytes = await readFile(file);
|
||||
validateImageBytes(bytes, file);
|
||||
metadata.push({
|
||||
path: `/${path.relative(assetRoot, file).split(path.sep).join("/")}`,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: bytes.length,
|
||||
});
|
||||
}
|
||||
return metadata.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
async function referencedImagesIn(directory) {
|
||||
const images = new Set();
|
||||
for (const file of await listFiles(directory)) {
|
||||
if (!file.endsWith(".mdx")) continue;
|
||||
const content = await readFile(file, "utf8");
|
||||
for (const match of content.matchAll(/!\[[^\]]*\]\((\/images\/articles\/[^)]+)\)/g)) images.add(match[1]);
|
||||
}
|
||||
return images;
|
||||
}
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const manifestPath = path.join(ROOT, "scripts/blog-articles-manifest.json");
|
||||
if (args.check && !args.source) {
|
||||
await validateGenerated(JSON.parse(await readFile(manifestPath, "utf8")));
|
||||
console.log("Generated article pages, navigation, and local images are valid.");
|
||||
return;
|
||||
}
|
||||
if (!args.source) throw new Error("Pass --source /path/to/langbot-landing-page or set LANGBOT_LANDING_REPO");
|
||||
if (!existsSync(path.join(args.source, "src/content/blog"))) throw new Error(`Not a LangBot landing repository: ${args.source}`);
|
||||
const { byLocale, slugs } = await loadPosts(args.source);
|
||||
const outputRoot = await mkdtemp(path.join(tmpdir(), "langbot-wiki-article-sync-"));
|
||||
const assetRoot = args.check ? outputRoot : ROOT;
|
||||
const imageCache = new Map();
|
||||
const localePosts = new Map();
|
||||
const manifest = { source: "https://github.com/langbot-app/langbot-landing-page/tree/main/src/content/blog", articles: [] };
|
||||
for (const locale of ARTICLE_LOCALES) {
|
||||
const renderedPosts = [];
|
||||
for (const slug of slugs) {
|
||||
const localized = byLocale.get(locale).get(slug);
|
||||
const post = localized || byLocale.get("en").get(slug);
|
||||
const fallback = !localized;
|
||||
const title = locale === "ja" && fallback ? (JAPANESE_TITLES[slug] || post.data.title) : post.data.title;
|
||||
renderedPosts.push({ slug, title, date: post.data.date, group: classify(byLocale.get("en").get(slug)), fallback });
|
||||
const target = path.join(outputRoot, locale, "articles", `${slug}.mdx`);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, await renderArticle(args.source, post, locale, fallback, assetRoot, imageCache));
|
||||
manifest.articles.push({ locale, slug, page: articlePage(locale, slug), sourceLocale: post.locale, fallback });
|
||||
}
|
||||
renderedPosts.sort((a, b) => b.date.localeCompare(a.date) || a.slug.localeCompare(b.slug));
|
||||
localePosts.set(locale, renderedPosts);
|
||||
await writeFile(path.join(outputRoot, locale, "articles", "index.mdx"), renderIndex(locale, renderedPosts));
|
||||
}
|
||||
if (!args.check) {
|
||||
const referencedImages = await referencedImagesIn(outputRoot);
|
||||
for (const file of await listFiles(path.join(ROOT, "images/articles"))) {
|
||||
const publicPath = `/${path.relative(ROOT, file).split(path.sep).join("/")}`;
|
||||
if (!referencedImages.has(publicPath)) await rm(file, { force: true });
|
||||
}
|
||||
}
|
||||
manifest.images = await imageMetadata(assetRoot);
|
||||
if (args.check) {
|
||||
const mismatches = [];
|
||||
for (const locale of ARTICLE_LOCALES) {
|
||||
const generatedDir = path.join(outputRoot, locale, "articles");
|
||||
const currentDir = path.join(ROOT, locale, "articles");
|
||||
const generatedNames = (await readdir(generatedDir)).sort();
|
||||
const currentEntries = existsSync(currentDir) ? await readdir(currentDir, { withFileTypes: true }) : [];
|
||||
if (currentEntries.some((entry) => !entry.isFile() || entry.isSymbolicLink())) mismatches.push(`${locale}/articles contains non-regular files`);
|
||||
const currentNames = currentEntries.map((entry) => entry.name).sort();
|
||||
if (JSON.stringify(generatedNames) !== JSON.stringify(currentNames)) mismatches.push(`${locale}/articles file set`);
|
||||
for (const name of generatedNames) {
|
||||
const generated = await readFile(path.join(generatedDir, name), "utf8");
|
||||
const currentFile = path.join(currentDir, name);
|
||||
const current = existsSync(currentFile) ? await readRegularFileWithin(currentDir, currentFile, `${locale} generated article`, "utf8") : "";
|
||||
if (generated !== current) mismatches.push(`${locale}/articles/${name}`);
|
||||
}
|
||||
}
|
||||
const currentManifest = existsSync(manifestPath) ? JSON.parse(await readFile(manifestPath, "utf8")) : null;
|
||||
if (JSON.stringify(currentManifest) !== JSON.stringify(manifest)) mismatches.push("scripts/blog-articles-manifest.json");
|
||||
const currentDocs = JSON.parse(await readFile(path.join(ROOT, "docs.json"), "utf8"));
|
||||
for (const locale of ARTICLE_LOCALES) {
|
||||
const config = LANGUAGE_CONFIG[locale];
|
||||
const language = currentDocs.navigation.languages.find((item) => item.language === config.mintlify);
|
||||
const matchingTabs = language?.tabs.filter((item) => item.tab === config.tab) || [];
|
||||
const currentTab = matchingTabs.length === 1 ? matchingTabs[0] : null;
|
||||
const expectedTab = navigationTab(locale, localePosts.get(locale));
|
||||
if (matchingTabs.length !== 1 || JSON.stringify(currentTab) !== JSON.stringify(expectedTab)) mismatches.push(`docs.json ${config.tab} tab`);
|
||||
}
|
||||
await rm(outputRoot, { recursive: true, force: true });
|
||||
if (mismatches.length) throw new Error(`Blog articles are out of sync:\n${mismatches.join("\n")}`);
|
||||
await validateGenerated(JSON.parse(await readFile(manifestPath, "utf8")));
|
||||
console.log(`All ${manifest.articles.length} localized article pages are synchronized.`);
|
||||
return;
|
||||
}
|
||||
for (const locale of ARTICLE_LOCALES) {
|
||||
const targetDir = path.join(ROOT, locale, "articles");
|
||||
await rm(targetDir, { recursive: true, force: true });
|
||||
await mkdir(path.dirname(targetDir), { recursive: true });
|
||||
await cp(path.join(outputRoot, locale, "articles"), targetDir, { recursive: true });
|
||||
}
|
||||
await rm(outputRoot, { recursive: true, force: true });
|
||||
const docsPath = path.join(ROOT, "docs.json");
|
||||
const docs = JSON.parse(await readFile(docsPath, "utf8"));
|
||||
for (const locale of ARTICLE_LOCALES) {
|
||||
const config = LANGUAGE_CONFIG[locale];
|
||||
const language = docs.navigation.languages.find((item) => item.language === config.mintlify);
|
||||
if (!language) throw new Error(`docs.json missing language ${config.mintlify}`);
|
||||
language.tabs = language.tabs.filter((item) => item.tab !== config.tab);
|
||||
const apiIndex = language.tabs.findIndex((item) => /API/.test(item.tab));
|
||||
language.tabs.splice(apiIndex >= 0 ? apiIndex : language.tabs.length, 0, navigationTab(locale, localePosts.get(locale)));
|
||||
}
|
||||
await writeFile(docsPath, `${JSON.stringify(docs, null, 2)}\n`);
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
await validateGenerated(manifest);
|
||||
console.log(`Synchronized ${slugs.length} posts into ${manifest.articles.length} localized Wiki pages.`);
|
||||
}
|
||||
main().catch((error) => { console.error(error.stack || error.message); process.exitCode = 1; });
|
||||
Reference in New Issue
Block a user