diff --git a/packages/sdks/astro/package.json b/packages/sdks/astro/package.json
index 4aec62d0..957b20dd 100644
--- a/packages/sdks/astro/package.json
+++ b/packages/sdks/astro/package.json
@@ -3,7 +3,8 @@
"version": "1.0.6-local",
"config": {
"transformPackageJson": false,
- "transformEnvs": true
+ "transformEnvs": true,
+ "docPath": "apps/public/content/docs/(tracking)/sdks/astro.mdx"
},
"exports": {
".": "./index.ts"
diff --git a/packages/sdks/express/package.json b/packages/sdks/express/package.json
index e3ed3c74..af915f9b 100644
--- a/packages/sdks/express/package.json
+++ b/packages/sdks/express/package.json
@@ -2,6 +2,9 @@
"name": "@openpanel/express",
"version": "1.0.4-local",
"module": "index.ts",
+ "config": {
+ "docPath": "apps/public/content/docs/(tracking)/sdks/express.mdx"
+ },
"scripts": {
"build": "rm -rf dist && tsup",
"typecheck": "tsc --noEmit"
diff --git a/packages/sdks/nextjs/package.json b/packages/sdks/nextjs/package.json
index 93aa0591..85825517 100644
--- a/packages/sdks/nextjs/package.json
+++ b/packages/sdks/nextjs/package.json
@@ -2,6 +2,9 @@
"name": "@openpanel/nextjs",
"version": "1.1.2-local",
"module": "index.ts",
+ "config": {
+ "docPath": "apps/public/content/docs/(tracking)/sdks/nextjs.mdx"
+ },
"scripts": {
"build": "rm -rf dist && tsup",
"typecheck": "tsc --noEmit"
diff --git a/packages/sdks/nuxt/package.json b/packages/sdks/nuxt/package.json
index 5a314d2e..ff5ab634 100644
--- a/packages/sdks/nuxt/package.json
+++ b/packages/sdks/nuxt/package.json
@@ -12,7 +12,8 @@
"files": ["dist"],
"config": {
"transformPackageJson": false,
- "transformEnvs": false
+ "transformEnvs": false,
+ "docPath": "apps/public/content/docs/(tracking)/sdks/nuxt.mdx"
},
"scripts": {
"build": "npx nuxt-module-build build",
diff --git a/packages/sdks/react-native/package.json b/packages/sdks/react-native/package.json
index 20797b9a..d383de97 100644
--- a/packages/sdks/react-native/package.json
+++ b/packages/sdks/react-native/package.json
@@ -2,6 +2,9 @@
"name": "@openpanel/react-native",
"version": "1.0.4-local",
"module": "index.ts",
+ "config": {
+ "docPath": "apps/public/content/docs/(tracking)/sdks/react-native.mdx"
+ },
"scripts": {
"build": "rm -rf dist && tsup",
"typecheck": "tsc --noEmit"
diff --git a/packages/sdks/sdk/package.json b/packages/sdks/sdk/package.json
index cd813c0a..9d91eae2 100644
--- a/packages/sdks/sdk/package.json
+++ b/packages/sdks/sdk/package.json
@@ -2,6 +2,9 @@
"name": "@openpanel/sdk",
"version": "1.0.3-local",
"module": "index.ts",
+ "config": {
+ "docPath": "apps/public/content/docs/(tracking)/sdks/javascript.mdx"
+ },
"scripts": {
"build": "rm -rf dist && tsup",
"typecheck": "tsc --noEmit"
diff --git a/packages/sdks/web/package.json b/packages/sdks/web/package.json
index 805f2c17..aaca88df 100644
--- a/packages/sdks/web/package.json
+++ b/packages/sdks/web/package.json
@@ -2,6 +2,9 @@
"name": "@openpanel/web",
"version": "1.0.6-local",
"module": "index.ts",
+ "config": {
+ "docPath": "apps/public/content/docs/(tracking)/sdks/web.mdx"
+ },
"scripts": {
"build": "rm -rf dist && tsup",
"typecheck": "tsc --noEmit"
diff --git a/tooling/publish/generate-readme.ts b/tooling/publish/generate-readme.ts
new file mode 100644
index 00000000..4d2332a6
--- /dev/null
+++ b/tooling/publish/generate-readme.ts
@@ -0,0 +1,234 @@
+import fs from 'node:fs';
+import { join, resolve } from 'node:path';
+import { dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+import type { PackageInfo } from './publish';
+
+const workspacePath = (relativePath: string) =>
+ resolve(__dirname, '../../', relativePath);
+
+const dedentContent = (text: string): string => {
+ const lines = text.split('\n');
+ if (lines.length === 0) return text;
+
+ // Find the minimum indentation (excluding empty lines)
+ // We'll dedent code blocks too, so include them in the calculation
+ let minIndent = Number.POSITIVE_INFINITY;
+ for (const line of lines) {
+ const trimmed = line.trim();
+ // Skip empty lines
+ if (trimmed.length === 0) continue;
+ const indent = line.match(/^(\s*)/)?.[1]?.length ?? 0;
+ if (indent < minIndent) minIndent = indent;
+ }
+
+ // If no indentation found, return as-is
+ if (minIndent === Number.POSITIVE_INFINITY || minIndent === 0) return text;
+
+ // Remove the common indentation from all lines
+ return lines
+ .map((line) => {
+ // For lines shorter than minIndent, just return them as-is (preserves empty lines)
+ if (line.length < minIndent) return line;
+ // Remove the common indentation
+ const dedented = line.slice(minIndent);
+ // If the line was all whitespace, return empty string to preserve the line
+ return line.trim().length === 0 ? '' : dedented;
+ })
+ .join('\n');
+};
+
+const transformMdxToReadme = (
+ mdxContent: string,
+ packageName: string,
+): string => {
+ let content = mdxContent;
+
+ // Load MDX component content files
+ const commonSdkConfigPath = workspacePath(
+ 'apps/public/src/components/common-sdk-config.mdx',
+ );
+ const webSdkConfigPath = workspacePath(
+ 'apps/public/src/components/web-sdk-config.mdx',
+ );
+
+ let commonSdkConfigContent = '';
+ let webSdkConfigContent = '';
+
+ try {
+ if (fs.existsSync(commonSdkConfigPath)) {
+ commonSdkConfigContent = fs.readFileSync(commonSdkConfigPath, 'utf-8');
+ }
+ } catch {
+ // Ignore if file doesn't exist
+ }
+
+ try {
+ if (fs.existsSync(webSdkConfigPath)) {
+ webSdkConfigContent = fs.readFileSync(webSdkConfigPath, 'utf-8');
+ }
+ } catch {
+ // Ignore if file doesn't exist
+ }
+
+ // Extract title from frontmatter
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n/);
+ let title = packageName;
+ let description = '';
+
+ if (frontmatterMatch?.[1]) {
+ const frontmatter = frontmatterMatch[1];
+ const titleMatch = frontmatter.match(/^title:\s*(.+)$/m);
+ const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
+ if (titleMatch?.[1]) title = titleMatch[1].trim();
+ if (descMatch?.[1]) description = descMatch[1].trim();
+
+ // Remove frontmatter
+ content = content.replace(/^---\n[\s\S]*?\n---\n/, '');
+ }
+
+ // Replace MDX component references with their actual content
+ // This must happen before code block protection so component content is also protected
+ if (commonSdkConfigContent) {
+ content = content.replace(
+ //g,
+ `\n${commonSdkConfigContent}\n`,
+ );
+ }
+ if (webSdkConfigContent) {
+ content = content.replace(
+ //g,
+ `\n${webSdkConfigContent}\n`,
+ );
+ }
+
+ // Protect code blocks from transformation
+ // Extract code blocks before any transformations to preserve their content
+ const codeBlockPlaceholders: string[] = [];
+ // Match code blocks: ```language (optional) followed by content until closing ```
+ // Using [\s\S] to match across newlines, non-greedy to stop at first closing ```
+ const codeBlockRegex = /```[\s\S]*?```/g;
+
+ // Extract and replace code blocks with placeholders
+ content = content.replace(codeBlockRegex, (match) => {
+ const placeholder = `__CODE_BLOCK_${codeBlockPlaceholders.length}__`;
+ codeBlockPlaceholders.push(match);
+ return placeholder;
+ });
+
+ // Remove import statements (outside code blocks)
+ content = content.replace(/^import\s+.*$/gm, '');
+
+ // Handle Tabs component specially - convert to markdown sections
+ // Extract tabs items from items prop
+ const tabsItemsMatch = content.match(//);
+ const tabsItems = tabsItemsMatch?.[1]
+ ? tabsItemsMatch[1]
+ .replace(/['"]/g, '')
+ .split(',')
+ .map((item) => item.trim())
+ : [];
+
+ // Replace Tabs/Tab structure with markdown sections
+ if (tabsItems.length > 0) {
+ // Match each Tab and convert to a markdown section
+ content = content.replace(
+ /([\s\S]*?)<\/Tab>/g,
+ (match, value, tabContent) => {
+ const dedented = dedentContent(tabContent).trim();
+ return `\n#### ${value}\n\n${dedented}\n\n`;
+ },
+ );
+ // Remove the Tabs wrapper
+ content = content.replace(/]*>([\s\S]*?)<\/Tabs>/g, '$1');
+ } else {
+ // Fallback: if no items prop, just convert tabs to sections
+ content = content.replace(
+ /([\s\S]*?)<\/Tab>/g,
+ (match, value, tabContent) => {
+ const dedented = dedentContent(tabContent).trim();
+ return `\n#### ${value}\n\n${dedented}\n\n`;
+ },
+ );
+ content = content.replace(/]*>([\s\S]*?)<\/Tabs>/g, '$1');
+ }
+
+ // Remove self-closing JSX components (like , )
+ content = content.replace(/<[A-Z][a-zA-Z]*[^>]*\/>/g, '');
+
+ // Remove JSX component tags but preserve content between opening/closing tags
+ // Handle nested components by recursively removing outer tags
+ // This regex matches opening tag, captures content (including nested tags), and closing tag
+ let previousContent = '';
+ while (content !== previousContent) {
+ previousContent = content;
+ // Match JSX components with their content - handles one level of nesting
+ content = content.replace(
+ /<([A-Z][a-zA-Z]*)[^>]*>([\s\S]*?)<\/\1>/g,
+ (match, tagName, innerContent) => {
+ return dedentContent(innerContent).trim();
+ },
+ );
+ }
+
+ // Remove any remaining JSX tags (self-closing or unmatched)
+ content = content.replace(/<\/?[A-Z][a-zA-Z]*[^>]*>/g, '');
+
+ // Restore code blocks
+ codeBlockPlaceholders.forEach((codeBlock, index) => {
+ content = content.replace(`__CODE_BLOCK_${index}__`, codeBlock);
+ });
+
+ // Convert internal links (starting with /) to absolute URLs
+ content = content.replace(
+ /\[([^\]]+)\]\((\/[^\)]+)\)/g,
+ '[$1](https://openpanel.dev$2)',
+ );
+
+ // Clean up extra blank lines
+ content = content.replace(/\n{3,}/g, '\n\n').trim();
+
+ // Build the README header
+ const docUrl = `https://openpanel.dev/docs/sdks/${packageName.replace('@openpanel/', '')}`;
+ let readme = `# ${title}\n\n`;
+
+ if (description) {
+ readme += `${description}\n\n`;
+ }
+
+ readme += `> ๐ **Full documentation:** [${docUrl}](${docUrl})\n\n`;
+ readme += '---\n\n';
+ readme += content;
+
+ return readme;
+};
+
+export const generateReadme = (
+ packages: Record,
+ dependents: string[],
+): string[] => {
+ const generatedReadmes: string[] = [];
+ for (const dep of dependents) {
+ const pkg = packages[dep];
+ const docPath = pkg?.config?.docPath;
+ if (!docPath) {
+ console.log(
+ `๐ Skipping README generation for ${dep} (no docPath configured)`,
+ );
+ continue;
+ }
+
+ const packagePath = workspacePath(pkg.localPath);
+ const readmePath = join(packagePath, 'README.md');
+ console.log(`๐ Generating README for ${dep}`);
+ const mdxContent = fs.readFileSync(workspacePath(docPath), 'utf-8');
+ const readmeContent = transformMdxToReadme(mdxContent, pkg.name);
+ fs.writeFileSync(readmePath, readmeContent, 'utf-8');
+ generatedReadmes.push(readmePath);
+ }
+ return generatedReadmes;
+};
diff --git a/tooling/publish/publish.ts b/tooling/publish/publish.ts
index a2b79b44..3d50c801 100644
--- a/tooling/publish/publish.ts
+++ b/tooling/publish/publish.ts
@@ -3,12 +3,13 @@ import fs from 'node:fs';
import { join, resolve } from 'node:path';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
-
-const __filename = fileURLToPath(import.meta.url);
-const __dirname = dirname(__filename);
import arg from 'arg';
import type { ReleaseType } from 'semver';
import semver, { RELEASE_TYPES } from 'semver';
+import { generateReadme } from './generate-readme';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
// Types
interface PackageJson {
@@ -20,11 +21,12 @@ interface PackageJson {
[key: string]: unknown;
config?: {
transformPackageJson?: boolean;
- transformEnvs: boolean;
+ transformEnvs?: boolean;
+ docPath?: string;
};
}
-interface PackageInfo extends PackageJson {
+export interface PackageInfo extends PackageJson {
nextVersion: string;
localPath: string;
}
@@ -146,7 +148,7 @@ const updatePackageJsonForRelease = (
main: './dist/index.js',
module: './dist/index.js',
types: './dist/index.d.ts',
- files: ['dist'],
+ files: ['dist', 'README.md'],
exports: restPkgJson.exports ?? {
'.': {
import: './dist/index.js',
@@ -266,6 +268,7 @@ const publishPackages = (
const restoreAndUpdateLocal = (
packages: Record,
dependents: string[],
+ generatedReadmes: string[],
): void => {
const filesToRestore = dependents
.map((dep) => join(workspacePath(packages[dep]!.localPath), 'package.json'))
@@ -273,6 +276,14 @@ const restoreAndUpdateLocal = (
execSync(`git checkout ${filesToRestore}`);
+ // Clean up auto-generated README files
+ for (const readmePath of generatedReadmes) {
+ if (fs.existsSync(readmePath)) {
+ console.log(`๐งน Removing auto-generated README: ${readmePath}`);
+ fs.unlinkSync(readmePath);
+ }
+ }
+
for (const dep of dependents) {
const { nextVersion, localPath, ...restPkgJson } = packages[dep]!;
console.log(`๐ Updating ${dep} (${nextVersion}-local)`);
@@ -356,6 +367,8 @@ function main() {
buildPackages(packages, dependents);
+ const generatedReadmes = generateReadme(packages, dependents);
+
if (args['--publish']) {
const config: PublishConfig = {
registry: args['--npm']
@@ -365,7 +378,7 @@ function main() {
};
publishPackages(packages, dependents, config);
- restoreAndUpdateLocal(originalPackages, dependents);
+ restoreAndUpdateLocal(originalPackages, dependents, generatedReadmes);
}
console.log('โ
All done!');