mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-01 13:25:18 +08:00
Add 'docs/' from commit '51dddcb3f891d60bc38645e88b2dbd515344da81'
git-subtree-dir: docs git-subtree-mainline:0582d1b3d4git-subtree-split:51dddcb3f8
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,14 @@
|
||||
# Runner tutorial verification
|
||||
|
||||
With the local SDK installed, run:
|
||||
|
||||
```bash
|
||||
python scripts/verify-runner-tutorial.py
|
||||
python scripts/verify-plugin-api-examples.py
|
||||
npm test
|
||||
npm run build
|
||||
```
|
||||
|
||||
The tutorial checker extracts the published Chinese, English and Japanese examples, checks that their code and manifests match, and executes event handlers including disabled replies, empty input, private/group messages and delivery failure. Platform actions use Mock; these tests do not prove external message delivery.
|
||||
|
||||
The official `langbot-plugin-demo/RunnerDemo` plugin adds multi-step actions, configuration isolation and progress/error examples. Its `scripts/smoke.py` installs a built package and runs the scenarios against a local Host. Use the same SDK and Core revision when testing.
|
||||
@@ -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
+265
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a GitHub Wiki-compatible mirror from the Mintlify MDX sources."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path, PurePosixPath
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
LANGUAGES = {"zh": "简体中文", "en": "English", "ja": "日本語"}
|
||||
NAVIGATION_LANGUAGE_CODES = {"cn": "zh", "en": "en", "jp": "ja"}
|
||||
DOC_EXTENSIONS = (".md", ".mdx", ".html")
|
||||
CALLOUTS = {"Info": "NOTE", "Note": "NOTE", "Tip": "TIP", "Warning": "WARNING"}
|
||||
MOVED_SECTIONS = {"platforms", "models", "pipelines", "knowledge", "mcp"}
|
||||
|
||||
|
||||
def page_name(path: PurePosixPath) -> str:
|
||||
return path.with_suffix("").as_posix().replace("/", "-")
|
||||
|
||||
|
||||
def extract_frontmatter(text: str) -> tuple[dict[str, str], str]:
|
||||
if not text.startswith("---\n"):
|
||||
return {}, text
|
||||
end = text.find("\n---\n", 4)
|
||||
if end == -1:
|
||||
return {}, text
|
||||
metadata: dict[str, str] = {}
|
||||
for line in text[4:end].splitlines():
|
||||
key, separator, value = line.partition(":")
|
||||
if separator:
|
||||
metadata[key.strip()] = value.strip().strip('"\'')
|
||||
return metadata, text[end + 5 :]
|
||||
|
||||
|
||||
def resolve_doc_target(source: PurePosixPath, target: str) -> str | None:
|
||||
parsed = urlsplit(target)
|
||||
if parsed.scheme or parsed.netloc or target.startswith("#"):
|
||||
return None
|
||||
raw_path = parsed.path
|
||||
if not raw_path:
|
||||
return None
|
||||
resolved = PurePosixPath(raw_path.lstrip("/")) if raw_path.startswith("/") else source.parent / raw_path
|
||||
parts: list[str] = []
|
||||
for part in resolved.parts:
|
||||
if part in ("", "."):
|
||||
continue
|
||||
if part == "..":
|
||||
if parts:
|
||||
parts.pop()
|
||||
else:
|
||||
parts.append(part)
|
||||
if not parts or parts[0] not in LANGUAGES:
|
||||
return None
|
||||
normalized = PurePosixPath(*parts)
|
||||
suffix = normalized.suffix.lower()
|
||||
if suffix in DOC_EXTENSIONS:
|
||||
normalized = normalized.with_suffix("")
|
||||
elif suffix:
|
||||
return None
|
||||
# Mintlify redirects these sections from their old deploy location. Apply the
|
||||
# same redirect in the flat Wiki mirror so historical source links still work.
|
||||
if len(normalized.parts) >= 3 and normalized.parts[1] == "deploy" and normalized.parts[2] in MOVED_SECTIONS:
|
||||
normalized = PurePosixPath(normalized.parts[0], "usage", *normalized.parts[2:])
|
||||
return urlunsplit(("", "", page_name(normalized), parsed.query, parsed.fragment))
|
||||
|
||||
|
||||
def rewrite_url(source: PurePosixPath, target: str, *, image: bool = False) -> str:
|
||||
parsed = urlsplit(target)
|
||||
if parsed.scheme or parsed.netloc or target.startswith(("#", "mailto:")):
|
||||
return target
|
||||
if image:
|
||||
path = parsed.path.lstrip("/") if parsed.path.startswith("/") else (source.parent / parsed.path).as_posix()
|
||||
return urlunsplit(("https", "raw.githubusercontent.com", f"/langbot-app/langbot-docs/main/{path}", parsed.query, parsed.fragment))
|
||||
return resolve_doc_target(source, target) or target
|
||||
|
||||
|
||||
def convert_callouts(text: str) -> str:
|
||||
pattern = re.compile(
|
||||
r"<(Info|Note|Tip|Warning)(?:\s+title=(?:\"([^\"]*)\"|'([^']*)'))?\s*>(.*?)</\1>",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
component = match.group(1)
|
||||
title = match.group(2) or match.group(3) or ""
|
||||
body = match.group(4).strip()
|
||||
lines = [f"> [!{CALLOUTS[component]}]"]
|
||||
if title:
|
||||
lines.append(f"> **{title}**")
|
||||
lines.extend(">" if not line else f"> {line}" for line in body.splitlines())
|
||||
return "\n".join(lines)
|
||||
|
||||
previous = None
|
||||
while previous != text:
|
||||
previous = text
|
||||
text = pattern.sub(replace, text)
|
||||
return text
|
||||
|
||||
|
||||
def convert_mdx(source: PurePosixPath, text: str) -> tuple[str, str]:
|
||||
metadata, body = extract_frontmatter(text)
|
||||
title = metadata.get("title") or source.stem.replace("-", " ").title()
|
||||
body = convert_callouts(body)
|
||||
body = re.sub(
|
||||
r'<Accordion\s+title=(?:"([^"]*)"|\'([^\']*)\')\s*>',
|
||||
lambda m: f"\n<details>\n<summary><strong>{m.group(1) or m.group(2)}</strong></summary>\n",
|
||||
body,
|
||||
)
|
||||
body = body.replace("</Accordion>", "\n</details>")
|
||||
body = re.sub(r"</?AccordionGroup\s*>", "", body)
|
||||
body = re.sub(
|
||||
r"(!\[[^\]]*\]\()([^)\s]+)([^)]*\))",
|
||||
lambda m: m.group(1) + rewrite_url(source, m.group(2), image=True) + m.group(3),
|
||||
body,
|
||||
)
|
||||
body = re.sub(
|
||||
r"(?<!!)\[([^\]]+)\]\(([^)\s]+)([^)]*)\)",
|
||||
lambda m: f"[{m.group(1)}]({rewrite_url(source, m.group(2))}{m.group(3)})",
|
||||
body,
|
||||
)
|
||||
body = re.sub(
|
||||
r'(<img\b[^>]*?\bsrc=["\'])([^"\']+)(["\'])',
|
||||
lambda m: m.group(1) + rewrite_url(source, m.group(2), image=True) + m.group(3),
|
||||
body,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
body = body.replace("<br />", "<br>")
|
||||
body = re.sub(r"\n{3,}", "\n\n", body).strip()
|
||||
return title, f"# {title}\n\n{body}\n"
|
||||
|
||||
|
||||
def discover_documents(root: Path) -> list[Path]:
|
||||
return sorted(path for language in LANGUAGES for path in (root / language).rglob("*.mdx"))
|
||||
|
||||
|
||||
def render_page_items(
|
||||
items: list[object], titles: dict[str, str], *, indent: int = 0
|
||||
) -> tuple[list[str], set[str]]:
|
||||
lines: list[str] = []
|
||||
included: set[str] = set()
|
||||
prefix = " " * indent
|
||||
for item in items:
|
||||
if isinstance(item, str):
|
||||
source_name = item.removesuffix(".mdx").removesuffix(".md")
|
||||
title = titles.get(source_name)
|
||||
if title:
|
||||
lines.append(f"{prefix}- [{title}]({page_name(PurePosixPath(source_name))})")
|
||||
included.add(source_name)
|
||||
elif isinstance(item, dict) and not item.get("hidden"):
|
||||
group = item.get("group")
|
||||
children = item.get("pages", [])
|
||||
if group and isinstance(children, list):
|
||||
child_lines, child_pages = render_page_items(children, titles, indent=indent + 2)
|
||||
if child_lines:
|
||||
lines.append(f"{prefix}- **{group}**")
|
||||
lines.extend(child_lines)
|
||||
included.update(child_pages)
|
||||
return lines, included
|
||||
|
||||
|
||||
def build_sidebar(root: Path, titles: dict[str, str]) -> str:
|
||||
navigation_path = root / "docs.json"
|
||||
if not navigation_path.is_file():
|
||||
lines = ["## LangBot Documentation", ""]
|
||||
for language, label in LANGUAGES.items():
|
||||
lines.extend((f"### {label}", ""))
|
||||
lines.extend(
|
||||
f"- [{title}]({page_name(PurePosixPath(source))})"
|
||||
for source, title in titles.items()
|
||||
if source.startswith(f"{language}/")
|
||||
)
|
||||
lines.append("")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
navigation = json.loads(navigation_path.read_text(encoding="utf-8"))["navigation"]["languages"]
|
||||
nav_by_language = {
|
||||
NAVIGATION_LANGUAGE_CODES[item["language"]]: item
|
||||
for item in navigation
|
||||
if item.get("language") in NAVIGATION_LANGUAGE_CODES
|
||||
}
|
||||
lines = ["## LangBot Documentation", "", "[Home](Home)", ""]
|
||||
for language, label in LANGUAGES.items():
|
||||
language_nav = nav_by_language.get(language, {})
|
||||
open_attribute = " open" if language == "zh" else ""
|
||||
lines.extend((f"<details{open_attribute}>", f"<summary><strong>{label}</strong></summary>", ""))
|
||||
included: set[str] = set()
|
||||
for tab_index, tab in enumerate(language_nav.get("tabs", [])):
|
||||
tab_lines: list[str] = []
|
||||
tab_pages: set[str] = set()
|
||||
for group in tab.get("groups", []):
|
||||
group_lines, group_pages = render_page_items(group.get("pages", []), titles, indent=2)
|
||||
if group_lines:
|
||||
tab_lines.append(f"- **{group['group']}**")
|
||||
tab_lines.extend(group_lines)
|
||||
tab_pages.update(group_pages)
|
||||
if tab_lines:
|
||||
tab_open = " open" if language == "zh" and tab_index == 0 else ""
|
||||
lines.extend((f"<details{tab_open}>", f"<summary>{tab['tab']}</summary>", ""))
|
||||
lines.extend(tab_lines)
|
||||
lines.extend(("", "</details>", ""))
|
||||
included.update(tab_pages)
|
||||
|
||||
unlisted = sorted(
|
||||
(source, title)
|
||||
for source, title in titles.items()
|
||||
if source.startswith(f"{language}/") and source not in included
|
||||
)
|
||||
if unlisted:
|
||||
lines.extend(("<details>", "<summary>Other pages</summary>", ""))
|
||||
lines.extend(f"- [{title}]({page_name(PurePosixPath(source))})" for source, title in unlisted)
|
||||
lines.extend(("", "</details>", ""))
|
||||
lines.extend(("</details>", ""))
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def build(root: Path, output: Path) -> int:
|
||||
documents = discover_documents(root)
|
||||
if not documents:
|
||||
raise SystemExit("No MDX documents found")
|
||||
if output.exists():
|
||||
shutil.rmtree(output)
|
||||
output.mkdir(parents=True)
|
||||
index: dict[str, list[tuple[str, str]]] = {language: [] for language in LANGUAGES}
|
||||
titles: dict[str, str] = {}
|
||||
for path in documents:
|
||||
source = PurePosixPath(path.relative_to(root).as_posix())
|
||||
title, converted = convert_mdx(source, path.read_text(encoding="utf-8"))
|
||||
name = page_name(source)
|
||||
(output / f"{name}.md").write_text(converted, encoding="utf-8")
|
||||
index[source.parts[0]].append((name, title))
|
||||
titles[source.with_suffix("").as_posix()] = title
|
||||
home = [
|
||||
"# LangBot Documentation",
|
||||
"",
|
||||
"This GitHub Wiki is automatically synchronized from [langbot-app/langbot-docs](https://github.com/langbot-app/langbot-docs).",
|
||||
"",
|
||||
]
|
||||
for language, label in LANGUAGES.items():
|
||||
pages = index[language]
|
||||
guide_name = f"{language}-insight-guide"
|
||||
guide = next(((name, title) for name, title in pages if name == guide_name), pages[0])
|
||||
home.extend((f"## {label}", "", f"[{guide[1]}]({guide[0]})", ""))
|
||||
(output / "Home.md").write_text("\n".join(home).rstrip() + "\n", encoding="utf-8")
|
||||
(output / "_Sidebar.md").write_text(build_sidebar(root, titles), encoding="utf-8")
|
||||
(output / "_Footer.md").write_text(
|
||||
"Automatically synchronized from [langbot-app/langbot-docs](https://github.com/langbot-app/langbot-docs).\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return len(documents)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
|
||||
parser.add_argument("--output", type=Path, default=Path("dist/github-wiki"))
|
||||
args = parser.parse_args()
|
||||
count = build(args.root.resolve(), args.output.resolve())
|
||||
print(f"Built {count} GitHub Wiki pages in {args.output}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
export function deploymentGuidance() { return "## Canonical agent entry\n\nRead https://langbot.app/llms.txt first for deployment choices, optional managed hosting, CLI installation and operation, and end-to-end verification. This documentation index supplies the detailed guides referenced by that workflow.\n"; }
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const PUBLIC_ROOT = path.join(ROOT, "dist/public");
|
||||
const ORIGIN = "https://langbot.app";
|
||||
const BASE_PATH = "/docs";
|
||||
const TEXT_EXTENSIONS = new Set([".html", ".md", ".txt", ".xml", ".json", ".js", ".css"]);
|
||||
|
||||
async function collectTextFiles(directory) {
|
||||
const files = [];
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const target = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) files.push(...await collectTextFiles(target));
|
||||
else if (TEXT_EXTENSIONS.has(path.extname(entry.name)) || target.endsWith("/api/search")) files.push(target);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function prefixPublicAssets(content) {
|
||||
return content
|
||||
.replaceAll('"/images/', `"${BASE_PATH}/images/`)
|
||||
.replaceAll("'/images/", `'${BASE_PATH}/images/`)
|
||||
.replaceAll('"/favicon.ico"', `"${BASE_PATH}/favicon.ico"`)
|
||||
.replaceAll("'/favicon.ico'", `'${BASE_PATH}/favicon.ico'`)
|
||||
.replaceAll('"/langbot-logo.png"', `"${BASE_PATH}/langbot-logo.png"`)
|
||||
.replaceAll("'/langbot-logo.png'", `'${BASE_PATH}/langbot-logo.png'`)
|
||||
.replaceAll('"/rss.xml"', `"${BASE_PATH}/rss.xml"`)
|
||||
.replaceAll("'/rss.xml'", `'${BASE_PATH}/rss.xml'`);
|
||||
}
|
||||
|
||||
export async function finalizeBasePath(publicRoot = PUBLIC_ROOT) {
|
||||
const sitemapPath = path.join(publicRoot, "sitemap.xml");
|
||||
const originalSitemap = await readFile(sitemapPath, "utf8");
|
||||
const sourceUrls = [...originalSitemap.matchAll(/<loc>(https:\/\/langbot\.app\/[^<]+)<\/loc>/g)]
|
||||
.map((match) => match[1].replace(/\/$/, ""));
|
||||
const replacements = sourceUrls
|
||||
.map((source) => [source, `${ORIGIN}${BASE_PATH}${source.slice(ORIGIN.length)}`])
|
||||
.sort((left, right) => right[0].length - left[0].length);
|
||||
|
||||
let changed = 0;
|
||||
for (const file of await collectTextFiles(publicRoot)) {
|
||||
let content = await readFile(file, "utf8");
|
||||
const before = content;
|
||||
for (const [source, destination] of replacements) content = content.replaceAll(source, destination);
|
||||
content = content
|
||||
.replaceAll(`${ORIGIN}/sitemap.xml`, `${ORIGIN}${BASE_PATH}/sitemap.xml`)
|
||||
.replaceAll(`${ORIGIN}/sitemap-alternates.xml`, `${ORIGIN}${BASE_PATH}/sitemap-alternates.xml`);
|
||||
content = prefixPublicAssets(content);
|
||||
if (content !== before) {
|
||||
await writeFile(file, content);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const redirectsPath = path.join(publicRoot, "_redirects");
|
||||
const redirects = await readFile(redirectsPath, "utf8");
|
||||
const scopedRedirects = redirects
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (!line.trim()) return line;
|
||||
const [source, destination, status, ...rest] = line.trim().split(/\s+/);
|
||||
const scope = (value) => value.startsWith("/") ? `${BASE_PATH}${value === "/" ? "" : value}` : value;
|
||||
return [scope(source), scope(destination), status, ...rest].filter(Boolean).join(" ");
|
||||
})
|
||||
.join("\n");
|
||||
await writeFile(redirectsPath, scopedRedirects);
|
||||
return { changed, routes: replacements.length };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const result = await finalizeBasePath();
|
||||
console.log(`Finalized ${result.routes} documentation routes under ${BASE_PATH} across ${result.changed} files`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env node
|
||||
// Agent exports use canonical sources, not renderer-processed MDX: the latter
|
||||
// replaces images with JS variables and returns no text for virtual API pages.
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { unified } from "unified";
|
||||
import remarkParse from "remark-parse";
|
||||
import remarkMdx from "remark-mdx";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkStringify from "remark-stringify";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { collectMdxDocuments, normalizeMdxContent } from "./prepare-fumapress.mjs";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dirname, "..");
|
||||
export const BASE = "https://langbot.app/docs";
|
||||
export const LOCALES = ["en", "zh", "ja"];
|
||||
const METHODS = /^(get|put|post|delete|patch|options|head|trace)$/;
|
||||
const encode = (value) => value.split("/").map(encodeURIComponent).join("/");
|
||||
const slug = (value) => value.replace(/\s+/g, "-").toLowerCase();
|
||||
const pageRoute = (file) => file.replace(/\.mdx$/, "").replace(/\/index$/, "");
|
||||
const text = (value) => ({ type: "text", value });
|
||||
const paragraph = (children) => ({ type: "paragraph", children });
|
||||
const parser = unified().use(remarkParse).use(remarkMdx).use(remarkGfm);
|
||||
const printer = unified().use(remarkStringify, { fences: true, bullet: "-" }).use(remarkGfm);
|
||||
|
||||
export function splitFrontmatter(source) {
|
||||
const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
||||
if (!match) throw new Error("Documentation source requires frontmatter");
|
||||
return { metadata: parseYaml(match[1]), body: source.slice(match[0].length) };
|
||||
}
|
||||
|
||||
export function canonicalLink(value, document, routes, redirects = [], seen = new Set()) {
|
||||
if (!value || value.startsWith("#")) return value;
|
||||
let local = value;
|
||||
const absolute = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(value);
|
||||
if (absolute) {
|
||||
// Example URLs can contain prose punctuation; only parse our docs hosts.
|
||||
if (!/^(?:https?:)?\/\/(?:docs\.langbot\.app|langbot\.app)(?:\/|$)/i.test(value)) return value;
|
||||
const url = new URL(value, BASE);
|
||||
if (url.hostname === "docs.langbot.app") local = `${url.pathname}${url.search}${url.hash}`;
|
||||
else if (url.origin === "https://langbot.app" && (url.pathname === "/docs" || url.pathname.startsWith("/docs/"))) {
|
||||
local = `${url.pathname.slice(5) || "/"}${url.search}${url.hash}`;
|
||||
} else return value; // Marketing, API hosts, mailto and other external links.
|
||||
}
|
||||
local = local.replace(/^\/docs(?=\/|$)/, "");
|
||||
const suffixAt = local.search(/[?#]/);
|
||||
const suffix = suffixAt < 0 ? "" : local.slice(suffixAt);
|
||||
let pathname = decodeURIComponent(suffixAt < 0 ? local : local.slice(0, suffixAt));
|
||||
pathname = pathname.startsWith("/") ? pathname.slice(1) : path.posix.join(path.posix.dirname(document), pathname);
|
||||
pathname = path.posix.normalize(pathname).replace(/^\.\//, "").replace(/\/$/, "");
|
||||
if (pathname === "." || pathname === "" || LOCALES.includes(pathname)) pathname = `${LOCALES.includes(pathname) ? pathname : document.split("/")[0]}/insight/guide`;
|
||||
const candidate = pathname.replace(/\.(?:html|mdx|md)$/, "").replace(/\/index$/, "");
|
||||
if (routes.has(candidate)) return `${BASE}/${encode(candidate)}.md${suffix}`;
|
||||
// Honor the same legacy aliases as the human site, without inventing
|
||||
// locale fallbacks for documents that have no translation.
|
||||
for (const redirect of redirects) {
|
||||
const wildcard = redirect.source.match(/^(.*\/):([A-Za-z][A-Za-z0-9_]*)\*$/);
|
||||
const matches = redirect.source === `/${candidate}` || (wildcard && `/${candidate}`.startsWith(wildcard[1]));
|
||||
if (matches && redirect.destination !== redirect.source) {
|
||||
if (seen.has(candidate)) throw new Error(`Redirect cycle: ${candidate}`);
|
||||
seen.add(candidate);
|
||||
const destination = wildcard
|
||||
? redirect.destination.replace(`:${wildcard[2]}*`, `/${candidate}`.slice(wildcard[1].length))
|
||||
: redirect.destination;
|
||||
return canonicalLink(`${destination}${suffix}`, document, routes, redirects, seen);
|
||||
}
|
||||
}
|
||||
return `${BASE}/${encode(pathname)}${suffix}`;
|
||||
}
|
||||
|
||||
export function renderMdx(source, document, routes, redirects = []) {
|
||||
const { metadata, body } = splitFrontmatter(source);
|
||||
const tree = parser.parse(normalizeMdxContent(body, document));
|
||||
const link = (value) => canonicalLink(value, document, routes, redirects);
|
||||
function transform(node) {
|
||||
if (["mdxjsEsm", "mdxFlowExpression", "mdxTextExpression"].includes(node.type)) {
|
||||
if (/^\s*\/\*[\s\S]*\*\/\s*$/.test(node.value)) return [];
|
||||
// Never silently drop an executable source value that might contain docs.
|
||||
throw new Error(`${document}: unsupported ${node.type}: ${node.value}`);
|
||||
}
|
||||
if (node.children) node.children = node.children.flatMap(transform);
|
||||
if (["link", "image", "definition"].includes(node.type)) node.url = link(node.url);
|
||||
if (!node.type.startsWith("mdxJsx")) return [node];
|
||||
const attributes = Object.fromEntries((node.attributes ?? [])
|
||||
.filter((attribute) => attribute.type === "mdxJsxAttribute")
|
||||
.map((attribute) => [attribute.name, attribute.value]));
|
||||
const children = node.children ?? [];
|
||||
if (node.name === "img") {
|
||||
if (typeof attributes.src !== "string") throw new Error(`${document}: image requires a literal src`);
|
||||
const image = { type: "image", url: link(attributes.src), alt: attributes.alt ?? "", title: attributes.title ?? null };
|
||||
return [node.type === "mdxJsxFlowElement" ? paragraph([image]) : image];
|
||||
}
|
||||
if (node.name === "a" && typeof attributes.href === "string") {
|
||||
const anchor = { type: "link", url: link(attributes.href), children };
|
||||
return [node.type === "mdxJsxFlowElement" ? paragraph([anchor]) : anchor];
|
||||
}
|
||||
if (node.name === "br") return [node.type === "mdxJsxFlowElement" ? paragraph([text("")]) : { type: "break" }];
|
||||
if (node.name === "summary") return [paragraph([{ type: "strong", children }])];
|
||||
if (node.name === "Callout") {
|
||||
return [{ type: "blockquote", children: [paragraph([text(`${attributes.title ?? attributes.type ?? "Note"}:`)]), ...children] }];
|
||||
}
|
||||
if (["Card", "Step", "Tab"].includes(node.name)) {
|
||||
if (typeof attributes.title !== "string") throw new Error(`${document}: ${node.name} requires a literal title`);
|
||||
const title = typeof attributes.href === "string"
|
||||
? { type: "link", url: link(attributes.href), children: [text(attributes.title)] }
|
||||
: { type: "strong", children: [text(attributes.title)] };
|
||||
return [paragraph([title]), ...children];
|
||||
}
|
||||
// Audited source wrappers; fail closed when new semantic components appear.
|
||||
if (["div", "details", "span", "Steps", "Tabs", "CardGroup"].includes(node.name)) return children;
|
||||
throw new Error(`${document}: unsupported JSX component ${node.name}`);
|
||||
}
|
||||
tree.children = tree.children.flatMap(transform);
|
||||
const title = metadata.title ?? pageRoute(document);
|
||||
const url = `${BASE}/${encode(pageRoute(document))}`;
|
||||
const description = metadata.description ? `${metadata.description}\n\n` : "";
|
||||
return { title, description: metadata.description ?? "", url, markdown: `# ${title}\n\nSource: ${url}\n\n${description}${printer.stringify(tree)}` };
|
||||
}
|
||||
|
||||
export function operationBundle(spec, apiPath, method) {
|
||||
const item = spec.paths[apiPath];
|
||||
const operation = item[method];
|
||||
const common = Object.fromEntries(Object.entries(item).filter(([key]) => !METHODS.test(key)));
|
||||
const bundle = {
|
||||
openapi: spec.openapi,
|
||||
info: spec.info,
|
||||
servers: operation.servers ?? item.servers ?? spec.servers ?? [],
|
||||
security: operation.security ?? spec.security ?? [],
|
||||
paths: { [apiPath]: { ...common, [method]: operation } },
|
||||
components: {},
|
||||
};
|
||||
const visited = new Set();
|
||||
function include(ref) {
|
||||
if (visited.has(ref)) return;
|
||||
visited.add(ref);
|
||||
if (!ref.startsWith("#/components/")) throw new Error(`Unsupported OpenAPI reference: ${ref}`);
|
||||
const keys = ref.slice(2).split("/").map((key) => key.replaceAll("~1", "/").replaceAll("~0", "~"));
|
||||
const value = keys.reduce((node, key) => node?.[key], spec);
|
||||
if (value === undefined) throw new Error(`Missing OpenAPI reference: ${ref}`);
|
||||
let parent = bundle;
|
||||
for (const key of keys.slice(0, -1)) parent = parent[key] ??= {};
|
||||
parent[keys.at(-1)] = value;
|
||||
visit(value);
|
||||
}
|
||||
function visit(value) {
|
||||
if (!value || typeof value !== "object") return;
|
||||
if (value.$ref) include(value.$ref);
|
||||
for (const child of Object.values(value)) visit(child);
|
||||
}
|
||||
visit(bundle);
|
||||
for (const alternative of bundle.security) for (const name of Object.keys(alternative)) {
|
||||
include(`#/components/securitySchemes/${name.replaceAll("~", "~0").replaceAll("/", "~1")}`);
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
||||
export function renderOperation(spec, apiPath, method, locale) {
|
||||
const operation = spec.paths[apiPath][method];
|
||||
if (!operation.summary) throw new Error(`Missing summary for ${method} ${apiPath}`);
|
||||
const route = `${locale}/api-reference/${slug(operation.tags?.[0] ?? "unknown")}/${slug(operation.summary)}`;
|
||||
const url = `${BASE}/${encode(route)}`;
|
||||
const bundle = operationBundle(spec, apiPath, method);
|
||||
const auth = bundle.security.length === 0 ? "No authentication required." :
|
||||
`Authentication (OpenAPI security alternatives; OR between entries, AND within each entry): ${JSON.stringify(bundle.security)}. See components.securitySchemes below for header names, schemes and scopes.`;
|
||||
const markdown = `# ${operation.summary}\n\nSource: ${url}\n\n## ${method.toUpperCase()} ${apiPath}\n\n${operation.description ?? ""}\n\n${auth}\n\nServers: ${bundle.servers.map((server) => server.url).join(", ") || "Relative to your LangBot instance"}. Use your own LangBot instance, not the documentation host.\n\n[Complete ${locale} OpenAPI specification](${BASE}/openapi/service-api-${locale}.json)\n\n## Operation contract\n\nThe OpenAPI subset below preserves path/operation parameters, request bodies, response codes, media types, examples and schemas. All referenced components are included transitively; resolve local JSON pointers against this document. The operation is preserved verbatim, including security overrides.\n\n\`\`\`json\n${JSON.stringify(bundle, null, 2)}\n\`\`\`\n`;
|
||||
return { route, title: operation.summary, description: `${method.toUpperCase()} ${apiPath}`, url, markdown };
|
||||
}
|
||||
|
||||
import { deploymentGuidance } from "./deployment-guidance.mjs";
|
||||
|
||||
function indexText(pages, locale) {
|
||||
const languages = locale ? [locale] : LOCALES;
|
||||
const prefix = locale ? `${locale}/` : "";
|
||||
const lines = ["# LangBot Documentation", "", "> Canonical documentation for LangBot: deployment, messaging integrations, plugins, development and HTTP API reference.", "", "## Agent resources", "", `- [Complete documentation text](${BASE}/${prefix}llms-full.txt)`, `- [All-language index](${BASE}/llms.txt)`, ""];
|
||||
lines.push(deploymentGuidance(locale), "");
|
||||
for (const language of languages) {
|
||||
lines.push(`## ${language}`, "", `- [${language} documentation index](${BASE}/${language}/llms.txt)`, `- [${language} complete documentation text](${BASE}/${language}/llms-full.txt)`, `- [${language} OpenAPI specification](${BASE}/openapi/service-api-${language}.json)`, "");
|
||||
for (const page of pages.filter((page) => page.route.startsWith(`${language}/`))) {
|
||||
const label = page.title.replace(/[\[\]\\]/g, "\\$&").replace(/\s+/g, " ");
|
||||
lines.push(`- [${label}](${page.url}.md)${page.description ? `: ${page.description.replace(/\s+/g, " ")}` : ""}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
export async function generateAgentDocs({ root = ROOT, output = path.join(root, "dist/public") } = {}) {
|
||||
const documents = await collectMdxDocuments(root);
|
||||
const docs = JSON.parse(await readFile(path.join(root, "docs.json"), "utf8"));
|
||||
const pages = [];
|
||||
for (const locale of LOCALES) {
|
||||
const spec = JSON.parse(await readFile(path.join(root, `openapi/service-api-${locale}.json`), "utf8"));
|
||||
for (const [apiPath, item] of Object.entries(spec.paths)) for (const method of Object.keys(item).filter((key) => METHODS.test(key))) {
|
||||
pages.push(renderOperation(spec, apiPath, method, locale));
|
||||
}
|
||||
}
|
||||
const routes = new Set([...documents.map(pageRoute), ...pages.map((page) => page.route)]);
|
||||
for (const document of documents) {
|
||||
pages.push({ route: pageRoute(document), ...renderMdx(await readFile(path.join(root, document), "utf8"), document, routes, docs.redirects) });
|
||||
}
|
||||
pages.sort((a, b) => a.route.localeCompare(b.route, "en"));
|
||||
if (new Set(pages.map((page) => page.route)).size !== pages.length) throw new Error("Duplicate agent page routes");
|
||||
// Use the real static sitemap as the acceptance boundary, not a guessed count.
|
||||
const sitemap = await readFile(path.join(output, "sitemap.xml"), "utf8");
|
||||
const actual = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => decodeURIComponent(match[1].replace(/\/$/, ""))).sort();
|
||||
const expected = pages.map((page) => decodeURIComponent(page.url)).sort();
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error("Agent sources do not match the exported sitemap");
|
||||
for (const page of pages) {
|
||||
const target = path.join(output, `${page.route}.md`);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, page.markdown);
|
||||
}
|
||||
for (const locale of [null, ...LOCALES]) {
|
||||
const prefix = locale ? `${locale}/` : "";
|
||||
const selected = pages.filter((page) => !locale || page.route.startsWith(prefix));
|
||||
const index = indexText(selected, locale);
|
||||
await writeFile(path.join(output, `${prefix}llms.txt`), index);
|
||||
await writeFile(path.join(output, `${prefix}llm.txt`), index);
|
||||
await writeFile(path.join(output, `${prefix}llms-full.txt`), `${deploymentGuidance(locale)}\n${selected.map((page) => page.markdown.trim()).join("\n\n---\n\n")}\n`);
|
||||
}
|
||||
return { pages: pages.length, documents: documents.length, operations: pages.length - documents.length };
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
console.log(await generateAgentDocs());
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const BASE = "https://langbot.app/docs";
|
||||
const LOCALES = ["en", "zh", "ja"];
|
||||
const HREFLANG = { en: "en", zh: "zh-CN", ja: "ja" };
|
||||
|
||||
function escapeXml(value) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function decodeXml(value) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll(""", '"')
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
export function renderAlternateSitemap(sitemap, base = BASE, explicitGroups = []) {
|
||||
const urls = [...sitemap.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => decodeXml(match[1]));
|
||||
const groups = new Map();
|
||||
for (const url of urls) {
|
||||
for (const locale of LOCALES) {
|
||||
const prefix = `${base}/${locale}/`;
|
||||
if (!url.startsWith(prefix)) continue;
|
||||
const suffix = url.slice(prefix.length).replace(/\/$/, "");
|
||||
const group = groups.get(suffix) ?? {};
|
||||
group[locale] = url.replace(/\/$/, "");
|
||||
groups.set(suffix, group);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of explicitGroups) groups.set(`explicit:${group.en}`, group);
|
||||
|
||||
const shared = [...groups.values()]
|
||||
.filter((group) => LOCALES.every((locale) => group[locale]))
|
||||
.sort((left, right) => left.en.localeCompare(right.en));
|
||||
const lines = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">',
|
||||
];
|
||||
for (const group of shared) {
|
||||
const alternates = [
|
||||
...LOCALES.map((locale) => [HREFLANG[locale], group[locale]]),
|
||||
["x-default", group.en],
|
||||
];
|
||||
for (const locale of LOCALES) {
|
||||
lines.push(" <url>", ` <loc>${escapeXml(group[locale])}</loc>`);
|
||||
for (const [language, href] of alternates) {
|
||||
lines.push(` <xhtml:link rel="alternate" hreflang="${language}" href="${escapeXml(href)}" />`);
|
||||
}
|
||||
lines.push(" </url>");
|
||||
}
|
||||
}
|
||||
lines.push("</urlset>");
|
||||
return { xml: `${lines.join("\n")}\n`, groups: shared.length };
|
||||
}
|
||||
|
||||
function legacyOpenApiSlug(value) {
|
||||
return value.replace(/\s+/g, "-").toLowerCase();
|
||||
}
|
||||
|
||||
function encodeRoute(route) {
|
||||
return route.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
||||
}
|
||||
|
||||
async function collectOpenApiAlternateGroups(root = ROOT, base = BASE) {
|
||||
const groups = new Map();
|
||||
for (const locale of LOCALES) {
|
||||
const spec = JSON.parse(await readFile(path.join(root, `openapi/service-api-${locale}.json`), "utf8"));
|
||||
for (const [apiPath, pathItem] of Object.entries(spec.paths ?? {})) {
|
||||
for (const [method, operation] of Object.entries(pathItem)) {
|
||||
if (!/^(?:get|post|put|patch|delete|head|options)$/i.test(method) || !operation?.summary) continue;
|
||||
const tag = operation.tags?.[0] ?? "unknown";
|
||||
const route = encodeRoute(`api-reference/${legacyOpenApiSlug(tag)}/${legacyOpenApiSlug(operation.summary)}`);
|
||||
const key = `${method.toLowerCase()} ${apiPath}`;
|
||||
const group = groups.get(key) ?? {};
|
||||
group[locale] = `${base}/${locale}/${route}`;
|
||||
groups.set(key, group);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
async function loadSource(source) {
|
||||
if (/^https?:\/\//.test(source)) {
|
||||
const response = await fetch(source, { headers: { "user-agent": "LangBot docs alternate-sitemap generator" } });
|
||||
if (!response.ok) throw new Error(`Unable to fetch ${source}: ${response.status}`);
|
||||
return response.text();
|
||||
}
|
||||
return readFile(source, "utf8");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const source = process.env.SITEMAP_SOURCE ?? path.join(ROOT, "dist/public/sitemap.xml");
|
||||
const openApiGroups = await collectOpenApiAlternateGroups();
|
||||
const { xml, groups } = renderAlternateSitemap(await loadSource(source), BASE, openApiGroups);
|
||||
const outputs = process.env.SITEMAP_OUTPUT
|
||||
? [path.resolve(process.env.SITEMAP_OUTPUT)]
|
||||
: [path.join(ROOT, "dist/public/sitemap-alternates.xml"), path.join(ROOT, "sitemap-alternates.xml")];
|
||||
for (const output of outputs) await writeFile(output, xml);
|
||||
console.log(`Wrote ${groups} reciprocal language groups to ${outputs.join(", ")}`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "..");
|
||||
export async function generateLocaleEntry(publicRoot = path.join(root, "dist/public")) {
|
||||
const helper = await readFile(path.join(root, "src/locale-preference.mjs"), "utf8");
|
||||
// Inline a classic script: negotiation runs before rendering or module fetches.
|
||||
const script = helper.replace(/^export /gm, "") + "\nenterDocs();";
|
||||
const html = `<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>LangBot Docs</title><script>${script}</script></head>
|
||||
<body><main><h1>LangBot Docs</h1><noscript><p>Please choose a language / 请选择语言 / 言語を選択してください</p></noscript>
|
||||
<ul><li><a lang="en" href="/docs/en/insight/guide">English</a></li><li><a lang="zh" href="/docs/zh/insight/guide">简体中文</a></li><li><a lang="ja" href="/docs/ja/insight/guide">日本語</a></li></ul>
|
||||
</main></body></html>
|
||||
`;
|
||||
await writeFile(path.join(publicRoot, "index.html"), html);
|
||||
return html;
|
||||
}
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await generateLocaleEntry();
|
||||
console.log("Generated browser-negotiated /docs/ entry (langbot-docs-locale)");
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { access, readdir, rename } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
function decodePathSegment(segment) {
|
||||
const decoded = decodeURIComponent(segment);
|
||||
if (decoded.includes("/") || decoded.includes("\\") || decoded.includes("\0")) {
|
||||
throw new Error(`Refusing to decode unsafe output path segment: ${segment}`);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
async function exists(target) {
|
||||
try {
|
||||
await access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function normalizeStaticPaths(directory) {
|
||||
let renamed = 0;
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const source = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) renamed += await normalizeStaticPaths(source);
|
||||
|
||||
const decodedName = decodePathSegment(entry.name);
|
||||
if (decodedName === entry.name) continue;
|
||||
|
||||
const destination = path.join(directory, decodedName);
|
||||
if (await exists(destination)) {
|
||||
throw new Error(`Static output path collision: ${source} -> ${destination}`);
|
||||
}
|
||||
await rename(source, destination);
|
||||
renamed += 1;
|
||||
}
|
||||
return renamed;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const publicRoot = path.join(ROOT, "dist/public");
|
||||
const renamed = await normalizeStaticPaths(publicRoot);
|
||||
console.log(`Normalized ${renamed} percent-encoded static path segments`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { cp, lstat, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const LOCALES = ["en", "zh", "ja"];
|
||||
const MINTLIFY_LOCALE_MAP = { en: "en", cn: "zh", jp: "ja" };
|
||||
const ASSET_DIRECTORIES = ["images", "logo", "ui", "openapi"];
|
||||
const ROOT_ASSET_PATTERN = /\.(?:gif|html|ico|jpe?g|png|svg|txt|webp|xml)$/i;
|
||||
const MINTLIFY_CALLOUT_TYPES = { Info: "info", Note: "info", Tip: "idea", Warning: "warning" };
|
||||
|
||||
export function mapMintlifyLocale(locale) {
|
||||
const mapped = MINTLIFY_LOCALE_MAP[locale];
|
||||
if (!mapped) throw new Error(`Unsupported Mintlify locale: ${locale}`);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async function listFiles(directory, prefix = "") {
|
||||
const files = [];
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const relative = path.posix.join(prefix, entry.name);
|
||||
const full = path.join(directory, entry.name);
|
||||
if (entry.isSymbolicLink()) throw new Error(`Generated inputs must not contain symlinks: ${full}`);
|
||||
if (entry.isDirectory()) files.push(...await listFiles(full, relative));
|
||||
else if (entry.isFile()) files.push(relative);
|
||||
}
|
||||
return files.sort();
|
||||
}
|
||||
|
||||
function rootRelativeAssetUrl(destination, documentPath) {
|
||||
const suffixIndex = destination.search(/[?#]/);
|
||||
const pathname = suffixIndex === -1 ? destination : destination.slice(0, suffixIndex);
|
||||
const suffix = suffixIndex === -1 ? "" : destination.slice(suffixIndex);
|
||||
const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(documentPath), pathname));
|
||||
if (resolved.startsWith("../") || path.posix.isAbsolute(resolved)) return destination;
|
||||
if (!ASSET_DIRECTORIES.includes(resolved.split("/", 1)[0])) return destination;
|
||||
return `/${resolved}${suffix}`;
|
||||
}
|
||||
|
||||
export function normalizeInternalHtmlUrl(destination) {
|
||||
if (/^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(destination)) return destination;
|
||||
const suffixIndex = destination.search(/[?#]/);
|
||||
const pathname = suffixIndex === -1 ? destination : destination.slice(0, suffixIndex);
|
||||
const suffix = suffixIndex === -1 ? "" : destination.slice(suffixIndex);
|
||||
return pathname.endsWith(".html") ? `${pathname.slice(0, -5)}${suffix}` : destination;
|
||||
}
|
||||
|
||||
export function normalizeMdxContent(content, documentPath) {
|
||||
let fence;
|
||||
return content.split(/(?<=\n)/).map((line) => {
|
||||
const body = line.replace(/\r?\n$/, "");
|
||||
if (fence) {
|
||||
if (new RegExp(`^ {0,3}${fence.character}{${fence.length},}\\s*$`).test(body)) fence = undefined;
|
||||
return line;
|
||||
}
|
||||
|
||||
const opening = body.match(/^( {0,3})(`{3,}|~{3,})(.*)$/);
|
||||
if (opening) {
|
||||
fence = { character: opening[2][0], length: opening[2].length };
|
||||
const normalizedInfo = opening[3].replace(/^(\s*)env(?=\s|$)/, "$1dotenv");
|
||||
return `${opening[1]}${opening[2]}${normalizedInfo}${line.slice(body.length)}`;
|
||||
}
|
||||
|
||||
return line
|
||||
.replace(/<\/?AccordionGroup>/g, "")
|
||||
.replace(/<Accordion\s+title=(["'])(.*?)\1\s*>/g, (_match, _quote, title) =>
|
||||
`<details>\n<summary>${title}</summary>`)
|
||||
.replace(/<\/Accordion>/g, "</details>")
|
||||
.replace(/<(Info|Note|Tip|Warning)(?=\s|>)([^>]*)>/g, (_match, name, attributes) =>
|
||||
`<Callout type="${MINTLIFY_CALLOUT_TYPES[name]}"${attributes}>`)
|
||||
.replace(/<\/(?:Info|Note|Tip|Warning)>/g, "</Callout>")
|
||||
.replace(/(!\[[^\]\n]*\]\(\s*)(<?)(\.\.?\/[^)\s>]+)(>?)([^)\n]*\))/g, (_match, prefix, open, destination, close, suffix) =>
|
||||
`${prefix}${open}${rootRelativeAssetUrl(destination, documentPath)}${close}${suffix}`)
|
||||
.replace(/(\]\(\s*<?)([^)\s>]+)(>?)([^)\n]*\))/g, (_match, prefix, destination, close, suffix) =>
|
||||
`${prefix}${normalizeInternalHtmlUrl(destination)}${close}${suffix}`)
|
||||
.replace(/(\bhref\s*=\s*["'])([^"']+)(["'])/g, (_match, prefix, destination, suffix) =>
|
||||
`${prefix}${normalizeInternalHtmlUrl(destination)}${suffix}`)
|
||||
.replace(/(\bsrc\s*=\s*["'])(\.\.?\/[^"']+)(["'])/g, (_match, prefix, destination, suffix) =>
|
||||
`${prefix}${rootRelativeAssetUrl(destination, documentPath)}${suffix}`);
|
||||
}).join("");
|
||||
}
|
||||
|
||||
export async function collectMdxDocuments(root = ROOT) {
|
||||
const pages = [];
|
||||
for (const locale of LOCALES) {
|
||||
const files = await listFiles(path.join(root, locale));
|
||||
pages.push(...files.filter((file) => file.endsWith(".mdx")).map((file) => `${locale}/${file}`));
|
||||
}
|
||||
return pages.sort();
|
||||
}
|
||||
|
||||
function visitNavigation(value, callback) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) visitNavigation(item, callback);
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object") return;
|
||||
callback(value);
|
||||
for (const child of Object.values(value)) visitNavigation(child, callback);
|
||||
}
|
||||
|
||||
export function collectOpenApiSources(docs) {
|
||||
const sources = [];
|
||||
visitNavigation(docs.navigation, (item) => {
|
||||
if (!item.openapi || typeof item.openapi !== "object" || !item.openapi.source || !item.openapi.directory) return;
|
||||
const locale = item.openapi.directory.split("/", 1)[0];
|
||||
sources.push({ locale, source: item.openapi.source, directory: item.openapi.directory });
|
||||
});
|
||||
return sources.sort((left, right) => LOCALES.indexOf(left.locale) - LOCALES.indexOf(right.locale));
|
||||
}
|
||||
|
||||
export function normalizeMintlifyNavigationForFumapress(docs) {
|
||||
const normalized = structuredClone(docs);
|
||||
for (const language of normalized.navigation?.languages ?? []) {
|
||||
const locale = mapMintlifyLocale(language.language);
|
||||
visitNavigation(language, (item) => {
|
||||
if (!Array.isArray(item.pages)) return;
|
||||
item.pages = item.pages.map((entry) =>
|
||||
typeof entry === "string" && entry.startsWith(`${locale}/`)
|
||||
? entry.slice(locale.length + 1)
|
||||
: entry,
|
||||
);
|
||||
});
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function cloudflarePattern(value, destination = false) {
|
||||
return value.replace(/:([A-Za-z][A-Za-z0-9_]*)\*/g, destination ? ":splat" : "*");
|
||||
}
|
||||
|
||||
export function renderCloudflareRedirects(docs) {
|
||||
const lines = [
|
||||
"/zh/develop/adapter/discord /zh/develop/adapter/discord/README 308",
|
||||
"/scripts/README-blog-articles /en/articles 308",
|
||||
];
|
||||
for (const redirect of docs.redirects ?? []) {
|
||||
const status = redirect.permanent === false ? 307 : 308;
|
||||
lines.push(`${cloudflarePattern(redirect.source)} ${cloudflarePattern(redirect.destination, true)} ${status}`);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
async function copyDirectory(source, destination) {
|
||||
const info = await lstat(source);
|
||||
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Expected a real directory: ${source}`);
|
||||
await cp(source, destination, { recursive: true, force: true, errorOnExist: false });
|
||||
}
|
||||
|
||||
export async function prepareFumapress({ root = ROOT, outRoot = root } = {}) {
|
||||
const docs = JSON.parse(await readFile(path.join(root, "docs.json"), "utf8"));
|
||||
const documents = await collectMdxDocuments(root);
|
||||
const openapi = collectOpenApiSources(docs);
|
||||
if (openapi.length !== LOCALES.length) {
|
||||
throw new Error(`Expected one nested OpenAPI source per locale, found ${openapi.length}`);
|
||||
}
|
||||
const contentRoot = path.join(outRoot, "content");
|
||||
const publicRoot = path.join(outRoot, "public");
|
||||
await rm(contentRoot, { recursive: true, force: true });
|
||||
await rm(publicRoot, { recursive: true, force: true });
|
||||
await mkdir(path.join(contentRoot, "docs"), { recursive: true });
|
||||
await mkdir(publicRoot, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(contentRoot, "fumapress-docs.json"),
|
||||
`${JSON.stringify(normalizeMintlifyNavigationForFumapress(docs), null, 2)}\n`,
|
||||
);
|
||||
|
||||
const documentsByPath = new Map();
|
||||
for (const document of documents) {
|
||||
const [locale, ...relativeParts] = document.split("/");
|
||||
const relative = relativeParts.join("/");
|
||||
const translations = documentsByPath.get(relative) ?? new Map();
|
||||
translations.set(locale, document);
|
||||
documentsByPath.set(relative, translations);
|
||||
}
|
||||
|
||||
let localeOnlyDocuments = 0;
|
||||
for (const [relative, translations] of [...documentsByPath].sort(([left], [right]) => left.localeCompare(right))) {
|
||||
if (!translations.has("en")) localeOnlyDocuments += 1;
|
||||
|
||||
for (const [locale, document] of translations) {
|
||||
const extension = locale === "en" ? ".mdx" : `.${locale}.mdx`;
|
||||
const destination = path.join(contentRoot, "docs", relative.replace(/\.mdx$/, extension));
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
const source = await readFile(path.join(root, document), "utf8");
|
||||
await writeFile(destination, normalizeMdxContent(source, document));
|
||||
}
|
||||
|
||||
}
|
||||
for (const directory of ASSET_DIRECTORIES) {
|
||||
await copyDirectory(path.join(root, directory), path.join(publicRoot, directory));
|
||||
}
|
||||
for (const entry of await readdir(root, { withFileTypes: true })) {
|
||||
if (entry.isFile() && ROOT_ASSET_PATTERN.test(entry.name) && !["robots.txt", "sitemap-alternates.xml"].includes(entry.name)) {
|
||||
await cp(path.join(root, entry.name), path.join(publicRoot, entry.name), { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
await cp(path.join(root, "robots.txt"), path.join(publicRoot, "robots.txt"), { force: true });
|
||||
await writeFile(path.join(publicRoot, "_redirects"), renderCloudflareRedirects(docs));
|
||||
|
||||
return {
|
||||
documents: documents.length,
|
||||
fallbackDefaults: 0,
|
||||
localeOnlyDocuments,
|
||||
redirects: (docs.redirects?.length ?? 0) + 2,
|
||||
locales: [...LOCALES],
|
||||
openapi,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const result = await prepareFumapress();
|
||||
console.log(`Prepared ${result.documents} MDX documents, ${result.openapi.length} OpenAPI specs, and ${result.redirects} redirects.`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
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-docs-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; });
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Advance only the landing docs gitlink, then sync its production fork."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
SOURCE = "langbot-app/langbot-docs"
|
||||
TARGET = "langbot-app/langbot-landing-page"
|
||||
DEPLOY = "RockChinQ/LangBot-LandingPage-Vue"
|
||||
|
||||
|
||||
def api(method, path, data=None):
|
||||
token = os.environ["GH_TOKEN"]
|
||||
request = urllib.request.Request(
|
||||
"https://api.github.com/repos/" + path,
|
||||
data=None if data is None else json.dumps(data).encode(),
|
||||
headers={"Authorization": "Bea" + "rer " + token,
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Content-Type": "application/json"},
|
||||
method=method,
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def head(call, repo):
|
||||
return call("GET", repo + "/git/ref/heads/main")["object"]["sha"]
|
||||
|
||||
|
||||
def sync(call=api, dry_run=False):
|
||||
for attempt in range(5):
|
||||
source = head(call, SOURCE) # Coalesce queued events onto current main.
|
||||
parent = head(call, TARGET)
|
||||
commit = call("GET", TARGET + "/git/commits/" + parent)
|
||||
tree = call("GET", TARGET + "/git/trees/" + commit["tree"]["sha"])
|
||||
entry = next(item for item in tree["tree"] if item["path"] == "docs")
|
||||
if entry["mode"] != "160000" or entry["type"] != "commit":
|
||||
raise RuntimeError("Landing docs is not a submodule")
|
||||
if entry["sha"] == source:
|
||||
print("Landing docs already at " + source)
|
||||
break
|
||||
comparison = call("GET", SOURCE + "/compare/" + entry["sha"] + "..." + source)
|
||||
if comparison["status"] != "ahead":
|
||||
raise RuntimeError("Refusing a docs rewind or divergent history")
|
||||
print("Updating landing docs: " + entry["sha"] + " -> " + source)
|
||||
if dry_run:
|
||||
return
|
||||
updated = call("POST", TARGET + "/git/trees", {
|
||||
"base_tree": commit["tree"]["sha"],
|
||||
"tree": [{"path": "docs", "mode": "160000", "type": "commit", "sha": source}],
|
||||
})
|
||||
candidate = call("POST", TARGET + "/git/commits", {
|
||||
"message": "docs: sync langbot-docs@" + source[:12],
|
||||
"tree": updated["sha"], "parents": [parent],
|
||||
"author": {"name": "github-actions[bot]", "email": "41898282+github-actions[bot]@users.noreply.github.com"},
|
||||
})
|
||||
try:
|
||||
call("PATCH", TARGET + "/git/refs/heads/main", {"sha": candidate["sha"], "force": False})
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code in (409, 422) and attempt < 4:
|
||||
continue # Rebuild atop current target; never force-push.
|
||||
raise
|
||||
actual = call("GET", TARGET + "/contents/docs?ref=main")
|
||||
if actual["sha"] != source:
|
||||
raise RuntimeError("Landing submodule readback mismatch")
|
||||
print("Published landing commit " + candidate["sha"])
|
||||
break
|
||||
else:
|
||||
raise RuntimeError("Landing branch kept changing")
|
||||
if dry_run:
|
||||
return
|
||||
# Also retry this on no-op runs, so failed deployment sync is recoverable.
|
||||
call("POST", DEPLOY + "/merge-upstream", {"branch": "main"})
|
||||
if head(call, DEPLOY) != head(call, TARGET):
|
||||
raise RuntimeError("Production fork readback mismatch")
|
||||
print("Production fork matches landing main")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
sync(dry_run=parser.parse_args().dry_run)
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Run the API documentation snippets with the installed SDK and a mock Host.
|
||||
|
||||
Usage: python scripts/verify-plugin-api-examples.py
|
||||
This checks SDK calls and result shapes, not external platform delivery.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import argparse
|
||||
import ast
|
||||
import inspect
|
||||
import re
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from langbot_plugin.api.entities.builtin.runner.context import RunnerContext
|
||||
from langbot_plugin.api.entities.builtin.runner.errors import (
|
||||
AgentAPIError,
|
||||
AgentAPIException,
|
||||
)
|
||||
from langbot_plugin.api.proxies.runner import RunnerAPIProxy
|
||||
from langbot_plugin.api.proxies.langbot_api import LangBotAPIProxy
|
||||
from langbot_plugin.api.proxies.event_context import EventContextProxy
|
||||
from langbot_plugin.api.proxies.execute_context import ExecuteContextProxy
|
||||
from langbot_plugin.api.entities.builtin.provider.session import Session
|
||||
from langbot_plugin.api.proxies.invocation import bind_invocation
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class Host:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.closed_streams = 0
|
||||
|
||||
async def call_action(self, action, data, timeout=15):
|
||||
name = action.value
|
||||
self.calls.append((name, data))
|
||||
responses = {
|
||||
"invoke_llm": {
|
||||
"message": {"role": "assistant", "content": "Hello!"},
|
||||
"usage": {"total_tokens": 8},
|
||||
},
|
||||
"invoke_rerank": {"results": [{"index": 0, "relevance_score": 0.9}]},
|
||||
"list_parsers": {
|
||||
"parsers": [{"plugin_author": "demo", "plugin_name": "Parser"}]
|
||||
},
|
||||
"invoke_parser": {"text": "guide", "sections": [], "metadata": {}},
|
||||
"vector_list": {
|
||||
"items": [{"id": "chunk", "document": "guide", "metadata": {}}],
|
||||
"total": 1,
|
||||
},
|
||||
"list_plugins_manifest": {"plugins": []},
|
||||
"list_commands": {"commands": []},
|
||||
"call_platform_api": {"result": {"id": "123456", "name": "Example"}},
|
||||
"call_tool": {"result": {"id": "alice"}},
|
||||
"reply_stream": {"result": {"ok": True, "mock": True}},
|
||||
"history_page": {
|
||||
"items": [],
|
||||
"has_more": not data.get("before_cursor"),
|
||||
"prev_cursor": "older",
|
||||
"next_cursor": None,
|
||||
},
|
||||
"reply_message": {},
|
||||
"set_query_var": {},
|
||||
"state_set": {"success": True},
|
||||
"state_get": {"value": "setup"},
|
||||
}
|
||||
if name not in responses:
|
||||
raise AssertionError(f"Unexpected Host action: {name}")
|
||||
return responses[name]
|
||||
|
||||
async def call_action_generator(self, action, data, timeout=15):
|
||||
assert action.value == "invoke_llm_stream"
|
||||
self.calls.append((action.value, data))
|
||||
try:
|
||||
yield {"chunk": {"role": "assistant", "content": "Hello", "is_final": False}}
|
||||
yield {"chunk": {"role": "assistant", "content": "!", "is_final": True}}
|
||||
yield {"usage": {"total_tokens": 8}}
|
||||
finally:
|
||||
self.closed_streams += 1
|
||||
|
||||
|
||||
def context():
|
||||
return RunnerContext.model_validate(
|
||||
{
|
||||
"run_id": "docs-run",
|
||||
"trigger": {"type": "user_message"},
|
||||
"event": {
|
||||
"event_id": "docs-event",
|
||||
"event_type": "message.received",
|
||||
"source": "test",
|
||||
},
|
||||
"input": {"content": "Hello"},
|
||||
"delivery": {"surface": "test"},
|
||||
"runtime": {},
|
||||
"resources": {
|
||||
"models": [{"model_id": "model"}],
|
||||
"tools": [
|
||||
{"tool_name": "event_get_actor"},
|
||||
{"tool_name": "event_reply"},
|
||||
],
|
||||
},
|
||||
"context": {
|
||||
"available_apis": {
|
||||
"history_page": True,
|
||||
"state": True,
|
||||
"reply_stream": True,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def snippets(locale, page):
|
||||
source = (ROOT / locale / f"plugin/dev/apis/{page}.mdx").read_text(encoding="utf-8")
|
||||
if page == "common":
|
||||
headings = {
|
||||
"zh": ("### 模型流式输出与用量", "## 其他资源 API"),
|
||||
"en": ("### Model streaming and usage", "## Additional resource APIs"),
|
||||
"ja": ("### モデルのストリーミングと使用量", "## その他のリソース API"),
|
||||
}[locale]
|
||||
start = source.index(headings[0])
|
||||
end = source.index("\n### ", start + len(headings[0]))
|
||||
source = source[start:end] + source[source.index(headings[1]) :]
|
||||
return re.findall(r"^```python\n(.*?)^```", source, re.M | re.S)
|
||||
|
||||
|
||||
def verify_context_reference():
|
||||
source = (ROOT / "zh/plugin/dev/apis/agent-run.mdx").read_text(encoding="utf-8")
|
||||
query_methods = {
|
||||
"reply",
|
||||
"get_bot_uuid",
|
||||
"set_query_var",
|
||||
"get_query_var",
|
||||
"get_query_vars",
|
||||
"create_new_conversation",
|
||||
"list_pipeline_knowledge_bases",
|
||||
"retrieve_knowledge",
|
||||
}
|
||||
runner_methods = {
|
||||
name
|
||||
for name, value in RunnerContext.__dict__.items()
|
||||
if callable(value) and not name.startswith("_")
|
||||
}
|
||||
runner_methods.discard("model_post_init")
|
||||
|
||||
for prefix in ("event_context", "context"):
|
||||
missing = sorted(
|
||||
name for name in query_methods if f"{prefix}.{name}(" not in source
|
||||
)
|
||||
assert not missing, f"{prefix} methods missing from context reference: {missing}"
|
||||
|
||||
for name in ("prevent_default", "prevent_postorder"):
|
||||
assert f"event_context.{name}(" in source
|
||||
|
||||
missing = sorted(name for name in runner_methods if f"ctx.{name}(" not in source)
|
||||
assert not missing, f"RunnerContext methods missing from context reference: {missing}"
|
||||
assert "`platform_event`" in source
|
||||
|
||||
required_fields = {
|
||||
"instance_uuid",
|
||||
"workspace_uuid",
|
||||
"placement_generation",
|
||||
"query_id",
|
||||
"query_uuid",
|
||||
"eid",
|
||||
"event_name",
|
||||
"event",
|
||||
"is_prevent_default",
|
||||
"is_prevent_postorder",
|
||||
"session",
|
||||
"command_text",
|
||||
"full_command_text",
|
||||
"command",
|
||||
"crt_command",
|
||||
"params",
|
||||
"crt_params",
|
||||
"privilege",
|
||||
*RunnerContext.model_fields,
|
||||
}
|
||||
missing = sorted(name for name in required_fields if f"`{name}`" not in source)
|
||||
assert not missing, f"Context fields missing from context reference: {missing}"
|
||||
assert "spec.permissions" not in source
|
||||
assert "spec.capabilities" not in source
|
||||
|
||||
|
||||
async def execute(block, namespace):
|
||||
# Examples are function bodies; signature reference blocks may include stubs.
|
||||
function = "async def example():\n" + "\n".join(
|
||||
" " + line for line in block.splitlines()
|
||||
)
|
||||
tree = ast.parse(function)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
|
||||
continue
|
||||
owner = ast.unparse(node.func.value)
|
||||
cls = (
|
||||
LangBotAPIProxy
|
||||
if owner == "self.plugin"
|
||||
else EventContextProxy
|
||||
if owner == "event_context"
|
||||
else ExecuteContextProxy
|
||||
if owner == "context"
|
||||
else RunnerContext
|
||||
if owner == "ctx"
|
||||
else RunnerAPIProxy
|
||||
if owner == "api"
|
||||
else None
|
||||
)
|
||||
if cls is not None:
|
||||
signature = inspect.signature(getattr(cls, node.func.attr))
|
||||
signature.bind(
|
||||
None,
|
||||
*([None] * len(node.args)),
|
||||
**{kw.arg: None for kw in node.keywords},
|
||||
)
|
||||
exec(compile(tree, "plugin-api-doc-example", "exec"), namespace)
|
||||
await namespace["example"]()
|
||||
|
||||
|
||||
async def main(pages):
|
||||
verify_context_reference()
|
||||
total = 0
|
||||
for page in pages:
|
||||
blocks = snippets("zh", page)
|
||||
for locale in ("en", "ja"):
|
||||
assert blocks == snippets(locale, page), f"{page}: {locale} code differs"
|
||||
for block in blocks:
|
||||
host = Host()
|
||||
ctx = context()
|
||||
api = RunnerAPIProxy(ctx, host)
|
||||
ctx._api = api
|
||||
ctx._results = asyncio.Queue()
|
||||
session = Session(launcher_type="group", launcher_id="docs-group", sender_id="docs-user")
|
||||
namespace = {
|
||||
"event_context": EventContextProxy.model_construct(
|
||||
query_id=42,
|
||||
event=SimpleNamespace(sender_id="docs-user"),
|
||||
plugin_runtime_handler=host,
|
||||
),
|
||||
"context": ExecuteContextProxy.model_construct(
|
||||
query_id=43,
|
||||
plugin_runtime_handler=host,
|
||||
crt_params=["Hello!"],
|
||||
session=session,
|
||||
),
|
||||
"session": session,
|
||||
"Any": Any,
|
||||
"Message": Message,
|
||||
"api": api,
|
||||
"ctx": ctx,
|
||||
"self": SimpleNamespace(
|
||||
plugin=LangBotAPIProxy(host), get_run_api=lambda _: api
|
||||
),
|
||||
"model_uuid": "model",
|
||||
"rerank_model_uuid": "model",
|
||||
"collection_id": "collection",
|
||||
"storage_path": "files/guide.pdf",
|
||||
"bot_uuid": "bot",
|
||||
"print": lambda *args: None,
|
||||
}
|
||||
with bind_invocation(host, api):
|
||||
await execute(block, namespace)
|
||||
if page == "llm-requests":
|
||||
expected = 0 if "aclosing" in block else 1
|
||||
llm_calls = [data for name, data in host.calls if name.startswith("invoke_llm")]
|
||||
assert len(llm_calls) == 1
|
||||
assert llm_calls[0]["extra_args"]["request_policy"]["retries"] == expected
|
||||
if "aclosing" in block:
|
||||
assert host.closed_streams == 1, "Early close did not reach the Host stream"
|
||||
for name, data in host.calls:
|
||||
if name in {"reply_message", "set_query_var"}:
|
||||
expected_query = 42 if "event_context" in block else 43
|
||||
assert data["query_id"] == expected_query
|
||||
assert "run_id" not in data
|
||||
if "reply.update" in block:
|
||||
ops = [
|
||||
data["operation"]
|
||||
for name, data in host.calls
|
||||
if name == "reply_stream"
|
||||
]
|
||||
assert ops == ["update", "update", "finish"], ops
|
||||
if "older =" in block:
|
||||
pages = [data for name, data in host.calls if name == "history_page"]
|
||||
assert len(pages) == 2 and pages[1]["before_cursor"] == "older"
|
||||
if "except PermissionDeniedError" in block:
|
||||
# Exercise both documented error handlers with the real SDK proxy.
|
||||
api._allowed_tool_names = frozenset()
|
||||
await execute(block, namespace)
|
||||
ctx.log.assert_awaited()
|
||||
api._allowed_tool_names = frozenset({"event_get_actor"})
|
||||
host.call_action = AsyncMock(
|
||||
side_effect=AgentAPIException(
|
||||
AgentAPIError(
|
||||
code="host.action_error", message="Failed", retryable=False
|
||||
)
|
||||
)
|
||||
)
|
||||
await execute(block, namespace)
|
||||
assert ctx.log.await_count == 2
|
||||
total += 1
|
||||
print(
|
||||
f"{total} API examples passed; all three locales have identical code. Host delivery is mocked."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--page", action="append",
|
||||
choices=("common", "agent-run", "platform", "llm-requests"),
|
||||
help="Verify selected pages (repeatable); defaults to all pages.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(main(args.page or ("common", "agent-run", "platform", "llm-requests")))
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Execute the published tutorial with the matching 4.11 SDK installed.
|
||||
|
||||
Run: python scripts/verify-runner-tutorial.py
|
||||
The tool API is mocked; real Host/runtime checks are a separate validation layer.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import yaml
|
||||
from langbot_plugin.api.definition.components.runner import RunnerContext
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load_tutorial():
|
||||
examples = []
|
||||
for locale in ("zh", "en", "ja"):
|
||||
path = ROOT / locale / "plugin/dev/components/runner.mdx"
|
||||
source = path.read_text()
|
||||
python = re.findall(r"^```python\n(.*?)^```", source, re.M | re.S)
|
||||
manifests = [
|
||||
block
|
||||
for block in re.findall(r"^```yaml\n(.*?)^```", source, re.M | re.S)
|
||||
if "kind: Runner" in block
|
||||
]
|
||||
python = [block for block in python if "class Welcome(Runner):" in block]
|
||||
assert len(python) == len(manifests) == 1, path
|
||||
examples.append((python[0], manifests[0]))
|
||||
assert examples[0] == examples[1] == examples[2], "Locale examples differ"
|
||||
code, manifest = examples[0]
|
||||
namespace = {}
|
||||
exec(compile(code, "runner.mdx", "exec"), namespace)
|
||||
manifest = yaml.safe_load(manifest)
|
||||
assert manifest["spec"]["events"] == ["group.member_joined", "message.received"]
|
||||
assert manifest["spec"]["capabilities"]["tool_calling"] is True
|
||||
assert manifest["spec"]["permissions"]["tools"] == ["detail", "call"]
|
||||
defaults = {field["name"]: field["default"] for field in manifest["spec"]["config"]}
|
||||
return namespace[manifest["execution"]["python"]["attr"]], defaults
|
||||
|
||||
|
||||
async def main():
|
||||
component_class, defaults = load_tutorial()
|
||||
component = component_class()
|
||||
await component.initialize()
|
||||
cases = [
|
||||
(
|
||||
"join",
|
||||
{
|
||||
"type": "group.member_joined",
|
||||
"member": {"id": "alice", "nickname": "Alice"},
|
||||
},
|
||||
{},
|
||||
"Alice, Welcome aboard!",
|
||||
),
|
||||
(
|
||||
"nickname-fallback",
|
||||
{"type": "group.member_joined", "member": {"id": "alice"}},
|
||||
{},
|
||||
"alice, Welcome aboard!",
|
||||
),
|
||||
(
|
||||
"join-disabled",
|
||||
{"type": "group.member_joined"},
|
||||
{"reply_enabled": False},
|
||||
None,
|
||||
),
|
||||
(
|
||||
"command-disabled",
|
||||
{
|
||||
"type": "message.received",
|
||||
"message_chain": [{"type": "Plain", "text": "/hello"}],
|
||||
},
|
||||
{"reply_enabled": False},
|
||||
None,
|
||||
),
|
||||
(
|
||||
"ordinary-text",
|
||||
{
|
||||
"type": "message.received",
|
||||
"message_chain": [{"type": "Plain", "text": "hello"}],
|
||||
},
|
||||
{},
|
||||
None,
|
||||
),
|
||||
("empty-message", {"type": "message.received", "message_chain": []}, {}, None),
|
||||
(
|
||||
"image-only",
|
||||
{
|
||||
"type": "message.received",
|
||||
"message_chain": [
|
||||
{"type": "Image", "url": "https://example.com/image.png"}
|
||||
],
|
||||
},
|
||||
{},
|
||||
None,
|
||||
),
|
||||
]
|
||||
for chat_type in ("private", "group"):
|
||||
cases.append(
|
||||
(
|
||||
chat_type,
|
||||
{
|
||||
"type": "message.received",
|
||||
"chat_type": chat_type,
|
||||
"message_chain": [{"type": "Plain", "text": " /hello "}],
|
||||
},
|
||||
{"greeting": "你好 🧩"},
|
||||
"你好 🧩",
|
||||
)
|
||||
)
|
||||
|
||||
async def execute(name, data, config, reply, *, failure=False):
|
||||
api = SimpleNamespace(
|
||||
call_tool=AsyncMock(
|
||||
side_effect=RuntimeError("Delivery failed") if failure else None,
|
||||
return_value={"mock": True},
|
||||
)
|
||||
)
|
||||
component.get_run_api = Mock(return_value=api)
|
||||
context = RunnerContext.model_validate({
|
||||
"run_id": name, "trigger": {"type": data["type"]},
|
||||
"event": {"event_id": name, "event_type": data["type"], "source": "test", "data": data},
|
||||
"config": {**defaults, **config}, "input": {}, "delivery": {"surface": "test"}, "resources": {}, "runtime": {},
|
||||
})
|
||||
results = []
|
||||
try:
|
||||
async for result in component.invoke(context):
|
||||
results.append(result)
|
||||
except RuntimeError as exc:
|
||||
assert failure and str(exc) == "Delivery failed"
|
||||
else:
|
||||
assert not failure, "Delivery failure was swallowed"
|
||||
assert sum(r.type == "run.completed" for r in results) == int(not failure)
|
||||
assert all(r.run_id == name for r in results)
|
||||
if reply is None:
|
||||
api.call_tool.assert_not_called()
|
||||
else:
|
||||
api.call_tool.assert_awaited_once_with("event_reply", {"text": reply})
|
||||
started = [
|
||||
r.data["tool_call_id"] for r in results if r.type == "tool.call.started"
|
||||
]
|
||||
completed = [
|
||||
r.data["tool_call_id"]
|
||||
for r in results
|
||||
if r.type == "tool.call.completed"
|
||||
]
|
||||
assert len(started) == 1 and started == completed
|
||||
if failure:
|
||||
assert any(r.type == "processor.log" for r in results)
|
||||
assert any(r.data.get("error") == "Delivery failed" for r in results)
|
||||
print(f"PASS {name}")
|
||||
|
||||
for case in cases:
|
||||
await execute(*case)
|
||||
await execute("delivery-failure", cases[0][1], {}, cases[0][3], failure=True)
|
||||
print(
|
||||
"All 10 tutorial cases passed; Chinese, English and Japanese code is identical."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user