跳到主要内容

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 = '![](https://internal-api-drive-stream.feishu.cn/path/image.png)';
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, '![](/img/feishu/intro-1.png)');
});
  • 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![](https://img.local/root.png)'
: '# 获取支持';
},
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 ? `![${alt}](${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-cli wrapper 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.