feat: impl
This commit is contained in:
parent
9942bd9a7f
commit
dd43bb1a1d
|
|
@ -0,0 +1,23 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
# Yarn Spinner Loader
|
||||
|
||||
Load and compile Yarn Spinner project files (`.yarnproject`) for various build tools (esbuild, rollup, webpack, vite).
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Parse and validate `.yarnproject` files against the official JSON schema
|
||||
- ✅ Load and compile `.yarn` dialogue files using glob patterns
|
||||
- ✅ Support for multiple build tools (esbuild, rollup, webpack, vite)
|
||||
- ✅ TypeScript support with full type definitions
|
||||
- ✅ Comprehensive test coverage with real fixtures
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install yarn-spinner-loader
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Core API
|
||||
|
||||
```typescript
|
||||
import { loadYarnProject } from 'yarn-spinner-loader';
|
||||
|
||||
const result = await loadYarnProject('path/to/project.yarnproject');
|
||||
|
||||
console.log(result.project.projectName);
|
||||
console.log(result.yarnFiles); // Array of parsed Yarn documents
|
||||
```
|
||||
|
||||
### With esbuild
|
||||
|
||||
```typescript
|
||||
import { build } from 'esbuild';
|
||||
import { yarnSpinnerPlugin } from 'yarn-spinner-loader/esbuild';
|
||||
|
||||
build({
|
||||
entryPoints: ['src/index.ts'],
|
||||
plugins: [yarnSpinnerPlugin()],
|
||||
bundle: true,
|
||||
outfile: 'dist/bundle.js',
|
||||
});
|
||||
```
|
||||
|
||||
### With Vite
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
import { defineConfig } from 'vite';
|
||||
import { yarnSpinnerVite } from 'yarn-spinner-loader/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [yarnSpinnerVite()],
|
||||
});
|
||||
```
|
||||
|
||||
### With Rollup
|
||||
|
||||
```typescript
|
||||
// rollup.config.js
|
||||
import { yarnSpinnerRollup } from 'yarn-spinner-loader/rollup';
|
||||
|
||||
export default {
|
||||
input: 'src/main.js',
|
||||
plugins: [yarnSpinnerRollup()],
|
||||
output: {
|
||||
file: 'dist/bundle.js',
|
||||
format: 'es',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### With Webpack
|
||||
|
||||
```javascript
|
||||
// webpack.config.js
|
||||
module.exports = {
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.yarnproject$/,
|
||||
use: 'yarn-spinner-loader/webpack',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `loadYarnProject(path, options?)`
|
||||
|
||||
Load and compile a `.yarnproject` file and all its referenced `.yarn` files.
|
||||
|
||||
**Parameters:**
|
||||
- `path`: Path to the `.yarnproject` file
|
||||
- `options.baseDir`: Base directory for resolving glob patterns (default: directory of `.yarnproject` file)
|
||||
|
||||
**Returns:** `Promise<LoadResult>`
|
||||
|
||||
```typescript
|
||||
interface LoadResult {
|
||||
project: YarnProject;
|
||||
baseDir: string;
|
||||
yarnFiles: Array<{
|
||||
relativePath: string;
|
||||
absolutePath: string;
|
||||
document: YarnDocument;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
### `loadYarnProjectSync(path, options?)`
|
||||
|
||||
Synchronous version of `loadYarnProject`.
|
||||
|
||||
### `validateYarnProject(config)`
|
||||
|
||||
Validate a parsed `.yarnproject` object against the JSON schema.
|
||||
|
||||
**Returns:**
|
||||
```typescript
|
||||
{ valid: true; data: YarnProject } | { valid: false; errors: ValidationError[] }
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
yarn-spinner-loader/
|
||||
├── src/
|
||||
│ ├── loader/ # Core loader implementation
|
||||
│ │ ├── index.ts # YarnProjectLoader
|
||||
│ │ ├── validator.ts # JSON Schema validation
|
||||
│ │ └── types.ts # TypeScript types
|
||||
│ ├── plugins/ # Build tool plugins
|
||||
│ │ ├── esbuild.ts
|
||||
│ │ ├── rollup.ts
|
||||
│ │ ├── vite.ts
|
||||
│ │ └── webpack.ts
|
||||
│ ├── yarn-spinner/ # Yarn Spinner parser (existing)
|
||||
│ └── index.ts # Main entry point
|
||||
├── tests/
|
||||
│ ├── fixtures/ # Test fixtures
|
||||
│ │ ├── simple/
|
||||
│ │ ├── localised/
|
||||
│ │ └── complex/
|
||||
│ ├── validator.test.ts
|
||||
│ └── loader.test.ts
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run tests with vitest:
|
||||
|
||||
```bash
|
||||
npm test # Watch mode
|
||||
npm run test:run # Run once
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,87 @@
|
|||
{
|
||||
"name": "yarn-spinner-loader",
|
||||
"version": "0.1.0",
|
||||
"description": "Load and compile Yarn Spinner project files for various build tools",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./esbuild": {
|
||||
"types": "./dist/plugins/esbuild.d.ts",
|
||||
"import": "./dist/plugins/esbuild.js",
|
||||
"require": "./dist/plugins/esbuild.cjs"
|
||||
},
|
||||
"./rollup": {
|
||||
"types": "./dist/plugins/rollup.d.ts",
|
||||
"import": "./dist/plugins/rollup.js",
|
||||
"require": "./dist/plugins/rollup.cjs"
|
||||
},
|
||||
"./vite": {
|
||||
"types": "./dist/plugins/vite.d.ts",
|
||||
"import": "./dist/plugins/vite.js",
|
||||
"require": "./dist/plugins/vite.cjs"
|
||||
},
|
||||
"./webpack": {
|
||||
"types": "./dist/plugins/webpack.d.ts",
|
||||
"import": "./dist/plugins/webpack.js",
|
||||
"require": "./dist/plugins/webpack.cjs"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
"yarn-spinner",
|
||||
"yarnproject",
|
||||
"loader",
|
||||
"esbuild",
|
||||
"rollup",
|
||||
"webpack",
|
||||
"vite"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"fast-glob": "^3.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.5",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^3.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"esbuild": ">=0.20.0",
|
||||
"rollup": ">=4.0.0",
|
||||
"vite": ">=5.0.0",
|
||||
"webpack": ">=5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"rollup": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
},
|
||||
"webpack": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Yarn Spinner Loader - Main Entry Point
|
||||
*
|
||||
* Load and compile Yarn Spinner project files (.yarnproject)
|
||||
* and their associated .yarn dialogue files.
|
||||
*/
|
||||
|
||||
export {
|
||||
loadYarnProject,
|
||||
loadYarnProjectSync,
|
||||
type LoadOptions,
|
||||
type LoadResult,
|
||||
type LoadedYarnFile,
|
||||
LoadError,
|
||||
ValidationError as LoaderValidationError,
|
||||
} from './loader/index';
|
||||
|
||||
export {
|
||||
validateYarnProject,
|
||||
isYarnProject,
|
||||
type SchemaValidationError,
|
||||
} from './loader/validator';
|
||||
|
||||
export type { YarnProject } from './loader/types';
|
||||
|
||||
// Re-export yarn-spinner parser
|
||||
export { parseYarn } from './yarn-spinner/parse/parser';
|
||||
export { compile } from './yarn-spinner/compile/compiler';
|
||||
export { YarnRunner } from './yarn-spinner/runtime/runner';
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { dirname, resolve, relative } from 'node:path';
|
||||
import { glob } from 'fast-glob';
|
||||
import type { YarnProject } from './types';
|
||||
import { validateYarnProject, type SchemaValidationError } from './validator';
|
||||
import { parseYarn } from '../yarn-spinner/parse/parser';
|
||||
import type { YarnDocument } from '../yarn-spinner/model/ast';
|
||||
|
||||
/**
|
||||
* Options for loading a Yarn project
|
||||
*/
|
||||
export interface LoadOptions {
|
||||
/** Base directory for resolving glob patterns (default: directory of .yarnproject file) */
|
||||
baseDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded Yarn document with metadata
|
||||
*/
|
||||
export interface LoadedYarnFile {
|
||||
/** File path relative to base directory */
|
||||
relativePath: string;
|
||||
/** Absolute file path */
|
||||
absolutePath: string;
|
||||
/** Parsed Yarn document */
|
||||
document: YarnDocument;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of loading a Yarn project
|
||||
*/
|
||||
export interface LoadResult {
|
||||
/** Parsed .yarnproject configuration */
|
||||
project: YarnProject;
|
||||
/** Directory containing the .yarnproject file */
|
||||
baseDir: string;
|
||||
/** All loaded and parsed .yarn files */
|
||||
yarnFiles: LoadedYarnFile[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when loading fails
|
||||
*/
|
||||
export class LoadError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly cause?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'LoadError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when validation fails
|
||||
*/
|
||||
export class ValidationError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly errors: SchemaValidationError[],
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and compile a .yarnproject file and all its referenced .yarn files
|
||||
*/
|
||||
export async function loadYarnProject(
|
||||
projectFilePath: string,
|
||||
options: LoadOptions = {},
|
||||
): Promise<LoadResult> {
|
||||
// Resolve and read .yarnproject file
|
||||
const absoluteProjectPath = resolve(projectFilePath);
|
||||
|
||||
if (!existsSync(absoluteProjectPath)) {
|
||||
throw new LoadError(`Project file not found: ${absoluteProjectPath}`);
|
||||
}
|
||||
|
||||
const projectContent = readFileSync(absoluteProjectPath, 'utf-8');
|
||||
let projectConfig: unknown;
|
||||
|
||||
try {
|
||||
projectConfig = JSON.parse(projectContent);
|
||||
} catch (error) {
|
||||
throw new LoadError(`Failed to parse .yarnproject file as JSON: ${absoluteProjectPath}`, error);
|
||||
}
|
||||
|
||||
// Validate against schema
|
||||
const validation = validateYarnProject(projectConfig);
|
||||
|
||||
if (!validation.valid) {
|
||||
throw new ValidationError(
|
||||
`Invalid .yarnproject file: ${validation.errors.map(e => `${e.path}: ${e.message}`).join(', ')}`,
|
||||
validation.errors,
|
||||
);
|
||||
}
|
||||
|
||||
const project = validation.data as YarnProject;
|
||||
const baseDir = options.baseDir || dirname(absoluteProjectPath);
|
||||
|
||||
// Find all .yarn files using glob patterns
|
||||
const sourcePatterns = project.sourceFiles;
|
||||
const ignorePatterns = project.excludeFiles || [];
|
||||
|
||||
const yarnFilePaths = await glob(sourcePatterns, {
|
||||
cwd: baseDir,
|
||||
ignore: ignorePatterns,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
});
|
||||
|
||||
// Load and parse each .yarn file
|
||||
const yarnFiles: LoadedYarnFile[] = yarnFilePaths.map(absolutePath => {
|
||||
const relativePath = relative(baseDir, absolutePath);
|
||||
const content = readFileSync(absolutePath, 'utf-8');
|
||||
const document = parseYarn(content);
|
||||
|
||||
return {
|
||||
relativePath,
|
||||
absolutePath,
|
||||
document,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
project,
|
||||
baseDir,
|
||||
yarnFiles,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous version of loadYarnProject
|
||||
*/
|
||||
export function loadYarnProjectSync(
|
||||
projectFilePath: string,
|
||||
options: LoadOptions = {},
|
||||
): LoadResult {
|
||||
// Resolve and read .yarnproject file
|
||||
const absoluteProjectPath = resolve(projectFilePath);
|
||||
|
||||
if (!existsSync(absoluteProjectPath)) {
|
||||
throw new LoadError(`Project file not found: ${absoluteProjectPath}`);
|
||||
}
|
||||
|
||||
const projectContent = readFileSync(absoluteProjectPath, 'utf-8');
|
||||
let projectConfig: unknown;
|
||||
|
||||
try {
|
||||
projectConfig = JSON.parse(projectContent);
|
||||
} catch (error) {
|
||||
throw new LoadError(`Failed to parse .yarnproject file as JSON: ${absoluteProjectPath}`, error);
|
||||
}
|
||||
|
||||
// Validate against schema
|
||||
const validation = validateYarnProject(projectConfig);
|
||||
|
||||
if (!validation.valid) {
|
||||
throw new ValidationError(
|
||||
`Invalid .yarnproject file: ${validation.errors.map(e => `${e.path}: ${e.message}`).join(', ')}`,
|
||||
validation.errors,
|
||||
);
|
||||
}
|
||||
|
||||
const project = validation.data as YarnProject;
|
||||
const baseDir = options.baseDir || dirname(absoluteProjectPath);
|
||||
|
||||
// Find all .yarn files using glob patterns
|
||||
const sourcePatterns = project.sourceFiles;
|
||||
const ignorePatterns = project.excludeFiles || [];
|
||||
|
||||
const yarnFilePaths = glob.sync(sourcePatterns, {
|
||||
cwd: baseDir,
|
||||
ignore: ignorePatterns,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
});
|
||||
|
||||
// Load and parse each .yarn file
|
||||
const yarnFiles: LoadedYarnFile[] = yarnFilePaths.map(absolutePath => {
|
||||
const relativePath = relative(baseDir, absolutePath);
|
||||
const content = readFileSync(absolutePath, 'utf-8');
|
||||
const document = parseYarn(content);
|
||||
|
||||
return {
|
||||
relativePath,
|
||||
absolutePath,
|
||||
document,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
project,
|
||||
baseDir,
|
||||
yarnFiles,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
export interface YarnProject {
|
||||
projectFileVersion: number;
|
||||
projectName?: string;
|
||||
authorName?: string[];
|
||||
sourceFiles: string[];
|
||||
excludeFiles?: string[];
|
||||
baseLanguage: string;
|
||||
localisation?: {
|
||||
[languageCode: string]: {
|
||||
strings?: string;
|
||||
assets?: string;
|
||||
};
|
||||
};
|
||||
definitions?: string | string[];
|
||||
compilerOptions?: {
|
||||
requireVariableDeclarations?: boolean;
|
||||
allowPreviewFeatures?: boolean;
|
||||
};
|
||||
editorOptions?: {
|
||||
yarnScriptEditor?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import Ajv from 'ajv';
|
||||
import type { YarnProject } from './types.js';
|
||||
|
||||
export interface SchemaValidationError {
|
||||
path: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON Schema for .yarnproject files
|
||||
* Based on https://schemas.yarnspinner.dev/yarnproject.schema.json
|
||||
*/
|
||||
const YARNPROJECT_SCHEMA = {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectFileVersion: {
|
||||
type: 'integer',
|
||||
minimum: 2,
|
||||
default: 4,
|
||||
},
|
||||
projectName: {
|
||||
type: 'string',
|
||||
},
|
||||
authorName: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
sourceFiles: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
default: ['**/*.yarn'],
|
||||
},
|
||||
excludeFiles: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
baseLanguage: {
|
||||
type: 'string',
|
||||
},
|
||||
localisation: {
|
||||
type: 'object',
|
||||
additionalProperties: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
strings: {
|
||||
type: 'string',
|
||||
},
|
||||
assets: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
definitions: {
|
||||
oneOf: [
|
||||
{ type: 'string' },
|
||||
{
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
],
|
||||
},
|
||||
compilerOptions: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
requireVariableDeclarations: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
allowPreviewFeatures: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
editorOptions: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
required: ['projectFileVersion', 'sourceFiles', 'baseLanguage'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
|
||||
const ajv = new Ajv({ allErrors: true });
|
||||
const validate = ajv.compile(YARNPROJECT_SCHEMA);
|
||||
|
||||
/**
|
||||
* Validate a parsed .yarnproject object against the schema
|
||||
*/
|
||||
export function validateYarnProject(config: unknown): { valid: true; data: YarnProject } | { valid: false; errors: SchemaValidationError[] } {
|
||||
const valid = validate(config);
|
||||
|
||||
if (valid) {
|
||||
// Check required fields before casting
|
||||
const obj = config as Record<string, unknown>;
|
||||
if (obj.projectFileVersion && obj.sourceFiles && obj.baseLanguage) {
|
||||
return { valid: true, data: obj as unknown as YarnProject };
|
||||
}
|
||||
return { valid: true, data: obj as unknown as YarnProject };
|
||||
}
|
||||
|
||||
const errors: SchemaValidationError[] = (validate.errors || []).map((err: any) => ({
|
||||
path: err.instancePath || '/',
|
||||
message: err.message || 'Unknown error',
|
||||
}));
|
||||
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if config is a valid YarnProject
|
||||
*/
|
||||
export function isYarnProject(config: unknown): config is YarnProject {
|
||||
return validate(config) === true;
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import type { Plugin } from 'esbuild';
|
||||
import { loadYarnProject, type LoadResult } from '../loader/index.js';
|
||||
|
||||
export interface YarnSpinnerEsbuildOptions {
|
||||
/** Additional options passed to loadYarnProject */
|
||||
loadOptions?: Parameters<typeof loadYarnProject>[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Esbuild plugin for loading Yarn Spinner project files
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { yarnSpinnerPlugin } from 'yarn-spinner-loader/esbuild';
|
||||
*
|
||||
* esbuild.build({
|
||||
* plugins: [yarnSpinnerPlugin()],
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function yarnSpinnerPlugin(options: YarnSpinnerEsbuildOptions = {}): Plugin {
|
||||
return {
|
||||
name: 'yarn-spinner-loader',
|
||||
setup(build) {
|
||||
// Intercept .yarnproject files
|
||||
build.onLoad({ filter: /\.yarnproject$/ }, async (args) => {
|
||||
try {
|
||||
const result: LoadResult = await loadYarnProject(args.path, options.loadOptions);
|
||||
|
||||
return {
|
||||
contents: `export default ${JSON.stringify(result, null, 2)};`,
|
||||
loader: 'js',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
errors: [{ text: error instanceof Error ? error.message : String(error) }],
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import type { Plugin } from 'rollup';
|
||||
import { loadYarnProject, type LoadResult } from '../loader/index.js';
|
||||
|
||||
export interface YarnSpinnerRollupOptions {
|
||||
/** Additional options passed to loadYarnProject */
|
||||
loadOptions?: Parameters<typeof loadYarnProject>[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollup plugin for loading Yarn Spinner project files
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { yarnSpinnerRollup } from 'yarn-spinner-loader/rollup';
|
||||
*
|
||||
* rollup({
|
||||
* plugins: [yarnSpinnerRollup()],
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function yarnSpinnerRollup(options: YarnSpinnerRollupOptions = {}): Plugin {
|
||||
return {
|
||||
name: 'yarn-spinner-loader',
|
||||
async load(id) {
|
||||
if (id.endsWith('.yarnproject')) {
|
||||
try {
|
||||
const result: LoadResult = await loadYarnProject(id, options.loadOptions);
|
||||
return `export default ${JSON.stringify(result, null, 2)};`;
|
||||
} catch (error) {
|
||||
this.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import type { Plugin } from 'vite';
|
||||
import { loadYarnProject, type LoadResult } from '../loader/index.js';
|
||||
|
||||
export interface YarnSpinnerViteOptions {
|
||||
/** Additional options passed to loadYarnProject */
|
||||
loadOptions?: Parameters<typeof loadYarnProject>[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin for loading Yarn Spinner project files
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { yarnSpinnerVite } from 'yarn-spinner-loader/vite';
|
||||
*
|
||||
* export default defineConfig({
|
||||
* plugins: [yarnSpinnerVite()],
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function yarnSpinnerVite(options: YarnSpinnerViteOptions = {}): Plugin {
|
||||
return {
|
||||
name: 'yarn-spinner-loader',
|
||||
async load(id) {
|
||||
if (id.endsWith('.yarnproject')) {
|
||||
try {
|
||||
const result: LoadResult = await loadYarnProject(id, options.loadOptions);
|
||||
return `export default ${JSON.stringify(result, null, 2)};`;
|
||||
} catch (error) {
|
||||
this.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
transform(_src, id) {
|
||||
if (id.endsWith('.yarnproject')) {
|
||||
return {
|
||||
code: `export default ${JSON.stringify({}, null, 2)};`,
|
||||
map: null,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { loadYarnProjectSync, type LoadResult } from '../loader/index.js';
|
||||
|
||||
export interface YarnSpinnerWebpackOptions {
|
||||
/** Additional options passed to loadYarnProject */
|
||||
loadOptions?: Parameters<typeof loadYarnProjectSync>[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Webpack loader for loading Yarn Spinner project files
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // webpack.config.js
|
||||
* module.exports = {
|
||||
* module: {
|
||||
* rules: [
|
||||
* {
|
||||
* test: /\.yarnproject$/,
|
||||
* use: 'yarn-spinner-loader/webpack',
|
||||
* },
|
||||
* ],
|
||||
* },
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
export function YarnSpinnerWebpackLoader(this: any, source: string) {
|
||||
const options: YarnSpinnerWebpackOptions = this.getOptions() || {};
|
||||
const resourcePath = this.resourcePath;
|
||||
|
||||
try {
|
||||
const result: LoadResult = loadYarnProjectSync(resourcePath, options.loadOptions);
|
||||
const code = `export default ${JSON.stringify(result, null, 2)};`;
|
||||
return code;
|
||||
} catch (error) {
|
||||
this.emitError(error instanceof Error ? error : new Error(String(error)));
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
export default YarnSpinnerWebpackLoader;
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"projectFileVersion": 4,
|
||||
"projectName": "Complex Test Project",
|
||||
"authorName": ["Author One", "Author Two"],
|
||||
"sourceFiles": [
|
||||
"dialogue/**/*.yarn",
|
||||
"scripts/**/*.yarn"
|
||||
],
|
||||
"excludeFiles": [
|
||||
"**/*.backup.yarn",
|
||||
"dialogue/deprecated/**/*.yarn"
|
||||
],
|
||||
"baseLanguage": "en",
|
||||
"localisation": {
|
||||
"zh-Hans": {
|
||||
"strings": "translations/zh-Hans.csv"
|
||||
}
|
||||
},
|
||||
"definitions": ["custom-commands.ysls.json"],
|
||||
"compilerOptions": {
|
||||
"requireVariableDeclarations": true,
|
||||
"allowPreviewFeatures": false
|
||||
},
|
||||
"editorOptions": {
|
||||
"yarnScriptEditor": {
|
||||
"theme": "dark"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
title: ComplexNode1
|
||||
tags: test complex
|
||||
when: $has_sword
|
||||
---
|
||||
Character: This is a complex node with tags and conditions.
|
||||
<<once>>
|
||||
This text only shows once.
|
||||
<<endonce>>
|
||||
===
|
||||
|
||||
title: ComplexNode2
|
||||
---
|
||||
<<if $has_key>>
|
||||
Character: You have the key!
|
||||
<<else>>
|
||||
Character: You need a key to proceed.
|
||||
<<endif>>
|
||||
|
||||
Here are your options:
|
||||
-> [Option 1] Go left
|
||||
You went left.
|
||||
-> [Option 2] Go right
|
||||
You went right.
|
||||
===
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
title: BackupNode
|
||||
---
|
||||
This file should be excluded.
|
||||
===
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
title: ScriptNode
|
||||
---
|
||||
<<set $variable = 1>>
|
||||
<<set $another = true>>
|
||||
===
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"projectFileVersion": 4,
|
||||
"projectName": "Localised Project",
|
||||
"sourceFiles": ["**/*.yarn"],
|
||||
"baseLanguage": "en",
|
||||
"localisation": {
|
||||
"zh-Hans": {
|
||||
"strings": "translations/zh-Hans.csv",
|
||||
"assets": "translations/zh-Hans/"
|
||||
},
|
||||
"ja": {
|
||||
"strings": "translations/ja.csv"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
title: Welcome
|
||||
---
|
||||
Welcome to the localised project!
|
||||
This text should be translated.
|
||||
===
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"projectFileVersion": 4,
|
||||
"projectName": "Simple Test Project",
|
||||
"sourceFiles": ["**/*.yarn"],
|
||||
"baseLanguage": "en"
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
title: Start
|
||||
---
|
||||
Hello, world!
|
||||
This is a simple test dialogue.
|
||||
===
|
||||
|
||||
title: Greeting
|
||||
tags: greeting
|
||||
---
|
||||
Player: Hi there!
|
||||
NPC: Welcome to our game!
|
||||
===
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
title: AnotherNode
|
||||
---
|
||||
This is another test node.
|
||||
<<if $variable>>
|
||||
Conditional text here.
|
||||
<<endif>>
|
||||
===
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { loadYarnProject, loadYarnProjectSync, LoadError } from '../src/loader/index';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
describe('loadYarnProject', () => {
|
||||
it('should load a simple project', async () => {
|
||||
const projectPath = resolve(__dirname, 'fixtures/simple/.yarnproject');
|
||||
const result = await loadYarnProject(projectPath);
|
||||
|
||||
expect(result.project.projectFileVersion).toBe(4);
|
||||
expect(result.project.projectName).toBe('Simple Test Project');
|
||||
expect(result.project.sourceFiles).toEqual(['**/*.yarn']);
|
||||
expect(result.project.baseLanguage).toBe('en');
|
||||
expect(result.yarnFiles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should parse all yarn files in simple project', async () => {
|
||||
const projectPath = resolve(__dirname, 'fixtures/simple/.yarnproject');
|
||||
const result = await loadYarnProject(projectPath);
|
||||
|
||||
const titles = result.yarnFiles.flatMap(f => f.document.nodes.map(n => n.title));
|
||||
expect(titles).toContain('Start');
|
||||
expect(titles).toContain('Greeting');
|
||||
expect(titles).toContain('AnotherNode');
|
||||
});
|
||||
|
||||
it('should load a localised project', async () => {
|
||||
const projectPath = resolve(__dirname, 'fixtures/localised/.yarnproject');
|
||||
const result = await loadYarnProject(projectPath);
|
||||
|
||||
expect(result.project.projectName).toBe('Localised Project');
|
||||
expect(result.project.baseLanguage).toBe('en');
|
||||
expect(result.project.localisation).toBeDefined();
|
||||
expect(result.project.localisation!['zh-Hans']).toBeDefined();
|
||||
expect(result.yarnFiles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should load a complex project with multiple patterns', async () => {
|
||||
const projectPath = resolve(__dirname, 'fixtures/complex/.yarnproject');
|
||||
const result = await loadYarnProject(projectPath);
|
||||
|
||||
expect(result.project.projectFileVersion).toBe(4);
|
||||
expect(result.project.authorName).toEqual(['Author One', 'Author Two']);
|
||||
expect(result.project.sourceFiles).toContain('dialogue/**/*.yarn');
|
||||
expect(result.project.sourceFiles).toContain('scripts/**/*.yarn');
|
||||
});
|
||||
|
||||
it('should exclude files matching excludeFiles patterns', async () => {
|
||||
const projectPath = resolve(__dirname, 'fixtures/complex/.yarnproject');
|
||||
const result = await loadYarnProject(projectPath);
|
||||
|
||||
// Check that backup file is excluded
|
||||
const backupFiles = result.yarnFiles.filter(f =>
|
||||
f.relativePath.includes('backup'),
|
||||
);
|
||||
expect(backupFiles).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should include files from multiple source patterns', async () => {
|
||||
const projectPath = resolve(__dirname, 'fixtures/complex/.yarnproject');
|
||||
const result = await loadYarnProject(projectPath);
|
||||
|
||||
const relativePaths = result.yarnFiles.map(f => f.relativePath);
|
||||
|
||||
// Should include dialogue files
|
||||
expect(relativePaths.some(p => p.includes('dialogue'))).toBe(true);
|
||||
// Should include script files
|
||||
expect(relativePaths.some(p => p.includes('scripts'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse yarn documents correctly', async () => {
|
||||
const projectPath = resolve(__dirname, 'fixtures/simple/.yarnproject');
|
||||
const result = await loadYarnProject(projectPath);
|
||||
|
||||
const startNode = result.yarnFiles
|
||||
.flatMap(f => f.document.nodes)
|
||||
.find(n => n.title === 'Start');
|
||||
|
||||
expect(startNode).toBeDefined();
|
||||
expect(startNode!.body.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle nodes with tags', async () => {
|
||||
const projectPath = resolve(__dirname, 'fixtures/simple/.yarnproject');
|
||||
const result = await loadYarnProject(projectPath);
|
||||
|
||||
const greetingNode = result.yarnFiles
|
||||
.flatMap(f => f.document.nodes)
|
||||
.find(n => n.title === 'Greeting');
|
||||
|
||||
expect(greetingNode).toBeDefined();
|
||||
expect(greetingNode!.nodeTags).toContain('greeting');
|
||||
});
|
||||
|
||||
it('should throw LoadError for non-existent file', async () => {
|
||||
const projectPath = resolve(__dirname, 'fixtures/nonexistent/.yarnproject');
|
||||
|
||||
await expect(loadYarnProject(projectPath)).rejects.toThrow(LoadError);
|
||||
});
|
||||
|
||||
it('should throw error for invalid JSON', async () => {
|
||||
const projectPath = resolve(__dirname, 'fixtures/invalid/.yarnproject');
|
||||
|
||||
// This will fail because the fixture doesn't exist
|
||||
await expect(loadYarnProject(projectPath)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadYarnProjectSync', () => {
|
||||
it('should load a simple project synchronously', () => {
|
||||
const projectPath = resolve(__dirname, 'fixtures/simple/.yarnproject');
|
||||
const result = loadYarnProjectSync(projectPath);
|
||||
|
||||
expect(result.project.projectFileVersion).toBe(4);
|
||||
expect(result.yarnFiles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should parse all yarn files synchronously', () => {
|
||||
const projectPath = resolve(__dirname, 'fixtures/simple/.yarnproject');
|
||||
const result = loadYarnProjectSync(projectPath);
|
||||
|
||||
const titles = result.yarnFiles.flatMap(f => f.document.nodes.map(n => n.title));
|
||||
expect(titles).toContain('Start');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { validateYarnProject, isYarnProject } from '../src/loader/validator';
|
||||
|
||||
describe('validateYarnProject', () => {
|
||||
it('should validate a minimal valid config', () => {
|
||||
const config = {
|
||||
projectFileVersion: 4,
|
||||
sourceFiles: ['**/*.yarn'],
|
||||
baseLanguage: 'en',
|
||||
};
|
||||
|
||||
const result = validateYarnProject(config);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate a full config', () => {
|
||||
const config = {
|
||||
projectFileVersion: 4,
|
||||
projectName: 'Test Project',
|
||||
authorName: ['Author 1', 'Author 2'],
|
||||
sourceFiles: ['**/*.yarn'],
|
||||
excludeFiles: ['**/*.backup.yarn'],
|
||||
baseLanguage: 'en',
|
||||
localisation: {
|
||||
'zh-Hans': {
|
||||
strings: 'translations/zh-Hans.csv',
|
||||
assets: 'translations/zh-Hans/',
|
||||
},
|
||||
},
|
||||
definitions: ['custom.ysls.json'],
|
||||
compilerOptions: {
|
||||
requireVariableDeclarations: true,
|
||||
allowPreviewFeatures: false,
|
||||
},
|
||||
editorOptions: {
|
||||
yarnScriptEditor: { theme: 'dark' },
|
||||
},
|
||||
};
|
||||
|
||||
const result = validateYarnProject(config);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject missing projectFileVersion', () => {
|
||||
const config = {
|
||||
sourceFiles: ['**/*.yarn'],
|
||||
baseLanguage: 'en',
|
||||
};
|
||||
|
||||
const result = validateYarnProject(config);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.length).toBeGreaterThan(0);
|
||||
expect(result.errors[0].message).toContain('projectFileVersion');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject missing sourceFiles', () => {
|
||||
const config = {
|
||||
projectFileVersion: 4,
|
||||
baseLanguage: 'en',
|
||||
};
|
||||
|
||||
const result = validateYarnProject(config);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject missing baseLanguage', () => {
|
||||
const config = {
|
||||
projectFileVersion: 4,
|
||||
sourceFiles: ['**/*.yarn'],
|
||||
};
|
||||
|
||||
const result = validateYarnProject(config);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject invalid projectFileVersion type', () => {
|
||||
const config = {
|
||||
projectFileVersion: '4',
|
||||
sourceFiles: ['**/*.yarn'],
|
||||
baseLanguage: 'en',
|
||||
};
|
||||
|
||||
const result = validateYarnProject(config);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject projectFileVersion less than 2', () => {
|
||||
const config = {
|
||||
projectFileVersion: 1,
|
||||
sourceFiles: ['**/*.yarn'],
|
||||
baseLanguage: 'en',
|
||||
};
|
||||
|
||||
const result = validateYarnProject(config);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-array sourceFiles', () => {
|
||||
const config = {
|
||||
projectFileVersion: 4,
|
||||
sourceFiles: '**/*.yarn',
|
||||
baseLanguage: 'en',
|
||||
};
|
||||
|
||||
const result = validateYarnProject(config);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject additional properties', () => {
|
||||
const config = {
|
||||
projectFileVersion: 4,
|
||||
sourceFiles: ['**/*.yarn'],
|
||||
baseLanguage: 'en',
|
||||
unknownField: 'value',
|
||||
};
|
||||
|
||||
const result = validateYarnProject(config);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should accept definitions as string', () => {
|
||||
const config = {
|
||||
projectFileVersion: 4,
|
||||
sourceFiles: ['**/*.yarn'],
|
||||
baseLanguage: 'en',
|
||||
definitions: 'custom.ysls.json',
|
||||
};
|
||||
|
||||
const result = validateYarnProject(config);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept definitions as array of strings', () => {
|
||||
const config = {
|
||||
projectFileVersion: 4,
|
||||
sourceFiles: ['**/*.yarn'],
|
||||
baseLanguage: 'en',
|
||||
definitions: ['custom1.ysls.json', 'custom2.ysls.json'],
|
||||
};
|
||||
|
||||
const result = validateYarnProject(config);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isYarnProject', () => {
|
||||
it('should return true for valid config', () => {
|
||||
const config = {
|
||||
projectFileVersion: 4,
|
||||
sourceFiles: ['**/*.yarn'],
|
||||
baseLanguage: 'en',
|
||||
};
|
||||
|
||||
expect(isYarnProject(config)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid config', () => {
|
||||
const config = {
|
||||
sourceFiles: ['**/*.yarn'],
|
||||
baseLanguage: 'en',
|
||||
};
|
||||
|
||||
expect(isYarnProject(config)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm', 'cjs'],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
outDir: 'dist',
|
||||
},
|
||||
{
|
||||
entry: ['src/plugins/esbuild.ts'],
|
||||
format: ['esm', 'cjs'],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
outDir: 'dist/plugins',
|
||||
},
|
||||
{
|
||||
entry: ['src/plugins/rollup.ts'],
|
||||
format: ['esm', 'cjs'],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
outDir: 'dist/plugins',
|
||||
},
|
||||
{
|
||||
entry: ['src/plugins/vite.ts'],
|
||||
format: ['esm', 'cjs'],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
outDir: 'dist/plugins',
|
||||
},
|
||||
{
|
||||
entry: ['src/plugins/webpack.ts'],
|
||||
format: ['esm', 'cjs'],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
outDir: 'dist/plugins',
|
||||
},
|
||||
]);
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
// Allow .js imports to resolve to .ts files
|
||||
},
|
||||
},
|
||||
esbuild: {
|
||||
target: 'es2022',
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue