@marsx-dev/launcher 0.0.3 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/cli/index.d.ts +4 -0
  2. package/dist/cli/index.d.ts.map +1 -0
  3. package/dist/cli/index.js +1 -0
  4. package/dist/cli/init.d.ts +2 -0
  5. package/dist/cli/init.d.ts.map +1 -0
  6. package/dist/cli/init.js +32 -31
  7. package/dist/cli/migrate.d.ts +2 -0
  8. package/dist/cli/migrate.d.ts.map +1 -0
  9. package/dist/cli/migrate.js +3 -2
  10. package/dist/cli/start.d.ts +3 -0
  11. package/dist/cli/start.d.ts.map +1 -0
  12. package/dist/cli/start.js +3 -2
  13. package/dist/configuration.d.ts +30 -0
  14. package/dist/configuration.d.ts.map +1 -0
  15. package/dist/configuration.js +95 -33
  16. package/dist/index.d.ts +7 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +40 -5
  19. package/dist/launcher.d.ts +2 -0
  20. package/dist/launcher.d.ts.map +1 -0
  21. package/dist/launcher.js +6 -4
  22. package/dist/loader.d.ts +3 -0
  23. package/dist/loader.d.ts.map +1 -0
  24. package/dist/loader.js +5 -4
  25. package/dist/utils/compile.d.ts +9 -0
  26. package/dist/utils/compile.d.ts.map +1 -0
  27. package/dist/utils/compile.js +8 -6
  28. package/dist/utils/fileUtils.d.ts +12 -0
  29. package/dist/utils/fileUtils.d.ts.map +1 -0
  30. package/dist/utils/fileUtils.js +42 -0
  31. package/dist/utils/sfc.d.ts +55 -0
  32. package/dist/utils/sfc.d.ts.map +1 -0
  33. package/dist/utils/sfc.js +54 -97
  34. package/dist/utils/textUtils.d.ts +17 -0
  35. package/dist/utils/textUtils.d.ts.map +1 -0
  36. package/dist/utils/textUtils.js +105 -0
  37. package/dist/utils/v3.d.ts +12 -0
  38. package/dist/utils/v3.d.ts.map +1 -0
  39. package/dist/utils/v3.js +7 -6
  40. package/package.json +5 -2
  41. package/src/cli/index.ts +37 -0
  42. package/src/cli/init.ts +142 -0
  43. package/src/cli/migrate.ts +23 -0
  44. package/src/cli/start.ts +34 -0
  45. package/src/configuration.ts +104 -0
  46. package/src/index.ts +6 -0
  47. package/src/launcher.ts +39 -0
  48. package/src/loader.ts +65 -0
  49. package/src/utils/compile.ts +59 -0
  50. package/src/utils/fileUtils.ts +54 -0
  51. package/src/utils/sfc.ts +155 -0
  52. package/src/utils/textUtils.ts +82 -0
  53. package/src/utils/v3.ts +104 -0
  54. package/dist/utils/utils.js +0 -59
