Feishu Wiki Snapshot Migration Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a new Docusaurus site in the current workspace and import the Feishu wiki subtree rooted at NclNwZsMKi2L07kpYW1czK9Nnv7 into local docs with local image assets.
Architecture: Scaffold a TypeScript Docusaurus classic site in-place, keep the docs plugin as the content host, and add a Node-based one-time importer around lark-cli. Put logic that transforms titles, routes, links, and sidebars into small pure modules covered by Node built-in tests, then run the importer against the live Feishu tree and verify the generated site builds.
Tech Stack: Node.js 24, npm 11, Docusaurus 3 classic preset, TypeScript config, Node built-in test runner, lark-cli
Repository note: the current workspace is not a Git repository, so this plan replaces commit steps with explicit verification checkpoints. If Git history is needed later, initialize Git before execution.
Task 1: Scaffold The Docusaurus Site
Files:
-
Create:
package.json,tsconfig.json,docusaurus.config.ts,sidebars.ts,src/pages/index.tsx,src/css/custom.css,docs/intro.md, scaffolded Docusaurus files -
Modify:
package.json,docusaurus.config.ts,src/pages/index.tsx,src/css/custom.css,docs/intro.md -
Test:
npm run build -
Step 1: Scaffold the site into the current empty directory
Run:
npx create-docusaurus@latest . classic --typescript
Expected:
[SUCCESS] Created Docusaurus project in current directory.
- Step 2: Verify the scaffold created the expected top-level files
Run:
rg --files -g 'package.json' -g 'docusaurus.config.ts' -g 'sidebars.ts' -g 'src/pages/index.tsx' -g 'docs/intro.md'
Expected:
package.json
docusaurus.config.ts
sidebars.ts
src/pages/index.tsx
docs/intro.md
- Step 3: Replace the default site config with the migration-oriented config
Update docusaurus.config.ts to:
import type {Config} from '@docusaurus/types';
import type * as Preset from '@docusaurus/preset-classic';
const config: Config = {
title: 'Constreet Docs',
tagline: 'Constreet documentation migrated from Feishu',
favicon: 'img/favicon.ico',
url: 'https://docs.constreet.example',
baseUrl: '/',
organizationName: 'constreet',
projectName: 'docs-constreet',
onBrokenLinks: 'throw',
onBrokenMarkdownLinks: 'throw',
i18n: {
defaultLocale: 'zh-Hans',
locales: ['zh-Hans'],
},
presets: [
[
'classic',
{
docs: {
sidebarPath: './sidebars.ts',
},
blog: false,
pages: true,
theme: {
customCss: './src/css/custom.css',
},
} satisfies Preset.Options,
],
],
themeConfig: {
image: 'img/docusaurus-social-card.jpg',
navbar: {
title: 'Constreet Docs',
items: [
{
type: 'docSidebar',
sidebarId: 'tutorialSidebar',
position: 'left',
label: '文档',
},
],
},
footer: {
style: 'dark',
links: [],
copyright: `Copyright ${new Date().getFullYear()} Constreet`,
},
} satisfies Preset.ThemeConfig,
};
export default config;
- Step 4: Replace the default landing page with a redirect into the docs section
Update src/pages/index.tsx to:
import React from 'react';
import {Redirect} from '@docusaurus/router';
export default function Home(): JSX.Element {
return <Redirect to="/docs/intro" />;
}
- Step 5: Replace the default placeholder doc and custom CSS with minimal migration-safe content
Update docs/intro.md to:
---
title: 文档首页
slug: /intro
---
迁移脚本执行后,这个页面会被飞书根文档内容覆盖。
Update src/css/custom.css to:
:root {
--ifm-color-primary: #1f6f63;
--ifm-color-primary-dark: #1b6459;
--ifm-color-primary-darker: #195e54;
--ifm-color-primary-darkest: #144d45;
--ifm-color-primary-light: #237a6d;
--ifm-color-primary-lighter: #258072;
--ifm-color-primary-lightest: #2a9180;
--ifm-font-family-base: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
}
- Step 6: Build the untouched scaffold to confirm the site baseline works
Run:
npm run build
Expected:
[SUCCESS] Generated static files in "build".
Task 2: Write The Failing Tests For Migration Helpers
Files:
-
Modify:
package.json -
Create:
scripts/lib/normalize-title.test.mjs,scripts/lib/rewrite-links.test.mjs,scripts/lib/build-sidebar.test.mjs,scripts/lib/run-import.test.mjs -
Test:
npm test -
Step 1: Add a test script that uses Node's built-in test runner
Update the scripts section in package.json to include:
{
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
"serve": "docusaurus serve",
"clear": "docusaurus clear",
"test": "node --test scripts/**/*.test.mjs"
}
}
- Step 2: Write the failing tests for title normalization
Create scripts/lib/normalize-title.test.mjs:
import test from 'node:test';
import assert from 'node:assert/strict';
import {createSlugger} from './normalize-title.mjs';
test('createSlugger normalizes Chinese titles and removes emoji', () => {
const slugger = createSlugger();
assert.equal(
slugger('Claude Code(安装与接入Constreet)'),
'claude-code-安装与接入constreet',
);
assert.equal(slugger('### 🎯 智汇一体'), '智汇一体');
});
test('createSlugger disambiguates repeated titles', () => {
const slugger = createSlugger();
assert.equal(slugger('获取支持'), '获取支持');
assert.equal(slugger('获取支持'), '获取支持-2');
});
test('createSlugger falls back to a token when title becomes empty', () => {
const slugger = createSlugger();
assert.equal(slugger(' ', 'NclNwZsMKi2L07kpYW1czK9Nnv7'), 'nclnwzsmki2l07kpyw1czk9nnv7');
});
- Step 3: Write the failing tests for Markdown link rewriting
Create scripts/lib/rewrite-links.test.mjs:
import test from 'node:test';
import assert from 'node:assert/strict';
import {rewriteMarkdown} from './rewrite-links.mjs';
const routeMap = new Map([
['NclNwZsMKi2L07kpYW1czK9Nnv7', '/docs/intro'],
['GAosweDVoiGrjwkfs8ecUoaqn5b', '/docs/帮助中心/获取支持'],
]);
test('rewriteMarkdown converts tree-internal Feishu links to site routes', () => {
const input = '[获取帮助](https://my.feishu.cn/wiki/GAosweDVoiGrjwkfs8ecUoaqn5b)';
const output = rewriteMarkdown(input, {routeMap, imageMap: new Map()});
assert.equal(output, '[获取帮助](/docs/帮助中心/获取支持)');
});
test('rewriteMarkdown keeps tree-external Feishu links unchanged', () => {
const input = '[外部文档](https://my.feishu.cn/wiki/OutsideToken1234567890)';
const output = rewriteMarkdown(input, {routeMap, imageMap: new Map()});
assert.equal(output, input);
});
test('rewriteMarkdown replaces downloaded image URLs with local asset paths', () => {
const input = '';
const imageMap = new Map([
['https://internal-api-drive-stream.feishu.cn/path/image.png', '/img/feishu/intro-1.png'],
]);
const output = rewriteMarkdown(input, {routeMap, imageMap});
assert.equal(output, '');
});
- Step 4: Write the failing tests for sidebar generation
Create scripts/lib/build-sidebar.test.mjs:
import test from 'node:test';
import assert from 'node:assert/strict';
import {buildSidebar} from './build-sidebar.mjs';
test('buildSidebar preserves nested wiki categories', () => {
const sidebar = buildSidebar({
title: '文档首页',
route: 'intro',
children: [
{
title: '工具部署',
route: '工具部署/index',
children: [
{
title: '安装 Node.js',
route: '工具部署/安装-node-js/index',
children: [
{
title: 'Node.js 安装教程(Windows)',
route: '工具部署/安装-node-js/node-js-安装教程-windows',
children: [],
},
],
},
],
},
],
});
assert.deepEqual(sidebar, {
tutorialSidebar: [
'intro',
{
type: 'category',
label: '工具部署',
items: [
'工具部署/index',
{
type: 'category',
label: '安装 Node.js',
items: [
'工具部署/安装-node-js/index',
'工具部署/安装-node-js/node-js-安装教程-windows',
],
},
],
},
],
});
});
- Step 5: Write the failing tests for importer orchestration
Create scripts/lib/run-import.test.mjs:
import test from 'node:test';
import assert from 'node:assert/strict';
import {runImport} from './run-import.mjs';
test('runImport writes docs, downloads images, and returns a summary', async () => {
const writes = [];
const downloads = [];
const summary = await runImport({
rootNodeToken: 'root',
docsDir: '/tmp/docs',
imageDir: '/tmp/static/img/feishu',
sidebarPath: '/tmp/sidebars.ts',
client: {
async getNode(token) {
return token === 'root'
? {
node_token: 'root',
title: '文档首页',
obj_token: 'doc-root',
children: [
{
node_token: 'child',
title: '获取支持',
obj_token: 'doc-child',
children: [],
},
],
}
: null;
},
async getDocument(docToken) {
return docToken === 'doc-root'
? '# 文档首页\n\n[获取支持](https://my.feishu.cn/wiki/child)\n\n'
: '# 获取支持';
},
async downloadImage(url, outputPath) {
downloads.push([url, outputPath]);
return outputPath;
},
},
fs: {
async mkdir() {},
async writeFile(path, contents) {
writes.push([path, contents]);
},
},
});
assert.equal(summary.documentCount, 2);
assert.equal(summary.failureCount, 0);
assert.equal(downloads.length, 1);
assert.equal(writes.some(([path]) => path.endsWith('/intro.md')), true);
assert.equal(writes.some(([path]) => path.endsWith('/获取支持.md')), true);
assert.equal(writes.some(([path]) => path.endsWith('/sidebars.ts')), true);
});
- Step 6: Run the tests to verify they fail because the helper modules do not exist yet
Run:
npm test
Expected:
ERR_MODULE_NOT_FOUND
Task 3: Implement The Pure Migration Helpers
Files:
-
Create:
scripts/lib/normalize-title.mjs,scripts/lib/rewrite-links.mjs,scripts/lib/build-sidebar.mjs -
Test:
npm test -- --test-name-pattern "createSlugger|rewriteMarkdown|buildSidebar" -
Step 1: Implement the slugger to normalize titles and handle duplicates
Create scripts/lib/normalize-title.mjs:
const EMOJI_AND_SYMBOLS = /[\p{Extended_Pictographic}\p{Symbol_Other}]/gu;
const INVALID_PATH_CHARS = /[<>:"/\\|?*\u0000-\u001F]/g;
const WRAPPER_PUNCTUATION = /[()()[\]【】]/g;
const SEPARATORS = /[\s._]+/g;
const DUPLICATE_HYPHENS = /-+/g;
const EDGE_HYPHENS = /^-|-$/g;
function normalizeBase(title) {
return title
.normalize('NFKC')
.replace(EMOJI_AND_SYMBOLS, '')
.replace(INVALID_PATH_CHARS, ' ')
.replace(WRAPPER_PUNCTUATION, ' ')
.replace(SEPARATORS, '-')
.replace(DUPLICATE_HYPHENS, '-')
.replace(EDGE_HYPHENS, '')
.trim()
.toLowerCase();
}
export function createSlugger() {
const seen = new Map();
return (title, fallbackToken = '') => {
const base = normalizeBase(title || '') || fallbackToken.toLowerCase();
const count = (seen.get(base) ?? 0) + 1;
seen.set(base, count);
return count === 1 ? base : `${base}-${count}`;
};
}
- Step 2: Implement Markdown rewriting for internal Feishu links and local images
Create scripts/lib/rewrite-links.mjs:
const FEISHU_WIKI_LINK = /\[([^\]]+)\]\((https?:\/\/[^)]+\/wiki\/([A-Za-z0-9]+)[^)#]*(?:#[^)]+)?)\)/g;
const FEISHU_DOC_LINK = /\[([^\]]+)\]\((https?:\/\/[^)]+\/docx\/([A-Za-z0-9]+)[^)#]*(?:#[^)]+)?)\)/g;
const MARKDOWN_IMAGE = /!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g;
function replaceFeishuLinks(markdown, routeMap, matcher) {
return markdown.replace(matcher, (full, label, url, token) => {
const target = routeMap.get(token);
return target ? `[${label}](${target})` : full;
});
}
export function rewriteMarkdown(markdown, {routeMap, imageMap}) {
let rewritten = markdown;
rewritten = replaceFeishuLinks(rewritten, routeMap, FEISHU_WIKI_LINK);
rewritten = replaceFeishuLinks(rewritten, routeMap, FEISHU_DOC_LINK);
rewritten = rewritten.replace(MARKDOWN_IMAGE, (full, alt, url) => {
const target = imageMap.get(url);
return target ? `` : full;
});
return rewritten;
}
- Step 3: Implement sidebar generation from the imported wiki tree
Create scripts/lib/build-sidebar.mjs:
function toSidebarItem(node) {
if (!node.children.length) {
return node.route;
}
return {
type: 'category',
label: node.title,
items: [node.route, ...node.children.map((child) => toSidebarItem(child))],
};
}
export function buildSidebar(root) {
return {
tutorialSidebar: [root.route, ...root.children.map((child) => toSidebarItem(child))],
};
}
- Step 4: Run the focused helper tests and confirm they pass
Run:
npm test -- --test-name-pattern "createSlugger|rewriteMarkdown|buildSidebar"
Expected:
# pass
Task 4: Implement And Verify The Import Pipeline In Isolation
Files:
-
Create:
scripts/lib/run-import.mjs -
Test:
npm test -- --test-name-pattern "runImport" -
Step 1: Implement the importer orchestration as a dependency-injected function
Create scripts/lib/run-import.mjs:
import path from 'node:path';
import {createSlugger} from './normalize-title.mjs';
import {rewriteMarkdown} from './rewrite-links.mjs';
import {buildSidebar} from './build-sidebar.mjs';
function withFrontMatter(title, slug, body) {
return `---\ntitle: ${title}\nslug: /docs/${slug}\n---\n\n${body}\n`;
}
async function walkTree(client, nodeToken) {
const node = await client.getNode(nodeToken);
const children = [];
for (const child of node.children) {
children.push(await walkTree(client, child.node_token));
}
return {...node, children};
}
function assignRoutes(node, slugger, rootNodeToken, parentParts = []) {
const isRoot = node.node_token === rootNodeToken;
const selfSlug = isRoot ? 'intro' : slugger(node.title, node.node_token);
if (isRoot) {
return {
...node,
route: selfSlug,
children: node.children.map((child) => assignRoutes(child, slugger, rootNodeToken, [])),
};
}
const folderParts = [...parentParts, selfSlug];
const route = node.children.length ? `${folderParts.join('/')}/index` : folderParts.join('/');
return {
...node,
route,
children: node.children.map((child) => assignRoutes(child, slugger, rootNodeToken, folderParts)),
};
}
function flattenTree(node) {
return [node, ...node.children.flatMap(flattenTree)];
}
export async function runImport({rootNodeToken, docsDir, imageDir, sidebarPath, client, fs}) {
const slugger = createSlugger();
const root = assignRoutes(await walkTree(client, rootNodeToken), slugger, rootNodeToken);
const flatNodes = flattenTree(root);
const routeMap = new Map(flatNodes.map((node) => [node.node_token, `/docs/${node.route}`]));
const imageMap = new Map();
let failureCount = 0;
await fs.mkdir(docsDir, {recursive: true});
await fs.mkdir(imageDir, {recursive: true});
for (const node of flatNodes) {
try {
const markdown = await client.getDocument(node.obj_token);
const imageUrls = [...markdown.matchAll(/!\[[^\]]*\]\((https?:\/\/[^)]+)\)/g)].map((match) => match[1]);
for (const [index, imageUrl] of imageUrls.entries()) {
if (imageMap.has(imageUrl)) {
continue;
}
const filename = `${node.route.replaceAll('/', '-')}-${index + 1}.png`;
const assetPath = path.join(imageDir, filename);
await client.downloadImage(imageUrl, assetPath);
imageMap.set(imageUrl, `/img/feishu/${filename}`);
}
const rewritten = rewriteMarkdown(markdown, {routeMap, imageMap});
const filePath = path.join(docsDir, `${node.route}.md`);
await fs.mkdir(path.dirname(filePath), {recursive: true});
await fs.writeFile(filePath, withFrontMatter(node.title, node.route, rewritten));
} catch (error) {
failureCount += 1;
}
}
const sidebarTree = buildSidebar(root);
await fs.writeFile(sidebarPath, `export default ${JSON.stringify(sidebarTree, null, 2)};\n`);
return {
documentCount: flatNodes.length,
failureCount,
};
}
- Step 2: Run the importer orchestration test and confirm it passes
Run:
npm test -- --test-name-pattern "runImport"
Expected:
# pass
- Step 3: Run the full test suite and confirm all helper and orchestration tests pass together
Run:
npm test
Expected:
# tests 4
# pass 4
Task 5: Implement The Live Feishu Client And CLI Entry Point
Files:
-
Create:
scripts/lib/feishu-client.mjs,scripts/import-feishu-wiki.mjs -
Modify:
package.json -
Test:
node scripts/import-feishu-wiki.mjs --help -
Step 1: Add an npm script for the one-time importer
Update the scripts section in package.json to include:
{
"scripts": {
"import:feishu": "node scripts/import-feishu-wiki.mjs"
}
}
- Step 2: Implement the
lark-cliwrapper for wiki reads, doc fetches, and image downloads
Create scripts/lib/feishu-client.mjs:
import {execFile} from 'node:child_process';
import {promisify} from 'node:util';
import * as fs from 'node:fs/promises';
const execFileAsync = promisify(execFile);
const SPACE_ID = '7494479548433448962';
function parseJsonPayload(stdout) {
const start = stdout.indexOf('{');
return JSON.parse(stdout.slice(start));
}
async function runLark(args) {
const {stdout} = await execFileAsync('lark-cli', args, {
maxBuffer: 10 * 1024 * 1024,
});
return parseJsonPayload(stdout);
}
async function listChildren(parentNodeToken) {
const response = await runLark([
'wiki',
'+node-list',
'--as',
'user',
'--space-id',
SPACE_ID,
'--parent-node-token',
parentNodeToken,
'--page-all',
'--format',
'json',
]);
return response.data.nodes;
}
export function createFeishuClient() {
return {
async getNode(nodeToken) {
const nodeResponse = await runLark([
'wiki',
'spaces',
'get_node',
'--as',
'user',
'--params',
JSON.stringify({token: nodeToken}),
'--format',
'json',
]);
const children = nodeResponse.data.node.has_child ? await listChildren(nodeToken) : [];
return {
node_token: nodeResponse.data.node.node_token,
title: nodeResponse.data.node.title.trim(),
obj_token: nodeResponse.data.node.obj_token,
children: children.map((child) => ({
node_token: child.node_token,
title: child.title.trim(),
obj_token: child.obj_token,
children: [],
})),
};
},
async getDocument(docToken) {
const response = await runLark([
'docs',
'+fetch',
'--as',
'user',
'--api-version',
'v2',
'--doc',
docToken,
'--doc-format',
'markdown',
'--format',
'json',
]);
return response.data.document.content;
},
async downloadImage(url, outputPath) {
const response = await fetch(url);
const buffer = Buffer.from(await response.arrayBuffer());
await fs.writeFile(outputPath, buffer);
return outputPath;
},
};
}
- Step 3: Implement the CLI entry point that writes docs and the sidebar to the site
Create scripts/import-feishu-wiki.mjs:
import path from 'node:path';
import * as fs from 'node:fs/promises';
import {createFeishuClient} from './lib/feishu-client.mjs';
import {runImport} from './lib/run-import.mjs';
const ROOT_NODE_TOKEN = 'NclNwZsMKi2L07kpYW1czK9Nnv7';
const cwd = process.cwd();
async function main() {
if (process.argv.includes('--help')) {
console.log('Usage: npm run import:feishu');
return;
}
const summary = await runImport({
rootNodeToken: ROOT_NODE_TOKEN,
docsDir: path.join(cwd, 'docs'),
imageDir: path.join(cwd, 'static', 'img', 'feishu'),
sidebarPath: path.join(cwd, 'sidebars.ts'),
client: createFeishuClient(),
fs,
});
console.log(JSON.stringify(summary, null, 2));
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
- Step 4: Verify the CLI entry point is reachable before running the live import
Run:
node scripts/import-feishu-wiki.mjs --help
Expected:
Usage: npm run import:feishu
Task 6: Run The Live Import And Verify The Generated Site
Files:
-
Modify:
docs/**/*.md,static/img/feishu/*,sidebars.ts -
Test:
npm run import:feishu,npm run build -
Step 1: Run the importer against the live Feishu tree
Run:
npm run import:feishu
Expected:
{
"documentCount": 39,
"failureCount": 0
}
- Step 2: Verify the expected document count landed on disk
Run:
find docs -name '*.md' | wc -l
Expected:
39
- Step 3: Build the generated Docusaurus site
Run:
npm run build
Expected:
[SUCCESS] Generated static files in "build".
- Step 4: Spot-check one imported page with internal and external links
Run:
sed -n '1,220p' docs/帮助中心/获取支持.md
Expected:
---
title: 获取支持
slug: /docs/帮助中心/获取支持
---
And confirm in the body:
- internal migrated links use /docs/...
- external Feishu links remain https://my.feishu.cn/...
- Step 5: Spot-check one imported page with images and one with tables/code blocks
Run:
sed -n '1,220p' docs/intro.md
sed -n '1,260p' docs/API文档/openai格式api.md
Expected:
images point to /img/feishu/...
tables and fenced code blocks remain valid Markdown
- Step 6: Confirm no attachment files were imported into the static asset tree
Run:
find static/img/feishu -type f | rg -v '\.(png|jpg|jpeg|gif|webp|svg)$' || true
Expected:
[no output]
Self-Review
- Spec coverage: the plan covers scaffold, import logic, image migration, internal and external link handling, attachment exclusion, sidebar generation, and build verification.
- Placeholder scan: no
TODO,TBD, or deferred implementation notes remain. - Type consistency: helper names and file paths are consistent across tasks.