From c932220e4bb89a9f1ea9a12baa1c2ee79552e54e Mon Sep 17 00:00:00 2001 From: jianliang00 Date: Mon, 11 May 2026 18:42:44 +0800 Subject: [PATCH] feat(autolink): add autolink codegen package --- .changeset/native-autolink-codegen.md | 5 + CODEOWNERS | 1 + packages/lynx/autolink-codegen/CHANGELOG.md | 7 + packages/lynx/autolink-codegen/README.md | 30 + packages/lynx/autolink-codegen/package.json | 41 + .../lynx/autolink-codegen/rslib.config.ts | 20 + packages/lynx/autolink-codegen/src/cli.ts | 90 ++ packages/lynx/autolink-codegen/src/index.ts | 987 ++++++++++++++++++ .../autolink-codegen/test/codegen.test.ts | 425 ++++++++ .../lynx/autolink-codegen/tsconfig.build.json | 14 + packages/lynx/autolink-codegen/tsconfig.json | 7 + .../lynx/autolink-codegen/tsconfig.test.json | 10 + packages/lynx/autolink-codegen/turbo.json | 11 + .../lynx/autolink-codegen/vitest.config.ts | 11 + packages/lynx/tsconfig.json | 1 + pnpm-lock.yaml | 2 + vitest.config.ts | 2 +- 17 files changed, 1663 insertions(+), 1 deletion(-) create mode 100644 .changeset/native-autolink-codegen.md create mode 100644 packages/lynx/autolink-codegen/CHANGELOG.md create mode 100644 packages/lynx/autolink-codegen/README.md create mode 100644 packages/lynx/autolink-codegen/package.json create mode 100644 packages/lynx/autolink-codegen/rslib.config.ts create mode 100644 packages/lynx/autolink-codegen/src/cli.ts create mode 100644 packages/lynx/autolink-codegen/src/index.ts create mode 100644 packages/lynx/autolink-codegen/test/codegen.test.ts create mode 100644 packages/lynx/autolink-codegen/tsconfig.build.json create mode 100644 packages/lynx/autolink-codegen/tsconfig.json create mode 100644 packages/lynx/autolink-codegen/tsconfig.test.json create mode 100644 packages/lynx/autolink-codegen/turbo.json create mode 100644 packages/lynx/autolink-codegen/vitest.config.ts diff --git a/.changeset/native-autolink-codegen.md b/.changeset/native-autolink-codegen.md new file mode 100644 index 0000000000..d2b8568be3 --- /dev/null +++ b/.changeset/native-autolink-codegen.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/autolink-codegen": minor +--- + +Add the Native Autolink codegen package. diff --git a/CODEOWNERS b/CODEOWNERS index f506de663c..a02edb24d1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -3,6 +3,7 @@ packages/web-platform/** @pupiltong @Sherry-hue packages/webpack/** @colinaaa @upupming @luhc228 packages/rspeedy/** @colinaaa @upupming @luhc228 packages/i18n/** @luhc228 +packages/lynx/autolink-codegen/** @jianliang00 packages/rspeedy/plugin-react/** @upupming packages/react/** @hzy @HuJean @Yradex packages/react/transform/** @gaoachao diff --git a/packages/lynx/autolink-codegen/CHANGELOG.md b/packages/lynx/autolink-codegen/CHANGELOG.md new file mode 100644 index 0000000000..1859899390 --- /dev/null +++ b/packages/lynx/autolink-codegen/CHANGELOG.md @@ -0,0 +1,7 @@ +# @lynx-js/autolink-codegen + +## 0.0.0 + +### Minor Changes + +- Initial Native Autolink codegen package. diff --git a/packages/lynx/autolink-codegen/README.md b/packages/lynx/autolink-codegen/README.md new file mode 100644 index 0000000000..64a142e28a --- /dev/null +++ b/packages/lynx/autolink-codegen/README.md @@ -0,0 +1,30 @@ +# @lynx-js/autolink-codegen + +Native Autolink code generator for Lynx extensions. + +It scans `types/**/*.d.ts` for native module declarations annotated with +`/** @lynxmodule */`, reads `lynx.ext.json`, and generates: + +- `generated/.ts` +- Android `Spec.java` +- iOS `Spec.h` and `Spec.m` + +Run it from an extension package: + +```bash +npx @lynx-js/autolink-codegen +``` + +The installed binary name is `lynx-autolink-codegen`, so generated extensions +can use: + +```json +{ + "scripts": { + "codegen": "lynx-autolink-codegen" + } +} +``` + +The first version intentionally supports only Native Autolink. Web Autolink and +Web spec generation are outside this package. diff --git a/packages/lynx/autolink-codegen/package.json b/packages/lynx/autolink-codegen/package.json new file mode 100644 index 0000000000..460b64eaaa --- /dev/null +++ b/packages/lynx/autolink-codegen/package.json @@ -0,0 +1,41 @@ +{ + "name": "@lynx-js/autolink-codegen", + "version": "0.0.0", + "description": "Native Autolink code generator for Lynx extensions", + "keywords": [ + "Lynx", + "Autolink", + "codegen" + ], + "repository": { + "type": "git", + "url": "https://github.com/lynx-family/lynx-stack.git", + "directory": "packages/lynx/autolink-codegen" + }, + "license": "Apache-2.0", + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "bin": { + "lynx-autolink-codegen": "./dist/cli.js" + }, + "files": [ + "CHANGELOG.md", + "README.md", + "dist" + ], + "scripts": { + "build": "rslib build", + "dev": "rslib build --watch", + "test": "vitest run" + }, + "packageManager": "pnpm@11.0.8+sha512.4c4097e1dd2d42372c4e7fa5a791ff28fc75a484c7ac192e64b1df0fdef17594ba982f9b4fed9adfb3c757846f565b799b2763fb3733d1de1bcb82cf46684912", + "engines": { + "node": "^20 || ^22 || ^24" + } +} diff --git a/packages/lynx/autolink-codegen/rslib.config.ts b/packages/lynx/autolink-codegen/rslib.config.ts new file mode 100644 index 0000000000..c85237bcf0 --- /dev/null +++ b/packages/lynx/autolink-codegen/rslib.config.ts @@ -0,0 +1,20 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import type { RslibConfig } from '@rslib/core'; +import { defineConfig } from '@rslib/core'; + +const config: RslibConfig = defineConfig({ + lib: [ + { format: 'esm', syntax: 'es2022', dts: { bundle: true, tsgo: true } }, + ], + source: { + entry: { + index: './src/index.ts', + cli: './src/cli.ts', + }, + tsconfigPath: './tsconfig.build.json', + }, +}); + +export default config; diff --git a/packages/lynx/autolink-codegen/src/cli.ts b/packages/lynx/autolink-codegen/src/cli.ts new file mode 100644 index 0000000000..ab90428197 --- /dev/null +++ b/packages/lynx/autolink-codegen/src/cli.ts @@ -0,0 +1,90 @@ +#!/usr/bin/env node +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import path from 'node:path'; + +import type { CodegenOptions } from './index.js'; +import { runCodegen } from './index.js'; + +interface CliOptions { + root?: string; + help: boolean; +} + +/** + * Parses command-line arguments for the autolink codegen CLI. + */ +function parseArgs(argv: string[]): CliOptions { + const options: CliOptions = { help: false }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === '--help' || arg === '-h') { + options.help = true; + continue; + } + + if (arg === '--root' || arg === '-r') { + const value = argv[index + 1]; + + if (value === undefined || value.startsWith('-')) { + throw new Error(`${arg} requires a value`); + } + + options.root = value; + index += 1; + continue; + } + + throw new Error(`Unknown option "${arg ?? ''}"`); + } + + return options; +} + +/** + * Prints usage information for the autolink codegen CLI. + */ +function printHelp(): void { + console.info(`Usage: lynx-autolink-codegen [--root ] + +Generate Native Autolink JS, Android, and iOS specs from types/**/*.d.ts. + +Options: + --root, -r Extension package root. Defaults to the current directory. + --help, -h Show this help message. +`); +} + +/** + * Runs code generation from parsed CLI options. + */ +function main(): void { + const cliOptions = parseArgs(process.argv.slice(2)); + + if (cliOptions.help) { + printHelp(); + return; + } + + const options: CodegenOptions = {}; + + if (cliOptions.root !== undefined) { + options.root = path.resolve(cliOptions.root); + } + + const files = runCodegen(options); + + for (const file of files) { + console.info(`generated ${file.path}`); + } +} + +try { + main(); +} catch (error: unknown) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} diff --git a/packages/lynx/autolink-codegen/src/index.ts b/packages/lynx/autolink-codegen/src/index.ts new file mode 100644 index 0000000000..39f3c3afbf --- /dev/null +++ b/packages/lynx/autolink-codegen/src/index.ts @@ -0,0 +1,987 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import fs from 'node:fs'; +import path from 'node:path'; + +export interface CodegenOptions { + root?: string; +} + +export interface GeneratedFile { + path: string; + content: string; +} + +export type NativeModuleTypeName = 'void' | 'string' | 'number' | 'boolean'; + +export interface NativeModuleType { + name: NativeModuleTypeName; + nullable: boolean; +} + +export interface NativeModuleParam { + name: string; + type: NativeModuleType; +} + +export interface NativeModuleMethod { + name: string; + params: NativeModuleParam[]; + returnType: NativeModuleType; +} + +export interface NativeModuleSpec { + name: string; + methods: NativeModuleMethod[]; +} + +interface LynxExtJson { + platforms: { + android: { + packageName: string; + sourceDir: string; + }; + ios: { + sourceDir: string; + }; + }; +} + +const MODULE_HEADER_PATTERN = + /\/\*\*[\s\S]*?@lynxmodule[\s\S]*?\*\/\s*export\s+declare\s+class\s+([A-Za-z_$][\w$]*)\s*\{/g; +const IDENTIFIER_PATTERN = /^[A-Z_$][\w$]*$/i; +const JAVA_PACKAGE_NAME_PATTERN = /^[A-Z_]\w*(?:\.[A-Z_]\w*)*$/i; + +/** + * Parses native module declarations marked with `@lynxmodule` from a TypeScript declaration source. + */ +export function parseNativeModules( + source: string, + filename = '', +): NativeModuleSpec[] { + const modules: NativeModuleSpec[] = []; + const seen = new Set(); + + for ( + const { body, name: moduleName } of findNativeModuleDeclarations( + source, + filename, + ) + ) { + if (seen.has(moduleName)) { + throw new Error(`Duplicate native module "${moduleName}" in ${filename}`); + } + seen.add(moduleName); + + modules.push({ + name: moduleName, + methods: parseMethods(body, filename, moduleName), + }); + } + + return modules; +} + +/** + * Finds native module declarations and captures class bodies while ignoring braces in comments and strings. + */ +function findNativeModuleDeclarations( + source: string, + filename: string, +): Array<{ name: string; body: string }> { + const declarations: Array<{ name: string; body: string }> = []; + const pattern = new RegExp(MODULE_HEADER_PATTERN); + let match = pattern.exec(source); + + while (match !== null) { + const moduleName = match[1]; + const matchedHeader = match[0]; + + if (moduleName === undefined) { + match = pattern.exec(source); + continue; + } + + const openBraceIndex = match.index + matchedHeader.length - 1; + const closeBraceIndex = findMatchingBrace(source, openBraceIndex); + + if (closeBraceIndex === -1) { + throw new Error( + `Invalid native module declaration in ${filename}: ${moduleName} is missing a closing brace`, + ); + } + + declarations.push({ + name: moduleName, + body: source.slice(openBraceIndex + 1, closeBraceIndex), + }); + + pattern.lastIndex = closeBraceIndex + 1; + match = pattern.exec(source); + } + + return declarations; +} + +/** + * Returns the matching `}` for a class body opener, ignoring comments and strings. + */ +function findMatchingBrace(source: string, openBraceIndex: number): number { + let braceDepth = 1; + let inBlockComment = false; + let inLineComment = false; + let quote: string | undefined; + let escaped = false; + + for (let index = openBraceIndex + 1; index < source.length; index += 1) { + const character = source.charAt(index); + const next = source.charAt(index + 1); + + if (inBlockComment) { + if (character === '*' && next === '/') { + index += 1; + inBlockComment = false; + } + continue; + } + + if (inLineComment) { + if (character === '\n' || character === '\r') { + inLineComment = false; + } + continue; + } + + if (quote !== undefined) { + if (escaped) { + escaped = false; + continue; + } + + if (character === '\\') { + escaped = true; + continue; + } + + if (character === quote) { + quote = undefined; + } + continue; + } + + if (character === '/' && next === '*') { + index += 1; + inBlockComment = true; + continue; + } + + if (character === '/' && next === '/') { + index += 1; + inLineComment = true; + continue; + } + + if (character === '\'' || character === '"' || character === '`') { + quote = character; + continue; + } + + if (character === '{') { + braceDepth += 1; + continue; + } + + if (character === '}') { + braceDepth -= 1; + + if (braceDepth === 0) { + return index; + } + } + } + + return -1; +} + +/** + * Builds the generated JS facade, Android spec, and iOS spec file contents for an extension package. + */ +export function generate(options: CodegenOptions = {}): GeneratedFile[] { + const root = path.resolve(options.root ?? process.cwd()); + const manifest = readManifest(root); + const modules = readNativeModuleSpecs(root); + const seenModules = new Set(); + const files: GeneratedFile[] = []; + + for (const module of modules) { + if (seenModules.has(module.name)) { + throw new Error(`Duplicate native module "${module.name}" across types`); + } + seenModules.add(module.name); + + files.push({ + path: path.posix.join('generated', `${module.name}.ts`), + content: generateJsFacade(module), + }); + files.push({ + path: path.posix.join( + manifest.platforms.android.sourceDir, + 'src', + 'main', + 'java', + ...manifest.platforms.android.packageName.split('.'), + 'generated', + `${module.name}Spec.java`, + ), + content: generateAndroidSpec( + module, + manifest.platforms.android.packageName, + ), + }); + files.push({ + path: path.posix.join( + manifest.platforms.ios.sourceDir, + 'src', + 'generated', + `${module.name}Spec.h`, + ), + content: generateIosHeader(module), + }); + files.push({ + path: path.posix.join( + manifest.platforms.ios.sourceDir, + 'src', + 'generated', + `${module.name}Spec.m`, + ), + content: generateIosImplementation(module), + }); + } + + return files; +} + +/** + * Writes generated files to disk and returns the generated file descriptors. + */ +export function runCodegen(options: CodegenOptions = {}): GeneratedFile[] { + const root = path.resolve(options.root ?? process.cwd()); + const files = generate({ root }); + const targets = files.map((file) => ({ + file, + target: resolveInside(root, file.path, 'package root'), + })); + + for (const { target } of targets) { + assertNoSymlinkTraversal(root, target); + } + + for (const { file, target } of targets) { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, file.content); + } + + return files; +} + +/** + * Parses method signatures from a native module declaration body. + */ +function parseMethods( + body: string, + filename: string, + moduleName: string, +): NativeModuleMethod[] { + const methods: NativeModuleMethod[] = []; + const seen = new Set(); + + for (const trimmed of splitMethodDeclarations(body, filename, moduleName)) { + const openParen = trimmed.indexOf('('); + const closeParen = trimmed.lastIndexOf(')'); + const returnColon = closeParen === -1 + ? -1 + : trimmed.indexOf(':', closeParen); + + if ( + openParen <= 0 || closeParen <= openParen || returnColon <= closeParen + ) { + throw new Error( + `Invalid method declaration in ${filename}: ${moduleName}.${trimmed}`, + ); + } + + const methodName = trimmed.slice(0, openParen).trim(); + const paramsSource = trimmed.slice(openParen + 1, closeParen); + const returnSource = trimmed.slice(returnColon + 1).trim(); + + if (!IDENTIFIER_PATTERN.test(methodName)) { + throw new Error( + `Invalid method name "${methodName}" in ${filename}: ${moduleName}`, + ); + } + + if (seen.has(methodName)) { + throw new Error( + `Duplicate method "${moduleName}.${methodName}" in ${filename}`, + ); + } + seen.add(methodName); + + methods.push({ + name: methodName, + params: parseParams(paramsSource, filename, moduleName, methodName), + returnType: parseType( + returnSource.trim(), + filename, + `${moduleName}.${methodName} return`, + ), + }); + } + + return methods; +} + +/** + * Removes TypeScript comments while preserving line boundaries and string content. + */ +function stripTypeScriptComments(source: string): string { + let result = ''; + let inBlockComment = false; + let inLineComment = false; + let quote: string | undefined; + let escaped = false; + + for (let index = 0; index < source.length; index += 1) { + const character = source.charAt(index); + const next = source.charAt(index + 1); + + if (inBlockComment) { + if (character === '\n' || character === '\r') { + result += character; + } else { + result += ' '; + } + + if (character === '*' && next === '/') { + result += ' '; + index += 1; + inBlockComment = false; + } + continue; + } + + if (inLineComment) { + if (character === '\n' || character === '\r') { + result += character; + inLineComment = false; + } else { + result += ' '; + } + continue; + } + + if (quote !== undefined) { + result += character; + + if (escaped) { + escaped = false; + continue; + } + + if (character === '\\') { + escaped = true; + continue; + } + + if (character === quote) { + quote = undefined; + } + continue; + } + + if (character === '/' && next === '*') { + result += ' '; + index += 1; + inBlockComment = true; + continue; + } + + if (character === '/' && next === '/') { + result += ' '; + index += 1; + inLineComment = true; + continue; + } + + if (character === '\'' || character === '"' || character === '`') { + quote = character; + } + + result += character; + } + + return result; +} + +/** + * Splits a module body into method declarations while ignoring comments and accepting semicolon/newline separators. + */ +function splitMethodDeclarations( + body: string, + filename: string, + moduleName: string, +): string[] { + const declarations: string[] = []; + const source = stripTypeScriptComments(body); + let buffer = ''; + + for (const line of source.split(/\r?\n/)) { + const parts = line.split(';'); + + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]?.trim(); + + if (part !== undefined && part.length > 0) { + buffer = `${buffer} ${part}`.trim(); + } + + if ( + buffer.length > 0 + && (index < parts.length - 1 || isCompleteMethodDeclaration(buffer)) + ) { + declarations.push(buffer); + buffer = ''; + } + } + } + + if (buffer.length > 0) { + throw new Error( + `Invalid method declaration in ${filename}: ${moduleName}.${buffer}`, + ); + } + + return declarations; +} + +/** + * Checks whether a buffered method declaration has balanced parentheses and a return type. + */ +function isCompleteMethodDeclaration(source: string): boolean { + if (source.length === 0) { + return false; + } + + let parenDepth = 0; + + for (const character of source) { + if (character === '(') { + parenDepth += 1; + continue; + } + + if (character === ')') { + parenDepth -= 1; + + if (parenDepth < 0) { + return false; + } + } + } + + if (parenDepth !== 0) { + return false; + } + + const openParen = source.indexOf('('); + const closeParen = source.lastIndexOf(')'); + const returnColon = closeParen === -1 + ? -1 + : source.indexOf(':', closeParen); + + return openParen > 0 && closeParen > openParen && returnColon > closeParen; +} + +/** + * Parses and validates native module method parameters. + */ +function parseParams( + source: string, + filename: string, + moduleName: string, + methodName: string, +): NativeModuleParam[] { + const trimmed = source.trim(); + + if (trimmed.length === 0) { + return []; + } + + const params = trimmed.split(',').filter((paramSource) => + paramSource.trim().length > 0 + ); + + return params.map((paramSource): NativeModuleParam => { + const normalizedParam = paramSource.trim(); + const colon = normalizedParam.indexOf(':'); + + if (colon <= 0 || colon === normalizedParam.length - 1) { + throw new Error( + `Invalid parameter declaration in ${filename}: ${moduleName}.${methodName}(${paramSource})`, + ); + } + + const rawName = normalizedParam.slice(0, colon).trim(); + const optional = rawName.endsWith('?'); + const name = optional ? rawName.slice(0, -1).trim() : rawName; + const typeSource = normalizedParam.slice(colon + 1).trim(); + + if (!IDENTIFIER_PATTERN.test(name)) { + throw new Error( + `Invalid parameter name "${rawName}" in ${filename}: ${moduleName}.${methodName}`, + ); + } + + if (optional) { + throw new Error( + `Optional parameter "${moduleName}.${methodName}.${name}" is not supported by Native Autolink codegen v1`, + ); + } + + const type = parseType( + typeSource, + filename, + `${moduleName}.${methodName}.${name}`, + ); + + if (type.name === 'void') { + throw new Error( + `Unsupported parameter type "void" for ${moduleName}.${methodName}.${name} in ${filename}. Native Autolink codegen v1 only supports void as a return type.`, + ); + } + + return { name, type }; + }); +} + +/** + * Resolves a generated path and rejects paths that escape the package root. + */ +function resolveInside(root: string, filePath: string, label: string): string { + const resolvedRoot = path.resolve(root); + const target = path.resolve(resolvedRoot, filePath); + const relativePath = path.relative(resolvedRoot, target); + + if ( + relativePath === '..' || relativePath.startsWith(`..${path.sep}`) + || path.isAbsolute(relativePath) + ) { + throw new Error(`Generated path escapes ${label}: ${filePath}`); + } + + return target; +} + +/** + * Rejects generated targets that traverse an existing symlink inside the package root. + */ +function assertNoSymlinkTraversal(root: string, target: string): void { + const resolvedRoot = path.resolve(root); + const relativePath = path.relative(resolvedRoot, target); + + if (relativePath.length === 0) { + return; + } + + let current = resolvedRoot; + + for (const segment of relativePath.split(path.sep)) { + current = path.join(current, segment); + + if (fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) { + throw new Error( + `Generated path escapes package root via symlink: ${ + path.relative(resolvedRoot, current) + }`, + ); + } + } +} + +/** + * Parses a supported Native Autolink type, including nullable unions. + */ +function parseType( + source: string, + filename: string, + context: string, +): NativeModuleType { + const parts = source.split('|').map((part) => part.trim()).filter(Boolean); + const nullable = parts.includes('null'); + const nonNullParts = parts.filter((part) => part !== 'null'); + + if (nonNullParts.length !== 1) { + throw unsupportedType(source, filename, context); + } + + const name = nonNullParts[0]; + + if ( + name === undefined + || ( + name !== 'void' + && name !== 'string' + && name !== 'number' + && name !== 'boolean' + ) + ) { + throw unsupportedType(source, filename, context); + } + + if (nullable && name === 'void') { + throw unsupportedType(source, filename, context); + } + + return { name, nullable }; +} + +/** + * Creates a consistent unsupported-type error for parser and generator validation. + */ +function unsupportedType( + source: string, + filename: string, + context: string, +): Error { + return new Error( + `Unsupported type "${source}" for ${context} in ${filename}. Native Autolink codegen v1 supports void, string, number, boolean, and unions with null.`, + ); +} + +/** + * Reads all native module specs declared under the package `types` directory. + */ +function readNativeModuleSpecs(root: string): NativeModuleSpec[] { + const typesDir = path.join(root, 'types'); + + if (!fs.existsSync(typesDir)) { + return []; + } + + const modules: NativeModuleSpec[] = []; + + for (const file of walkFiles(typesDir)) { + if (!file.endsWith('.d.ts')) { + continue; + } + + const source = fs.readFileSync(file, 'utf8'); + modules.push(...parseNativeModules(source, path.relative(root, file))); + } + + return modules; +} + +/** + * Recursively lists files in deterministic order. + */ +function walkFiles(dir: string): string[] { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + files.push(...walkFiles(entryPath)); + } else if (entry.isFile()) { + files.push(entryPath); + } + } + + return files.sort(); +} + +/** + * Reads and normalizes the Native Autolink extension manifest. + */ +function readManifest(root: string): LynxExtJson { + const manifestPath = path.join(root, 'lynx.ext.json'); + + if (!fs.existsSync(manifestPath)) { + throw new Error( + `Missing lynx.ext.json in ${root}. Native Autolink codegen must run from an extension package root.`, + ); + } + + const json = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as unknown; + const platforms = readObject(json, 'platforms', manifestPath); + const android = readObject(platforms, 'android', manifestPath); + const ios = readObject(platforms, 'ios', manifestPath); + const packageName = readRequiredString( + android, + 'packageName', + manifestPath, + 'platforms.android.packageName', + ); + + if (!JAVA_PACKAGE_NAME_PATTERN.test(packageName)) { + throw new Error( + `${manifestPath} must define "platforms.android.packageName" as a valid Java package identifier (got "${packageName}")`, + ); + } + + return { + platforms: { + android: { + packageName, + sourceDir: readOptionalString( + android, + 'sourceDir', + manifestPath, + 'platforms.android.sourceDir', + ) ?? 'android', + }, + ios: { + sourceDir: readOptionalString( + ios, + 'sourceDir', + manifestPath, + 'platforms.ios.sourceDir', + ) ?? 'ios', + }, + }, + }; +} + +/** + * Reads a required object property from `lynx.ext.json`. + */ +function readObject( + value: unknown, + key: string, + manifestPath: string, +): Record { + if (!isRecord(value)) { + throw new Error(`${manifestPath} must be a JSON object`); + } + + const child = value[key]; + + if (!isRecord(child)) { + throw new Error(`${manifestPath} must define object "${key}"`); + } + + return child; +} + +/** + * Reads a required non-empty string from `lynx.ext.json`. + */ +function readRequiredString( + value: Record, + key: string, + manifestPath: string, + displayPath: string, +): string { + const child = value[key]; + + if (typeof child !== 'string' || child.trim().length === 0) { + throw new Error(`${manifestPath} must define string "${displayPath}"`); + } + + return child; +} + +/** + * Reads an optional non-empty string from `lynx.ext.json`. + */ +function readOptionalString( + value: Record, + key: string, + manifestPath: string, + displayPath: string, +): string | undefined { + const child = value[key]; + + if (child === undefined) { + return undefined; + } + + if (typeof child === 'string' && child.trim().length > 0) { + return child; + } + + throw new Error( + `${manifestPath} must define non-empty string "${displayPath}"`, + ); +} + +/** + * Narrows unknown JSON values to plain object records. + */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Generates the TypeScript facade for one native module. + */ +function generateJsFacade(module: NativeModuleSpec): string { + const methods = module.methods.map((method) => + ` ${method.name}(${ + method.params.map((param) => `${param.name}: ${toTsType(param.type)}`) + .join( + ', ', + ) + }): ${toTsType(method.returnType)};` + ).join('\n'); + + return `// Generated by @lynx-js/autolink-codegen. Do not edit. + +declare const NativeModules: { + ${module.name}: { +${methods} + }; +}; + +export const ${module.name}: typeof NativeModules.${module.name} = NativeModules.${module.name}; +export default ${module.name}; +`; +} + +/** + * Generates the Android abstract native module spec for one native module. + */ +function generateAndroidSpec( + module: NativeModuleSpec, + packageName: string, +): string { + const methods = module.methods.map((method) => + ` @LynxMethod\n public abstract ${ + toJavaType(method.returnType) + } ${method.name}(${ + method.params.map((param) => `${toJavaType(param.type)} ${param.name}`) + .join(', ') + });` + ).join('\n\n'); + + return `// Generated by @lynx-js/autolink-codegen. Do not edit. +package ${packageName}.generated; + +import androidx.annotation.Nullable; +import com.lynx.jsbridge.LynxContextModule; +import com.lynx.jsbridge.LynxMethod; +import com.lynx.tasm.behavior.LynxContext; + +public abstract class ${module.name}Spec extends LynxContextModule { + public ${module.name}Spec(LynxContext context) { + super(context); + } + +${methods} +} +`; +} + +/** + * Generates the iOS protocol header for one native module. + */ +function generateIosHeader(module: NativeModuleSpec): string { + const methods = module.methods.map((method) => + `- (${toObjCReturnType(method.returnType)})${method.name}${ + method.params.length === 0 ? '' : toObjCParams(method.params) + };` + ).join('\n'); + + return `// Generated by @lynx-js/autolink-codegen. Do not edit. +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@protocol ${module.name}Spec + +${methods} + +@end + +NS_ASSUME_NONNULL_END +`; +} + +/** + * Generates the iOS implementation shim for one native module spec. + */ +function generateIosImplementation(module: NativeModuleSpec): string { + return `// Generated by @lynx-js/autolink-codegen. Do not edit. +#import "${module.name}Spec.h" +`; +} + +/** + * Converts a parsed native module type to TypeScript syntax. + */ +function toTsType(type: NativeModuleType): string { + const base = type.name === 'void' ? 'void' : type.name; + return type.nullable ? `${base} | null` : base; +} + +/** + * Converts a parsed native module type to Java syntax. + */ +function toJavaType(type: NativeModuleType): string { + switch (type.name) { + case 'void': + return 'void'; + case 'string': + return type.nullable ? '@Nullable String' : 'String'; + case 'number': + return type.nullable ? '@Nullable Double' : 'double'; + case 'boolean': + return type.nullable ? '@Nullable Boolean' : 'boolean'; + } +} + +/** + * Converts a parsed native module type to an Objective-C return type. + */ +function toObjCReturnType(type: NativeModuleType): string { + switch (type.name) { + case 'void': + return 'void'; + case 'string': + return type.nullable ? 'nullable NSString *' : 'NSString *'; + case 'number': + return type.nullable ? 'nullable NSNumber *' : 'double'; + case 'boolean': + return type.nullable ? 'nullable NSNumber *' : 'BOOL'; + } +} + +/** + * Converts a parsed native module type to an Objective-C parameter type. + */ +function toObjCParamType(type: NativeModuleType): string { + switch (type.name) { + case 'void': + throw new Error('void parameters are not supported'); + case 'string': + return type.nullable ? 'nullable NSString *' : 'NSString *'; + case 'number': + return type.nullable ? 'nullable NSNumber *' : 'double'; + case 'boolean': + return type.nullable ? 'nullable NSNumber *' : 'BOOL'; + } +} + +/** + * Converts parsed parameters into an Objective-C selector suffix. + */ +function toObjCParams(params: NativeModuleParam[]): string { + return params.map((param, index) => { + const prefix = index === 0 ? ':' : ` ${param.name}:`; + return `${prefix}(${toObjCParamType(param.type)})${param.name}`; + }).join(''); +} diff --git a/packages/lynx/autolink-codegen/test/codegen.test.ts b/packages/lynx/autolink-codegen/test/codegen.test.ts new file mode 100644 index 0000000000..92d80bb707 --- /dev/null +++ b/packages/lynx/autolink-codegen/test/codegen.test.ts @@ -0,0 +1,425 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { generate, parseNativeModules, runCodegen } from '../src/index.js'; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { force: true, recursive: true }); + } +}); + +describe('@lynx-js/autolink-codegen', () => { + it('parses @lynxmodule declarations from d.ts sources', () => { + const modules = parseNativeModules( + `/** @lynxmodule */ +export declare class StorageModule { + setValue(key: string, value: string): void; + getValue(key: string): string | null; + hasValue(key: string): boolean; + score(): number; +} +`, + 'types/index.d.ts', + ); + + expect(modules).toEqual([ + { + name: 'StorageModule', + methods: [ + { + name: 'setValue', + params: [ + { name: 'key', type: { name: 'string', nullable: false } }, + { name: 'value', type: { name: 'string', nullable: false } }, + ], + returnType: { name: 'void', nullable: false }, + }, + { + name: 'getValue', + params: [ + { name: 'key', type: { name: 'string', nullable: false } }, + ], + returnType: { name: 'string', nullable: true }, + }, + { + name: 'hasValue', + params: [ + { name: 'key', type: { name: 'string', nullable: false } }, + ], + returnType: { name: 'boolean', nullable: false }, + }, + { + name: 'score', + params: [], + returnType: { name: 'number', nullable: false }, + }, + ], + }, + ]); + }); + + it('parses native module declarations without semicolons', () => { + const modules = parseNativeModules( + `/** @lynxmodule */ +export declare class StorageModule { + setValue(key: string, value: string): void + getValue(key: string): string | null +} +`, + 'types/index.d.ts', + ); + + expect(modules[0]?.methods.map((method) => method.name)).toEqual([ + 'setValue', + 'getValue', + ]); + }); + + it('ignores comments inside native module declarations', () => { + const modules = parseNativeModules( + `/** @lynxmodule */ +export declare class CommentedModule { + /** Stores a value for the provided key. */ + setValue( + key: string, // cache key + value: string + ): void; // native side returns nothing + + // Comment-only lines should not become method declarations. + /** + * Reads a value. Comments may include semicolons; + * and inline links such as {@link CommentedModule}. + */ + getValue(key: string): string | null +} +`, + 'types/commented.d.ts', + ); + + expect(modules[0]?.methods.map((method) => method.name)).toEqual([ + 'setValue', + 'getValue', + ]); + }); + + it('accepts trailing commas in native module parameters', () => { + const modules = parseNativeModules( + `/** @lynxmodule */ +export declare class FormattedModule { + setValue( + key: string, + value: string, + ): void; +} +`, + 'types/formatted.d.ts', + ); + + expect(modules[0]?.methods[0]?.params.map((param) => param.name)).toEqual([ + 'key', + 'value', + ]); + }); + + it('generates JS, Android, and iOS specs', () => { + const root = createFixture({ + manifest: { + platforms: { + android: { + packageName: 'com.example.storage', + }, + ios: {}, + }, + }, + types: `/** @lynxmodule */ +export declare class StorageModule { + setValue(key: string, value: string): void; + getValue(key: string): string | null; +} +`, + }); + + const files = generate({ root }); + + expect(files.map((file) => file.path).sort()).toEqual([ + 'generated/StorageModule.ts', + 'android/src/main/java/com/example/storage/generated/StorageModuleSpec.java', + 'ios/src/generated/StorageModuleSpec.h', + 'ios/src/generated/StorageModuleSpec.m', + ].sort()); + expect(files[0]?.content).toContain('NativeModules.StorageModule'); + expect(files[1]?.content).toContain( + 'package com.example.storage.generated;', + ); + expect(files[1]?.content).toContain( + 'import com.lynx.jsbridge.LynxContextModule;', + ); + expect(files[1]?.content).toContain('import com.lynx.jsbridge.LynxMethod;'); + expect(files[1]?.content).toContain( + 'import com.lynx.tasm.behavior.LynxContext;', + ); + expect(files[1]?.content).toContain('public abstract void setValue'); + expect(files[2]?.content).toContain('@protocol StorageModuleSpec'); + expect(files[2]?.content).toContain( + '- (nullable NSString *)getValue:(NSString *)key;', + ); + }); + + it('writes generated files from a temp extension package', () => { + const root = createFixture({ + manifest: { + platforms: { + android: { + packageName: 'com.example.storage', + sourceDir: 'android', + }, + ios: { + sourceDir: 'ios', + }, + }, + }, + types: `/** @lynxmodule */ +export declare class StorageModule { + clear(): void; +} +`, + }); + + const files = runCodegen({ root }); + + expect(files).toHaveLength(4); + expect( + fs.readFileSync(path.join(root, 'generated/StorageModule.ts'), 'utf8'), + ).toContain('export const StorageModule'); + expect( + fs.existsSync( + path.join( + root, + 'android/src/main/java/com/example/storage/generated/StorageModuleSpec.java', + ), + ), + ).toBe(true); + }); + + it('rejects generated paths that escape the package root', () => { + const root = createFixture({ + manifest: { + platforms: { + android: { + packageName: 'com.example.storage', + sourceDir: '../outside', + }, + ios: { + sourceDir: 'ios', + }, + }, + }, + types: `/** @lynxmodule */ +export declare class StorageModule { + clear(): void; +} +`, + }); + const outside = path.resolve(root, '../outside'); + + expect(() => runCodegen({ root })).toThrow( + /Generated path escapes package root/, + ); + expect(fs.existsSync(path.join(root, 'generated/StorageModule.ts'))).toBe( + false, + ); + expect(fs.existsSync(outside)).toBe(false); + }); + + it.runIf(process.platform !== 'win32')( + 'rejects generated paths that traverse symlinks', + () => { + const root = createFixture({ + manifest: { + platforms: { + android: { + packageName: 'com.example.storage', + sourceDir: 'android', + }, + ios: { + sourceDir: 'ios', + }, + }, + }, + types: `/** @lynxmodule */ +export declare class StorageModule { + clear(): void; +} +`, + }); + const outside = createTempDir(); + fs.symlinkSync(outside, path.join(root, 'android'), 'dir'); + + expect(() => runCodegen({ root })).toThrow( + /Generated path escapes package root via symlink: android/, + ); + expect(fs.existsSync(path.join(root, 'generated/StorageModule.ts'))).toBe( + false, + ); + expect( + fs.existsSync( + path.join( + outside, + 'src/main/java/com/example/storage/generated/StorageModuleSpec.java', + ), + ), + ).toBe(false); + }, + ); + + it('fails clearly when lynx.ext.json is missing', () => { + const root = createTempDir(); + fs.mkdirSync(path.join(root, 'types'), { recursive: true }); + + expect(() => generate({ root })).toThrow(/Missing lynx\.ext\.json/); + }); + + it('fails clearly when android packageName is missing', () => { + const root = createFixture({ + manifest: { + platforms: { + android: {}, + ios: {}, + }, + }, + types: '', + }); + + expect(() => generate({ root })).toThrow( + /platforms\.android\.packageName/, + ); + }); + + it('fails clearly when android packageName is not a Java package identifier', () => { + const root = createFixture({ + manifest: { + platforms: { + android: { + packageName: 'com..example', + }, + ios: {}, + }, + }, + types: '', + }); + + expect(() => generate({ root })).toThrow( + /platforms\.android\.packageName.*valid Java package identifier/, + ); + }); + + it('fails clearly when optional sourceDir values are invalid', () => { + const root = createFixture({ + manifest: { + platforms: { + android: { + packageName: 'com.example.storage', + sourceDir: '', + }, + ios: {}, + }, + }, + types: '', + }); + + expect(() => generate({ root })).toThrow( + /platforms\.android\.sourceDir/, + ); + }); + + it('fails clearly for unsupported native module types', () => { + expect(() => + parseNativeModules( + `/** @lynxmodule */ +export declare class BadModule { + setValue(value: string[]): void; +} +`, + 'types/index.d.ts', + ) + ).toThrow(/Unsupported type "string\[\]"/); + }); + + it('fails clearly when a parameter uses void', () => { + expect(() => + parseNativeModules( + `/** @lynxmodule */ +export declare class BadModule { + setValue(value: void): void; +} +`, + 'types/index.d.ts', + ) + ).toThrow( + /Unsupported parameter type "void" for BadModule\.setValue\.value/, + ); + }); + + it('fails clearly for duplicate module names across files', () => { + const root = createTempDir(); + writeJson(path.join(root, 'lynx.ext.json'), { + platforms: { + android: { packageName: 'com.example.dupe' }, + ios: {}, + }, + }); + fs.mkdirSync(path.join(root, 'types/a'), { recursive: true }); + fs.mkdirSync(path.join(root, 'types/b'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'types/a/index.d.ts'), + `/** @lynxmodule */ +export declare class DupeModule { + a(): void; +} +`, + ); + fs.writeFileSync( + path.join(root, 'types/b/index.d.ts'), + `/** @lynxmodule */ +export declare class DupeModule { + b(): void; +} +`, + ); + + expect(() => generate({ root })).toThrow( + /Duplicate native module "DupeModule"/, + ); + }); +}); + +function createFixture(options: { + manifest: unknown; + types: string; +}): string { + const root = createTempDir(); + writeJson(path.join(root, 'lynx.ext.json'), options.manifest); + fs.mkdirSync(path.join(root, 'types'), { recursive: true }); + fs.writeFileSync(path.join(root, 'types/index.d.ts'), options.types); + return root; +} + +function createTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lynx-autolink-codegen-')); + tempDirs.push(dir); + return dir; +} + +function writeJson(file: string, value: unknown): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`); +} diff --git a/packages/lynx/autolink-codegen/tsconfig.build.json b/packages/lynx/autolink-codegen/tsconfig.build.json new file mode 100644 index 0000000000..2cee55563c --- /dev/null +++ b/packages/lynx/autolink-codegen/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "composite": true, + "lib": ["es2022"], + "module": "Node16", + "moduleResolution": "Node16", + "noEmit": true, + "rootDir": "src", + "target": "ES2022", + "types": ["node"], + }, + "include": ["src"], +} diff --git a/packages/lynx/autolink-codegen/tsconfig.json b/packages/lynx/autolink-codegen/tsconfig.json new file mode 100644 index 0000000000..e13dabad5d --- /dev/null +++ b/packages/lynx/autolink-codegen/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.build.json" }, + { "path": "./tsconfig.test.json" }, + ], +} diff --git a/packages/lynx/autolink-codegen/tsconfig.test.json b/packages/lynx/autolink-codegen/tsconfig.test.json new file mode 100644 index 0000000000..4b969a16b4 --- /dev/null +++ b/packages/lynx/autolink-codegen/tsconfig.test.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "composite": true, + "emitDeclarationOnly": false, + "noEmit": true, + "rootDir": "." + }, + "include": ["src", "test"] +} diff --git a/packages/lynx/autolink-codegen/turbo.json b/packages/lynx/autolink-codegen/turbo.json new file mode 100644 index 0000000000..b52116b8b5 --- /dev/null +++ b/packages/lynx/autolink-codegen/turbo.json @@ -0,0 +1,11 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"] + }, + "test": { + "dependsOn": ["build"] + } + } +} diff --git a/packages/lynx/autolink-codegen/vitest.config.ts b/packages/lynx/autolink-codegen/vitest.config.ts new file mode 100644 index 0000000000..753a514338 --- /dev/null +++ b/packages/lynx/autolink-codegen/vitest.config.ts @@ -0,0 +1,11 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + name: 'lynx/autolink-codegen', + include: ['test/**/*.test.ts'], + }, +}); diff --git a/packages/lynx/tsconfig.json b/packages/lynx/tsconfig.json index c8cb2008b6..6773c1e951 100644 --- a/packages/lynx/tsconfig.json +++ b/packages/lynx/tsconfig.json @@ -4,6 +4,7 @@ }, "references": [ /** packages-start */ + { "path": "./autolink-codegen/tsconfig.build.json" }, { "path": "./gesture-runtime/tsconfig.build.json" }, /** packages-end */ ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbdca21b4a..6045fb0b8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -731,6 +731,8 @@ importers: specifier: 0.2.1 version: 0.2.1(@rsbuild/core@1.7.5)(i18next-cli@1.54.2(@swc/helpers@0.5.21)(@types/node@24.10.13)(i18next@26.0.6(typescript@5.9.3))(typescript@5.9.3))(rollup@4.34.9) + packages/lynx/autolink-codegen: {} + packages/lynx/benchx_cli: dependencies: zx: diff --git a/vitest.config.ts b/vitest.config.ts index c1fff85412..ddca1cc7b8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -82,7 +82,7 @@ export default defineConfig({ 'packages/use-sync-external-store/vitest.config.ts', 'packages/web-platform/*/vitest.config.ts', 'packages/webpack/*/vitest.config.ts', - 'packages/lynx/gesture-runtime/vitest.config.ts', + 'packages/lynx/*/vitest.config.ts', 'packages/motion/vitest.config.ts', ], },