vusion-api
Version:
Vusion Node.js API
396 lines (327 loc) • 13.2 kB
text/typescript
/// <reference path="../../types/line-reader.d.ts" />
import * as fs from 'fs-extra';
import * as path from 'path';
import * as shell from 'shelljs';
import * as lineReader from 'line-reader';
import * as pluralize from 'pluralize';
import { kebab2Camel, Camel2kebab } from '../utils';
import FSEntry from './FSEntry';
import TemplateHandler from './TemplateHandler';
import ScriptHandler from './ScriptHandler';
import StyleHandler from './StyleHandler';
import traverse from '@babel/traverse';
const fetchPartialContent = (content: string, tag: string) => {
const reg = new RegExp(`<${tag}.*?>([\\s\\S]+)<\\/${tag}>`);
const m = content.match(reg);
return m ? m[1].trim() + '\n' : '';
};
export enum VueFileExtendMode {
style = 'style',
script = 'script',
template = 'template',
all = 'all',
};
export default class VueFile extends FSEntry {
tagName: string; // 中划线名称
componentName: string; // 驼峰名称
alias: string;
// 子组件
// 为`undefined`表示未打开过,为数组表示已经打开。
parent: VueFile;
children: VueFile[];
isChild: boolean;
// 单文件内容
// 为`undefined`表示未打开过
content: string;
template: string;
script: string;
style: string;
sample: string;
templateHandler: TemplateHandler; // 为`undefined`表示还未解析
scriptHandler: ScriptHandler; // 为`undefined`表示还未解析
styleHandler: StyleHandler; // 为`undefined`表示还未解析
constructor(fullPath: string) {
super(fullPath, undefined);
this.isVue = true;
this.tagName = VueFile.resolveTagName(fullPath);
this.componentName = kebab2Camel(this.tagName);
}
/**
* 提前检测 VueFile 文件类型,以及子组件等
* 需要异步,否则可能会比较慢
*/
async preOpen() {
if (!fs.existsSync(this.fullPath))
return;
const stats = fs.statSync(this.fullPath);
this.isDirectory = stats.isDirectory();
if (this.isDirectory)
this.children = await this.loadDirectory();
this.alias = await this.readTitleInReadme();
}
/**
* 尝试读取 README.md 的标题行
* 在前 10 行中查找
*/
async readTitleInReadme(): Promise<string> {
const readmePath = path.join(this.fullPath, 'README.md');
if (!fs.existsSync(readmePath))
return;
const titleRE = /^#\s+\w+\s*(.*?)$/;
let count = 0;
let title: string;
return new Promise((resolve, reject) => {
lineReader.eachLine(readmePath, { encoding: 'utf8' }, (line, last) => {
line = line.trim();
const cap = titleRE.exec(line);
if (cap) {
title = cap[1];
return false;
} else {
count++;
if (count > 10)
return false;
}
}, (err) => {
err? reject(err) : resolve(title);
});
});
}
async loadDirectory() {
if (!fs.existsSync(this.fullPath))
throw new Error(`Cannot find: ${this.fullPath}`);
const children: Array<VueFile> = [];
const fileNames = await fs.readdir(this.fullPath);
fileNames.forEach((name) => {
if (!name.endsWith('.vue'))
return;
const fullPath = path.join(this.fullPath, name);
let vueFile;
if (this.isWatched)
vueFile = VueFile.fetch(fullPath);
else
vueFile = new VueFile(fullPath);
vueFile.parent = this;
vueFile.isChild = true;
children.push(vueFile);
});
return children;
}
async forceOpen() {
this.close();
await this.preOpen();
await this.load();
this.isOpen = true;
}
close() {
this.isDirectory = undefined;
this.alias = undefined;
this.children = undefined;
// 单文件内容
this.content = undefined;
this.template = undefined;
this.script = undefined;
this.style = undefined;
this.sample = undefined;
this.templateHandler = undefined;
this.scriptHandler = undefined;
this.styleHandler = undefined;
this.isOpen = false;
}
protected async load() {
if (!fs.existsSync(this.fullPath))
throw new Error(`Cannot find: ${this.fullPath}!`);
// const stats = fs.statSync(this.fullPath);
// this.isDirectory = stats.isDirectory();
if (this.isDirectory) {
if (fs.existsSync(path.join(this.fullPath, 'index.js')))
this.script = await fs.readFile(path.join(this.fullPath, 'index.js'), 'utf8');
else
throw new Error(`Cannot find 'index.js' in multifile Vue!`);
if (fs.existsSync(path.join(this.fullPath, 'index.html')))
this.template = await fs.readFile(path.join(this.fullPath, 'index.html'), 'utf8');
if (fs.existsSync(path.join(this.fullPath, 'module.css')))
this.style = await fs.readFile(path.join(this.fullPath, 'module.css'), 'utf8');
if (fs.existsSync(path.join(this.fullPath, 'sample.vue'))) {
const sampleRaw = await fs.readFile(path.join(this.fullPath, 'sample.vue'), 'utf8');
const templateRE = /<template.*?>([\s\S]*?)<\/template>/i;
const sample = sampleRaw.match(templateRE);
this.sample = sample && sample[1].trim();
}
} else {
this.content = await fs.readFile(this.fullPath, 'utf8');
this.template = fetchPartialContent(this.content, 'template');
this.script = fetchPartialContent(this.content, 'script');
this.style = fetchPartialContent(this.content, 'style');
}
return this;
}
async save() {
this.isSaving = true;
if (fs.statSync(this.fullPath).isDirectory() !== this.isDirectory)
shell.rm('-rf', this.fullPath);
let template = this.template;
let script = this.script;
let style = this.style;
if (this.templateHandler)
this.template = template = this.templateHandler.generate();
if (this.scriptHandler)
this.script = script = this.scriptHandler.generate();
if (this.styleHandler)
this.style = style = this.styleHandler.generate();
let result;
if (this.isDirectory) {
fs.ensureDirSync(this.fullPath);
const promises = [];
template && promises.push(fs.writeFile(path.resolve(this.fullPath, 'index.html'), template));
script && promises.push(fs.writeFile(path.resolve(this.fullPath, 'index.js'), script));
style && promises.push(fs.writeFile(path.resolve(this.fullPath, 'module.css'), style));
result = await Promise.all(promises);
} else {
const contents = [];
template && contents.push(`<template>\n${template}</template>`);
script && contents.push(`<script>\n${script}</script>`);
style && contents.push(`<style module>\n${style}</style>`);
result = await fs.writeFile(this.fullPath, contents.join('\n\n') + '\n');
}
super.save();
return result;
}
parseTemplate() {
if (this.templateHandler)
return;
this.templateHandler = new TemplateHandler(this.template);
}
parseScript() {
if (this.scriptHandler)
return;
this.scriptHandler = new ScriptHandler(this.script);
}
parseStyle() {
if (this.styleHandler)
return;
this.styleHandler = new StyleHandler(this.style);
}
checkTransform() {
if (!this.isDirectory)
return true; // @TODO
else {
const files = fs.readdirSync(this.fullPath);
const normalBlocks = ['index.html', 'index.js', 'module.css'];
const extraBlocks: Array<string> = [];
files.forEach((file) => {
if (!normalBlocks.includes(file))
extraBlocks.push(file);
});
return extraBlocks.length ? extraBlocks : true;
}
}
transform() {
const isDirectory = this.isDirectory;
this.parseScript();
this.parseStyle();
// this.parseTemplate();
function shortenPath(filePath: string) {
if (filePath.startsWith('../')) {
let newPath = filePath.replace(/^\.\.\//, '');
if (!newPath.startsWith('../'))
newPath = './' + newPath;
return newPath;
} else
return filePath;
}
function lengthenPath(filePath: string) {
if (filePath.startsWith('.'))
return path.join('../', filePath);
else
return filePath;
}
traverse(this.scriptHandler.ast, {
ImportDeclaration(nodePath) {
if (nodePath.node.source)
nodePath.node.source.value = isDirectory ? shortenPath(nodePath.node.source.value) : lengthenPath(nodePath.node.source.value);
},
ExportAllDeclaration(nodePath) {
if (nodePath.node.source)
nodePath.node.source.value = isDirectory ? shortenPath(nodePath.node.source.value) : lengthenPath(nodePath.node.source.value);
},
ExportNamedDeclaration(nodePath) {
if (nodePath.node.source)
nodePath.node.source.value = isDirectory ? shortenPath(nodePath.node.source.value) : lengthenPath(nodePath.node.source.value);
},
});
this.styleHandler.ast.walkAtRules((node) => {
if (node.name !== 'import')
return;
const value = node.params.slice(1, -1);
node.params = `'${isDirectory ? shortenPath(value) : lengthenPath(value)}'`;
});
this.styleHandler.ast.walkDecls((node) => {
const re = /url\((['"])(.+?)['"]\)/;
const cap = re.exec(node.value);
if (cap) {
node.value = node.value.replace(re, (m, quote, url) => {
url = isDirectory ? shortenPath(url) : lengthenPath(url);
return `url(${quote}${url}${quote})`;
});
}
});
this.isDirectory = !this.isDirectory;
}
extend(mode: VueFileExtendMode, fullPath: string, fromPath: string) {
const vueFile = new VueFile(fullPath);
vueFile.isDirectory = true;
// JS
const tempComponentName = this.componentName.replace(/^[A-Z]/, 'O');
vueFile.script = fromPath.endsWith('.vue')
? `import ${this.componentName === vueFile.componentName ? tempComponentName : this.componentName} from '${fromPath}';`
: `import { ${this.componentName}${this.componentName === vueFile.componentName ? ' as ' + tempComponentName : ''} } from '${fromPath}';`;
vueFile.script += `\n
export const ${vueFile.componentName} = {
name: '${vueFile.tagName}',
extends: ${this.componentName === vueFile.componentName ? tempComponentName : this.componentName},
};
export default ${vueFile.componentName};
`;
if (mode === VueFileExtendMode.style || mode === VueFileExtendMode.all)
vueFile.style = `@extend;\n`;
if (mode === VueFileExtendMode.template || mode === VueFileExtendMode.all)
vueFile.template = this.template;
return vueFile;
}
private static _splitPath(fullPath: string) {
const arr = fullPath.split(path.sep);
let pos = arr.length - 1; // root Vue 的位置
while(arr[pos] && arr[pos].endsWith('.vue'))
pos--;
pos++;
return { arr, pos };
}
/**
* 计算根组件所在的目录
* @param fullPath 完整路径
*/
static resolveRootVueDir(fullPath: string) {
const { arr, pos } = VueFile._splitPath(fullPath);
return arr.slice(0, pos).join(path.sep);
}
static resolveTagName(fullPath: string) {
const { arr, pos } = VueFile._splitPath(fullPath);
const vueNames = arr.slice(pos);
let result: Array<string> = [];
vueNames.forEach((vueName) => {
const baseName = path.basename(vueName, '.vue');
const arr = baseName.split('-');
if (arr[0].length === 1) // u-navbar
result = arr;
else if (pluralize(baseName) === result[result.length - 1]) // 如果是前一个的单数形式,u-actions -> action,u-checkboxes -> checkbox
result[result.length - 1] = baseName;
else
result.push(baseName);
});
return result.join('-');
}
static fetch(fullPath: string) {
return super.fetch(fullPath) as VueFile;
}
}