@@ -0,0 +1,142 @@
1
+ import chalk from 'chalk';
2
+ import { spawnSync } from 'child_process';
3
+ import { randomBytes } from 'crypto';
4
+ import path from 'path';
5
+ import { Config, CustomEnvironmentVariables } from '../configuration';
6
+ import { isDirectory, writeFileMakeDir } from '../utils/fileUtils';
7
+ import { assert } from '../utils/textUtils';
8
+ import { CliError } from './index';
9
+
10
+ const FASTIFY_DEPS = [
11
+ '@fastify/session',
12
+ 'fastify',
13
+ 'fastify-accepts',
14
+ 'fastify-compress',
15
+ 'fastify-cookie',
16
+ 'fastify-cors',
17
+ 'fastify-csrf',
18
+ 'fastify-flash',
19
+ 'fastify-formbody',
20
+ 'fastify-helmet',
21
+ 'fastify-multipart',
22
+ 'fastify-request-context',
23
+ 'fastify-static',
24
+ 'fastify-websocket',
25
+ ];
26
+
27
+ const COMMON_DEPS = [
28
+ '@marsx-dev/launcher',
29
+ 'aws-sdk',
30
+ 'axios',
31
+ 'azure-storage',
32
+ 'base-x',
33
+ 'bcrypt',
34
+ 'chokidar',
35
+ 'lodash',
36
+ 'mongodb4@npm:mongodb@4',
37
+ 'mongodb3@npm:mongodb@3',
38
+ 'typescript',
39
+ 'uuid',
40
+ 'xxhash',
41
+ ];
42
+
43
+ const V3_DEPS = [
44
+ 'babel-core',
45
+ 'babel-plugin-transform-react-jsx',
46
+ 'config',
47
+ 'crypto-js',
48
+ 'jsonwebtoken',
49
+ 'mobile-detect',
50
+ 'moment-timezone',
51
+ 'route-pattern',
52
+ 'route-sort',
53
+ ];
54
+
55
+ const DEFAULT_DEPS = [...FASTIFY_DEPS, ...COMMON_DEPS, ...V3_DEPS];
56
+
57
+ export async function initProject(projectName: string) {
58
+ if (!projectName.match(/^\w+$/) || projectName !== projectName.toLowerCase()) {
59
+ throw new CliError(
60
+ `Project name "${projectName}" may contain only lower case alphanumeric characters and underscores (eg. my_project_name)`,
61
+ );
62
+ }
63
+ const name = {
64
+ snakeCase: projectName,
65
+ withDashes: projectName.replace('_', '-'),
66
+ noSep: projectName.replace('_', ''),
67
+ };
68
+
69
+ const projectDir = path.resolve(projectName);
70
+
71
+ if (await isDirectory(projectDir)) {
72
+ throw new CliError(`Project dir "${projectName}" already exist. Delete it or choose a different name.`);
73
+ }
74
+
75
+ console.log(`Creating a new MarsX project in ${chalk.green(projectDir)}`);
76
+
77
+ const packageJson = {
78
+ name: name.withDashes,
79
+ version: '0.0.0',
80
+ private: true,
81
+ scripts: {
82
+ start: 'marsx start',
83
+ },
84
+ dependencies: {},
85
+ };
86
+ const packageJsonStr = JSON.stringify(packageJson, null, 2);
87
+ await writeFileMakeDir(path.join(projectDir, 'package.json'), packageJsonStr);
88
+
89
+ const config: Config = {
90
+ production: false,
91
+ port: 3000,
92
+ blocksDir: 'blocks',
93
+ cacheDir: '.cache',
94
+ mongoConn: '<CONN_STR>',
95
+ mongoDbName: name.withDashes,
96
+ azureStorageConnection: '<CONN_STR>',
97
+ azureStorageAccountName: name.noSep,
98
+ azureStorageUrl: `https://${name.noSep}.blob.core.windows.net`,
99
+ webFilesTable: 'webFiles',
100
+ webRecentFilesTable: 'webRecentFiles',
101
+ webFilesBlob: 'web-files',
102
+ secret: (await randomBytes(32)).toString('hex'),
103
+ importProjects: [
104
+ {
105
+ name: 'marsx-core',
106
+ url: 'https://core.marsx.dev',
107
+ api_key: '<API_KEY>',
108
+ git_commit_ish: 'main',
109
+ },
110
+ ],
111
+ };
112
+
113
+ await writeFileMakeDir(path.join(projectDir, 'config', 'default.json'), JSON.stringify(config, null, 2));
114
+ await writeFileMakeDir(
115
+ path.join(projectDir, 'config', 'custom-environment-variables.json'),
116
+ JSON.stringify(CustomEnvironmentVariables, null, 2),
117
+ );
118
+ await writeFileMakeDir(path.join(projectDir, '.gitignore'), 'node_modules\ndist\n.cache\n');
119
+
120
+ function run(...args: string[]) {
121
+ const cmd = args[0];
122
+ assert(cmd);
123
+ spawnSync(cmd, args.slice(1), { cwd: projectDir, stdio: 'inherit' });
124
+ }
125
+
126
+ console.log('\nInstalling dependencies. This might take a couple of minutes.');
127
+ run('npm', 'i', ...DEFAULT_DEPS);
128
+ console.log('');
129
+
130
+ run('git', 'init', '--initial-branch=main');
131
+ run('git', 'add', '.gitignore');
132
+ run('git', 'add', '-A');
133
+ run('git', 'commit', '-m', 'Initial MarsX commit');
134
+
135
+ console.log(`\n${chalk.green('Success!')} Created ${projectName} at ${projectDir}`);
136
+
137
+ console.log(`\nNext steps:`);
138
+ console.log(` - Open new project directory: ${chalk.underline(`cd ${projectName}`)}`);
139
+ console.log(` - Contact MarsX to get config params and update them: ${chalk.underline('code config/default.json')}`);
140
+ console.log(` - (optionally) Migrate V3 blocks using: ${chalk.underline('npx marsx migrate')}`);
141
+ console.log(` - Start local server: ${chalk.underline('npm run start')}`);
142
+ }
@@ -0,0 +1,23 @@
1
+ import chalk from 'chalk';
2
+ import { MongoClient } from 'mongodb';
3
+ import path from 'path';
4
+ import { config } from '../configuration';
5
+ import { serializeSfc } from '../utils/sfc';
6
+ import { writeFileMakeDir } from '../utils/fileUtils';
7
+ import { convertV3ToSfc, V3MongoBlock } from '../utils/v3';
8
+
9
+ export async function migrateV3ToV4() {
10
+ console.log('Connecting to MongoDB...');
11
+ const mongoConn = await new MongoClient(config.mongoConn).connect();
12
+ const db = mongoConn.db(config.mongoDbName);
13
+ const allBlocks = await db.collection<V3MongoBlock>('blocks').find().toArray();
14
+ console.log(`Downloaded ${allBlocks.length} block(s)`);
15
+
16
+ for (const block of allBlocks) {
17
+ const serialized = serializeSfc(convertV3ToSfc(block));
18
+ await writeFileMakeDir(path.join(config.blocksDir, serialized.filePath), serialized.content);
19
+ console.log(`Saved ${serialized.filePath}`);
20
+ }
21
+
22
+ console.log(chalk.green('\nMigration complete successfully!'));
23
+ }
@@ -0,0 +1,34 @@
1
+ import child_process from 'child_process';
2
+ import { config } from '../configuration';
3
+ import { launchBooter } from '../launcher';
4
+ import { assert } from '../utils/textUtils';
5
+
6
+ export const RESTART_EXIT_CODe = 9;
7
+
8
+ export const start = async () => {
9
+ if (config.production || process.env['MARSX_NO_SPAWN']) {
10
+ console.log(`MarsX process ${process.pid} starting...`);
11
+ await launchBooter();
12
+ console.log(`MarsX process ${process.pid} started`);
13
+ } else {
14
+ const spawnChildProc = () => {
15
+ assert(process.argv[0]);
16
+ const child = child_process.spawn(process.argv[0], ['--inspect', '--enable-source-maps', ...process.argv.slice(1)], {
17
+ cwd: process.cwd(),
18
+ env: { ...process.env, MARSX_NO_SPAWN: 'true' },
19
+ stdio: 'inherit',
20
+ });
21
+
22
+ child.on('close', (code: number) => {
23
+ console.log(`MarsX process ${child.pid} terminated with ${code}`);
24
+ if (code === RESTART_EXIT_CODe) {
25
+ setTimeout(spawnChildProc, 100);
26
+ } else {
27
+ process.exit(code);
28
+ }
29
+ });
30
+ };
31
+
32
+ spawnChildProc();
33
+ }
34
+ };
@@ -0,0 +1,104 @@
1
+ process.env['SUPPRESS_NO_CONFIG_WARNING'] = 'true';
2
+ import configModule from 'config';
3
+ import _ from 'lodash';
4
+ import path from 'path';
5
+ import * as yup from 'yup';
6
+ import { ValidationError } from 'yup';
7
+
8
+ export interface ImportProjectConfig {
9
+ name: string;
10
+ url: string;
11
+ api_key: string;
12
+ git_commit_ish?: string | undefined;
13
+ }
14
+
15
+ export interface Config {
16
+ production: boolean;
17
+ port: number;
18
+ blocksDir: string;
19
+ cacheDir: string;
20
+ mongoConn: string;
21
+ mongoDbName: string;
22
+ azureStorageConnection: string;
23
+ azureStorageAccountName: string;
24
+ azureStorageUrl: string;
25
+ secret: string;
26
+ webFilesTable: string;
27
+ webRecentFilesTable: string;
28
+ webFilesBlob: string;
29
+ importProjects: ImportProjectConfig[];
30
+ }
31
+
32
+ export class ConfigError extends Error {}
33
+
34
+ const ImportProjectSchema = yup.object({
35
+ name: yup.string().required(),
36
+ url: yup.string().url().required(),
37
+ api_key: yup.string().required(),
38
+ git_commit_ish: yup.string().optional(),
39
+ });
40
+
41
+ const ConfigSchema = yup.object().shape({
42
+ production: yup.boolean().required(),
43
+ port: yup.number().required().positive().integer(),
44
+ blocksDir: yup
45
+ .string()
46
+ .required()
47
+ .transform(v => path.resolve(v)),
48
+ cacheDir: yup
49
+ .string()
50
+ .required()
51
+ .transform(v => path.resolve(v)),
52
+ mongoConn: yup.string().required(),
53
+ mongoDbName: yup.string().required(),
54
+ azureStorageConnection: yup.string().required(),
55
+ azureStorageAccountName: yup.string().required(),
56
+ azureStorageUrl: yup.string().required(),
57
+ secret: yup.string().required(),
58
+ webFilesTable: yup.string().required(),
59
+ webRecentFilesTable: yup.string().required(),
60
+ webFilesBlob: yup.string().required(),
61
+ importProjects: yup.array().of(ImportProjectSchema).required(),
62
+ });
63
+
64
+ function ErrorThrowingConfig(errorMessage: string) {
65
+ return new Proxy({} as never, {
66
+ get() {
67
+ throw new ConfigError(errorMessage);
68
+ },
69
+ });
70
+ }
71
+
72
+ function validateConfig(): Config {
73
+ if (configModule.util.getConfigSources().length === 0) {
74
+ return ErrorThrowingConfig('Config file not found, ensure you have "config/default.json" file.');
75
+ }
76
+
77
+ const configObject = configModule.util.toObject();
78
+ try {
79
+ return ConfigSchema.validateSync(configObject, { abortEarly: false, stripUnknown: false });
80
+ } catch (e) {
81
+ if (e instanceof ValidationError) {
82
+ return ErrorThrowingConfig(e.errors.join('\n'));
83
+ } else {
84
+ throw e;
85
+ }
86
+ }
87
+ }
88
+
89
+ export const config = validateConfig();
90
+
91
+ function getEnvVarMapping() {
92
+ const result: Record<string, string | { __name: string; __format: string }> = {};
93
+ for (const [name, field] of Object.entries(ConfigSchema.fields)) {
94
+ const envVar = `MARSX_${_.snakeCase(name).toUpperCase()}`;
95
+ if ('type' in field && (field.type === 'object' || field.type === 'array')) {
96
+ result[name] = { __name: envVar, __format: 'json' };
97
+ } else {
98
+ result[name] = envVar;
99
+ }
100
+ }
101
+ return result;
102
+ }
103
+
104
+ export const CustomEnvironmentVariables = getEnvVarMapping();
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { config, ImportProjectConfig, Config } from './configuration';
2
+ export { compileSfcSource, transpileTypescript } from './utils/compile';
3
+ export { parseSFC, serializeSfc, parseSfcPath, serializeSfcPath, SfcBlock, SfcPath, SfcSource } from './utils/sfc';
4
+ export { convertSfcToV3, convertV3ToSfc, V3MongoBlock } from './utils/v3';
5
+ export * as textUtils from './utils/textUtils';
6
+ export * as fileUtils from './utils/fileUtils';
@@ -0,0 +1,39 @@
1
+ import { config } from './configuration';
2
+ import { loadAllBlocks } from './loader';
3
+ import { compileSfcSource, transpileTypescript } from './utils/compile';
4
+ import { parseSFC, serializeSfc } from './utils/sfc';
5
+ import { writeFileMakeDir } from './utils/fileUtils';
6
+ import { assert } from './utils/textUtils';
7
+ import { convertSfcToV3, convertV3ToSfc } from './utils/v3';
8
+
9
+ const LAUNCHER_UTILS = {
10
+ config,
11
+ parseSFC,
12
+ serializeSfc,
13
+ convertV3ToSfc,
14
+ convertSfcToV3,
15
+ transpileTypescript,
16
+ compileSfcSource,
17
+ writeFileMakeDir,
18
+ };
19
+
20
+ export async function launchBooter(booterBlockName = 'Booter') {
21
+ const allBlocks = await loadAllBlocks();
22
+
23
+ const booterBlocks = allBlocks.filter(b => b.path.name === booterBlockName);
24
+ if (booterBlocks.length === 0)
25
+ throw new Error(`Booter block ${booterBlockName} not found. Ensure you have it locally or it is imported.`);
26
+
27
+ // Take last because order of imports matter and local blocks always override imported.
28
+ const booterBlock = booterBlocks[booterBlocks.length - 1];
29
+ assert(booterBlock);
30
+
31
+ const compiledBooterPath = await compileSfcSource(booterBlock, 'BlockFunction');
32
+ try {
33
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
34
+ await require(compiledBooterPath).default(allBlocks, LAUNCHER_UTILS);
35
+ } catch (e) {
36
+ console.error('Booter failed with:', e);
37
+ process.exit(1);
38
+ }
39
+ }
package/src/loader.ts ADDED
@@ -0,0 +1,65 @@
1
+ import axios from 'axios';
2
+ import crypto from 'crypto';
3
+ import { promises as fs } from 'fs';
4
+ import stringify from 'json-stable-stringify';
5
+ import _ from 'lodash';
6
+ import path from 'path';
7
+ import { config, ImportProjectConfig } from './configuration';
8
+ import { parseSFC, SfcBlock } from './utils/sfc';
9
+ import { isFile, listFilesRecursive, writeFileMakeDir } from './utils/fileUtils';
10
+ import { convertV3ToSfc, V3MongoBlock } from './utils/v3';
11
+
12
+ async function downloadFromExternal(externalImport: ImportProjectConfig): Promise<SfcBlock[]> {
13
+ const params = { api_key: externalImport.api_key, git_commit_ish: externalImport.git_commit_ish || '' };
14
+
15
+ console.log(`Downloading blocks from ${externalImport.url}`);
16
+ try {
17
+ const v4Resp = await axios.get<SfcBlock[]>(`${externalImport.url}/api/GetExportedAppBlocksV4`, { params });
18
+ return v4Resp.data;
19
+ } catch (e) {
20
+ console.log(`${externalImport.url} does not support V4, fallback to V3`);
21
+ }
22
+
23
+ const v3Resp = await axios.get<V3MongoBlock[]>(`${externalImport.url}/api/GetExportedAppBlocks`, { params });
24
+ return v3Resp.data.map(b => convertV3ToSfc(b));
25
+ }
26
+
27
+ async function loadCachedOrDownload(externalImport: ImportProjectConfig): Promise<SfcBlock[]> {
28
+ const hash = crypto.createHash('md5').update(stringify(externalImport)).digest('hex');
29
+ const cacheFileName = `${externalImport.name}_${externalImport.git_commit_ish}_${hash}.json`.replace(/[^\w.]+/g, '_');
30
+ const cacheFilePath = path.join(config.cacheDir, 'imports', cacheFileName);
31
+
32
+ if (await isFile(cacheFilePath)) {
33
+ const content = await fs.readFile(cacheFilePath);
34
+ console.log(`Loading cached blocks from ${externalImport.url}`);
35
+ return JSON.parse(content.toString('utf-8'));
36
+ }
37
+
38
+ const data = await downloadFromExternal(externalImport);
39
+ console.log(`Downloaded ${data.length} blocks from ${externalImport.url}`);
40
+
41
+ await writeFileMakeDir(cacheFilePath, JSON.stringify(data, null, 2), 'utf-8');
42
+ return data;
43
+ }
44
+
45
+ async function downloadAll(externalImports: ImportProjectConfig[]) {
46
+ const responses = await Promise.all(externalImports.map(loadCachedOrDownload));
47
+ return _.flatten(responses);
48
+ }
49
+
50
+ async function readBlockFile(blocksDir: string, filePath: string): Promise<SfcBlock> {
51
+ const content = await fs.readFile(filePath);
52
+ const relPath = path.relative(blocksDir, filePath);
53
+ return parseSFC(relPath, content);
54
+ }
55
+
56
+ async function readBlockFiles(blocksDir: string) {
57
+ const files = await listFilesRecursive(blocksDir);
58
+ return await Promise.all(files.filter(f => !path.basename(f).startsWith('.')).map(f => readBlockFile(blocksDir, f)));
59
+ }
60
+
61
+ export async function loadAllBlocks(): Promise<SfcBlock[]> {
62
+ const externalBlocks = await downloadAll(config.importProjects);
63
+ const localBlocks = await readBlockFiles(config.blocksDir);
64
+ return [...externalBlocks, ...localBlocks];
65
+ }
@@ -0,0 +1,59 @@
1
+ import { SourceMapPayload } from 'module';
2
+ import path from 'path';
3
+ import ts from 'typescript';
4
+ import { config } from '../configuration';
5
+ import { SfcBlock } from './sfc';
6
+ import { writeFileMakeDir } from './fileUtils';
7
+ import { assert } from './textUtils';
8
+
9
+ function genSourceMapComment(sourceMap: SourceMapPayload): string {
10
+ return `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(sourceMap)).toString('base64')}`;
11
+ }
12
+
13
+ export function transpileTypescript(
14
+ sourceCode: string,
15
+ options?: {
16
+ sourceRoot?: string;
17
+ originalFilePath?: string;
18
+ compiledFilePath?: string;
19
+ lineOffset?: number | undefined;
20
+ },
21
+ ): string {
22
+ const compiledFilePath = options?.compiledFilePath || '<UNKNOWN>.js';
23
+ const originalFilePath = options?.originalFilePath || '<UNKNOWN>.ts';
24
+
25
+ if (options?.lineOffset) {
26
+ sourceCode = '\n'.repeat(options.lineOffset) + sourceCode;
27
+ }
28
+
29
+ const transpiled = ts.transpileModule(sourceCode, {
30
+ compilerOptions: {
31
+ module: ts.ModuleKind.CommonJS,
32
+ target: ts.ScriptTarget.Latest,
33
+ esModuleInterop: true,
34
+ sourceMap: true,
35
+ // inlineSources: true,
36
+ // inlineSourceMap: true,
37
+ },
38
+ });
39
+ assert(transpiled.sourceMapText);
40
+
41
+ const sourceMap: SourceMapPayload = JSON.parse(transpiled.sourceMapText);
42
+ sourceMap.sourceRoot = options?.sourceRoot || '';
43
+ sourceMap.file = compiledFilePath;
44
+ sourceMap.sources = [originalFilePath];
45
+ const sourceMapComment = genSourceMapComment(sourceMap);
46
+ return transpiled.outputText.replace(/\n\/\/# sourceMappingURL=.+/, '') + sourceMapComment;
47
+ }
48
+
49
+ export async function compileSfcSource(sfcBlock: SfcBlock, sourceId: string): Promise<string> {
50
+ const sourceCode = sfcBlock.sources[sourceId];
51
+ if (!sourceCode) throw new Error(`Source code block ${sourceId} not found in ${sfcBlock.path.filePath}`);
52
+
53
+ const compiledFilePath = path.join(config.cacheDir, 'compiled', `${sfcBlock.path.filePath}.${sourceId}.js`);
54
+ const originalFilePath = path.join(config.blocksDir, sfcBlock.path.filePath);
55
+ const code = transpileTypescript(sourceCode.source, { compiledFilePath, originalFilePath, lineOffset: sourceCode.lineOffset });
56
+
57
+ await writeFileMakeDir(compiledFilePath, code, 'utf-8');
58
+ return compiledFilePath;
59
+ }
@@ -0,0 +1,54 @@
1
+ import { promises as fs } from 'fs';
2
+ import _ from 'lodash';
3
+ import { Abortable } from 'node:events';
4
+ import { Mode, ObjectEncodingOptions, OpenMode } from 'node:fs';
5
+ import { Stream } from 'node:stream';
6
+ import path from 'path';
7
+
8
+ export async function listFilesRecursive(dir: string): Promise<string[]> {
9
+ const entries = await fs.readdir(dir);
10
+
11
+ const files = await Promise.all(
12
+ entries.map(async (entry): Promise<string[]> => {
13
+ const entryPath = path.resolve(dir, entry);
14
+ return (await fs.stat(entryPath)).isDirectory() ? listFilesRecursive(entryPath) : [entryPath];
15
+ }),
16
+ );
17
+ return _.flatten(files);
18
+ }
19
+
20
+ export async function isFile(filePath: string): Promise<boolean> {
21
+ try {
22
+ return (await fs.stat(filePath)).isFile();
23
+ } catch (e) {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ export async function isDirectory(dirPath: string): Promise<boolean> {
29
+ try {
30
+ return (await fs.stat(dirPath)).isDirectory();
31
+ } catch (e) {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ export async function writeFileMakeDir(
37
+ filePath: string,
38
+ data:
39
+ | string
40
+ | NodeJS.ArrayBufferView
41
+ | Iterable<string | NodeJS.ArrayBufferView>
42
+ | AsyncIterable<string | NodeJS.ArrayBufferView>
43
+ | Stream,
44
+ options?:
45
+ | (ObjectEncodingOptions & {
46
+ mode?: Mode | undefined;
47
+ flag?: OpenMode | undefined;
48
+ } & Abortable)
49
+ | BufferEncoding
50
+ | null,
51
+ ): Promise<void> {
52
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
53
+ await fs.writeFile(filePath, data, options);
54
+ }
@@ -0,0 +1,155 @@
1
+ import { CompilerError } from '@vue/compiler-core';
2
+ import JSON5 from 'json5';
3
+ import _ from 'lodash';
4
+ import path from 'path';
5
+ import { escapeCloseTag, escapeHtmlAttr, parseVueLike, stableHtmlAttributes, stablePrettyJson, unescapeCloseTag } from './textUtils';
6
+
7
+ export const METADATA_SECTION_ID = 'metadata';
8
+ export const MARS_SFC_EXT = 'mars';
9
+
10
+ const SAVE_EMPTY_SOURCES = true;
11
+
12
+ export type SfcPath = {
13
+ folder: string;
14
+ name: string;
15
+ blockTypeName: string;
16
+ ext: string;
17
+ filePath: string;
18
+ };
19
+
20
+ export type SfcSource = {
21
+ source: string;
22
+ lang: keyof typeof LANG_TAG_MAP | string | undefined;
23
+ props?: Record<string, string | undefined>;
24
+ lineOffset?: number;
25
+ };
26
+
27
+ export type SfcBlock = { path: SfcPath } & (
28
+ | { metadata: Record<string, unknown>; jsons: Record<string, unknown>; sources: Record<string, SfcSource>; rawContent: null }
29
+ | { metadata: EmptyObject; jsons: EmptyObject; sources: EmptyObject; rawContent: Buffer }
30
+ );
31
+
32
+ export function parseSfcPath(filePath: string): SfcPath {
33
+ // Blog/BlogPost.page.mars => {folder:"Blog", name: "BlogPost", blockTypeName: "page", ext: "mars", fullName: "Blog.BlogPost"}
34
+ const originalParsed = path.parse(filePath);
35
+ const withoutExt = originalParsed.name;
36
+ const ext = originalParsed.ext.slice(1);
37
+
38
+ const parsed = path.parse(withoutExt);
39
+ if (!parsed.name || !parsed.ext) throw new Error(`Invalid block file path: ${filePath}`);
40
+ const blockTypeName = parsed.ext.slice(1);
41
+
42
+ return {
43
+ folder: originalParsed.dir,
44
+ name: parsed.name,
45
+ blockTypeName,
46
+ ext,
47
+ filePath,
48
+ };
49
+ }
50
+
51
+ export function serializeSfcPath(identity: SfcPath): string {
52
+ return path.join(identity.folder, `${identity.name}.${identity.blockTypeName}.${identity.ext}`);
53
+ }
54
+
55
+ type EmptyObject = {
56
+ [K in string]: never;
57
+ };
58
+
59
+ const LANG_TAG_MAP = {
60
+ html: 'html',
61
+ pug: 'template',
62
+ js: 'script',
63
+ jsx: 'script',
64
+ ts: 'script',
65
+ tsx: 'script',
66
+ css: 'style',
67
+ scss: 'style',
68
+ less: 'style',
69
+ sass: 'style',
70
+ stylus: 'style',
71
+ text: 'text',
72
+ };
73
+
74
+ export function parseSFC(filePath: string, content: Buffer): SfcBlock {
75
+ const sfcPath = parseSfcPath(filePath);
76
+ if (sfcPath.ext !== MARS_SFC_EXT) {
77
+ return { path: sfcPath, metadata: {}, jsons: {}, sources: {}, rawContent: content };
78
+ }
79
+
80
+ const block: SfcBlock = {
81
+ path: parseSfcPath(filePath),
82
+ metadata: {},
83
+ jsons: {},
84
+ sources: {},
85
+ rawContent: null,
86
+ };
87
+ const seenIds = new Set<string>();
88
+ const errors: (CompilerError | SyntaxError)[] = [];
89
+
90
+ for (const node of parseVueLike(content.toString('utf-8'), errors)) {
91
+ const { id: sectionId, lang, ...props } = node.props;
92
+
93
+ if (!sectionId) {
94
+ errors.push({ name: `missing-id`, message: `Id property is missing`, code: 0, loc: node.loc });
95
+ continue;
96
+ }
97
+ if (seenIds.has(sectionId)) {
98
+ errors.push({ name: `duplicate-id`, message: `Duplicate section id: ${sectionId}`, code: 0, loc: node.loc });
99
+ continue;
100
+ }
101
+ seenIds.add(sectionId);
102
+
103
+ if (node.tag === 'json') {
104
+ try {
105
+ const jsonData = JSON5.parse(node.content);
106
+ if (sectionId === METADATA_SECTION_ID) {
107
+ block.metadata = jsonData;
108
+ } else {
109
+ block.jsons[sectionId] = jsonData;
110
+ }
111
+ } catch (e) {
112
+ errors.push({ name: 'invalid-json', message: `${e}`, code: 0, loc: node.loc });
113
+ }
114
+ } else {
115
+ const source = unescapeCloseTag(node.content.slice(1, -1), node.tag);
116
+ const lineOffset = node.loc.start.line;
117
+ block.sources[sectionId] = { source, lang, props, lineOffset };
118
+ }
119
+ }
120
+
121
+ if (errors.length) {
122
+ throw new Error(`Parsing SFC ${filePath} failed with ${JSON.stringify(errors)}`);
123
+ }
124
+
125
+ return block;
126
+ }
127
+
128
+ export function serializeSfc(block: SfcBlock): { filePath: string; content: Buffer } {
129
+ const filePath = serializeSfcPath(block.path);
130
+ if (block.path.ext !== MARS_SFC_EXT) {
131
+ if (block.rawContent === null) throw new Error(`SfcBlock must have rawContent if ext!="${MARS_SFC_EXT}"`);
132
+ return { filePath, content: block.rawContent };
133
+ }
134
+
135
+ if (block.rawContent !== null) throw new Error(`SfcBlock cannot have rawContent if ext=="${MARS_SFC_EXT}"`);
136
+
137
+ let content = '';
138
+
139
+ for (const [name, source] of [[METADATA_SECTION_ID, block.metadata] as const, ..._.sortBy(Object.entries(block.jsons), e => e[0])]) {
140
+ const jsonStr = stablePrettyJson(source);
141
+ content += `<json id="${escapeHtmlAttr(name)}">\n${jsonStr}</json>\n\n`;
142
+ }
143
+
144
+ for (const [name, source] of _.sortBy(Object.entries(block.sources), e => e[0])) {
145
+ if (!SAVE_EMPTY_SOURCES && !source.source.trim()) continue;
146
+ const tag = (LANG_TAG_MAP as Record<string, string>)[source.lang || ''] || 'text';
147
+ const id = escapeHtmlAttr(name);
148
+ const lang = escapeHtmlAttr(source.lang || '');
149
+ const props = stableHtmlAttributes(source.props);
150
+ const text = escapeCloseTag(source.source, tag);
151
+ content += `<${tag} id="${id}" lang="${lang}"${props}>\n${text}\n</${tag}>\n\n`;
152
+ }
153
+
154
+ return { filePath, content: Buffer.from(content, 'utf-8') };
155
+ }