@marsx-dev/launcher 0.0.1 → 0.0.4

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 (49) 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 +67 -36
  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 +1 -0
  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 +1 -0
  13. package/dist/configuration.d.ts +24 -0
  14. package/dist/configuration.d.ts.map +1 -0
  15. package/dist/configuration.js +1 -0
  16. package/dist/index.d.ts +6 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +39 -5
  19. package/dist/launcher.d.ts +2 -0
  20. package/dist/launcher.d.ts.map +1 -0
  21. package/dist/launcher.js +1 -0
  22. package/dist/loader.d.ts +3 -0
  23. package/dist/loader.d.ts.map +1 -0
  24. package/dist/loader.js +1 -0
  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 +1 -0
  28. package/dist/utils/sfc.d.ts +55 -0
  29. package/dist/utils/sfc.d.ts.map +1 -0
  30. package/dist/utils/sfc.js +1 -0
  31. package/dist/utils/utils.d.ts +16 -0
  32. package/dist/utils/utils.d.ts.map +1 -0
  33. package/dist/utils/utils.js +1 -0
  34. package/dist/utils/v3.d.ts +12 -0
  35. package/dist/utils/v3.d.ts.map +1 -0
  36. package/dist/utils/v3.js +1 -0
  37. package/package.json +12 -53
  38. package/src/cli/index.ts +37 -0
  39. package/src/cli/init.ts +141 -0
  40. package/src/cli/migrate.ts +23 -0
  41. package/src/cli/start.ts +34 -0
  42. package/src/configuration.ts +53 -0
  43. package/src/index.ts +5 -0
  44. package/src/launcher.ts +38 -0
  45. package/src/loader.ts +65 -0
  46. package/src/utils/compile.ts +58 -0
  47. package/src/utils/sfc.ts +178 -0
  48. package/src/utils/utils.ts +72 -0
  49. package/src/utils/v3.ts +105 -0
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+
3
+ import chalk from 'chalk';
4
+ import { Command } from 'commander';
5
+ import { initProject } from './init';
6
+ import { migrateV3ToV4 } from './migrate';
7
+ import { start } from './start';
8
+
9
+ export class CliError extends Error {}
10
+
11
+ async function main() {
12
+ try {
13
+ const program = new Command();
14
+
15
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
16
+ const version = require('../../package.json').version;
17
+ program.name('marsx').description('CLI for MarsX launcher').version(version);
18
+
19
+ program.command('start', { isDefault: true }).description('Start MarsX server').action(start);
20
+ program
21
+ .command('init <project_name>')
22
+ .description('Initialize a new MarsX project named <project_name> in a directory of the same name')
23
+ .action(initProject);
24
+ program.command('migrate').description('Migrate MarsX V3 project to V4').action(migrateV3ToV4);
25
+
26
+ await program.parseAsync();
27
+ } catch (e) {
28
+ if (e instanceof CliError) {
29
+ console.error(chalk.red(e.message));
30
+ } else {
31
+ console.error(chalk.red(e));
32
+ }
33
+ process.exit(1);
34
+ }
35
+ }
36
+
37
+ main();
@@ -0,0 +1,141 @@
1
+ import chalk from 'chalk';
2
+ import { spawnSync } from 'child_process';
3
+ import { randomBytes } from 'crypto';
4
+ import path from 'path';
5
+ import { Configuration } from '../configuration';
6
+ import { assert, isDirectory, writeFileMakeDir } from '../utils/utils';
7
+ import { CliError } from './index';
8
+
9
+ const FASTIFY_DEPS = [
10
+ '@fastify/session',
11
+ 'fastify',
12
+ 'fastify-accepts',
13
+ 'fastify-compress',
14
+ 'fastify-cookie',
15
+ 'fastify-cors',
16
+ 'fastify-csrf',
17
+ 'fastify-flash',
18
+ 'fastify-formbody',
19
+ 'fastify-helmet',
20
+ 'fastify-multipart',
21
+ 'fastify-request-context',
22
+ 'fastify-static',
23
+ 'fastify-websocket',
24
+ ];
25
+
26
+ const COMMON_DEPS = [
27
+ '@marsx-dev/launcher',
28
+ 'aws-sdk',
29
+ 'axios',
30
+ 'azure-storage',
31
+ 'base-x',
32
+ 'bcrypt',
33
+ 'chokidar',
34
+ 'lodash',
35
+ 'mongodb4@npm:mongodb@4',
36
+ 'mongodb3@npm:mongodb@3',
37
+ 'typescript',
38
+ 'uuid',
39
+ 'xxhash',
40
+ ];
41
+
42
+ const V3_DEPS = [
43
+ 'babel-core',
44
+ 'babel-plugin-transform-react-jsx',
45
+ 'config',
46
+ 'crypto-js',
47
+ 'jsonwebtoken',
48
+ 'mobile-detect',
49
+ 'moment-timezone',
50
+ 'route-pattern',
51
+ 'route-sort',
52
+ ];
53
+
54
+ const DEFAULT_DEPS = [...FASTIFY_DEPS, ...COMMON_DEPS, ...V3_DEPS];
55
+
56
+ const DEMO_BOOTER = `<json id='metadata'>
57
+ {"version": 4, "restartOnChange": true}
58
+ </json>
59
+
60
+
61
+ <script id='BlockFunction' lang='tsx'>
62
+ export default async () => {
63
+ console.log('MarsX loaded!')
64
+ };
65
+ </script>`;
66
+
67
+ export async function initProject(projectName: string) {
68
+ if (!projectName.match(/^\w+$/) || projectName !== projectName.toLowerCase()) {
69
+ throw new CliError(
70
+ `Project name "${projectName}" may contain only lower case alphanumeric characters and underscores (eg. my_project_name)`,
71
+ );
72
+ }
73
+
74
+ const projectDir = path.resolve(projectName);
75
+
76
+ if (await isDirectory(projectDir)) {
77
+ throw new CliError(`Project dir "${projectName}" already exist. Delete it or choose a different name.`);
78
+ }
79
+
80
+ console.log(`Creating a new MarsX project in ${chalk.green(projectDir)}`);
81
+
82
+ const packageJson = {
83
+ name: projectName,
84
+ version: '0.0.0',
85
+ private: true,
86
+ scripts: {
87
+ start: 'marsx start',
88
+ },
89
+ dependencies: {},
90
+ };
91
+ const packageJsonStr = JSON.stringify(packageJson, null, 2);
92
+ await writeFileMakeDir(path.join(projectDir, 'package.json'), packageJsonStr);
93
+
94
+ const config: Configuration = {
95
+ production: false,
96
+ port: 3000,
97
+ blocksDir: 'blocks',
98
+ cacheDir: '.cache',
99
+ mongoConn: '<CONN_STR>',
100
+ mongoDbName: projectName,
101
+ azureStorageConnection: '<CONN_STR>',
102
+ azureStorageAccountName: projectName,
103
+ azureStorageUrl: `https://${projectName}.blob.core.windows.net`,
104
+ webFilesTable: 'webFiles',
105
+ webRecentFilesTable: 'webRecentFiles',
106
+ webFilesBlob: 'web-files',
107
+ secret: (await randomBytes(48)).toString('hex'),
108
+ importProjects: [
109
+ {
110
+ name: 'mars-ide',
111
+ url: 'https://ide.marscloud.dev',
112
+ api_key: '<API_KEY>',
113
+ git_commit_ish: 'main',
114
+ },
115
+ ],
116
+ };
117
+
118
+ await writeFileMakeDir(path.join(projectDir, 'config', 'default.json'), JSON.stringify(config, null, 2));
119
+ await writeFileMakeDir(path.join(projectDir, '.gitignore'), 'node_modules\ndist\n.cache\n');
120
+
121
+ await writeFileMakeDir(path.join(projectDir, 'blocks', 'Booter.service.vue'), DEMO_BOOTER);
122
+
123
+ function run(...args: string[]) {
124
+ const cmd = args[0];
125
+ assert(cmd);
126
+ spawnSync(cmd, args.slice(1), { cwd: projectDir, stdio: 'inherit' });
127
+ }
128
+ console.log('Installing dependencies. This might take a couple of minutes.');
129
+ run('npm', 'i', ...DEFAULT_DEPS);
130
+
131
+ run('git', 'init');
132
+ run('git', 'add', '.gitignore');
133
+ run('git', 'add', '-A');
134
+ run('git', 'commit', '-m', 'Initial commit');
135
+ console.log('Initialized git repository and created initial commit.');
136
+
137
+ console.log(chalk.yellow(`\nMake sure to update parameters in config/default.json`));
138
+
139
+ console.log(`${chalk.green('Success!')} Created ${projectName} at ${projectDir}`);
140
+ console.log(chalk.dim(`\nInside that directory, you can start MarsX with\n\n cd ${projectName}\n npm run start\n`));
141
+ }
@@ -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/utils';
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/utils';
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,53 @@
1
+ process.env['SUPPRESS_NO_CONFIG_WARNING'] = 'true';
2
+ import configModule from 'config';
3
+ import path from 'path';
4
+ import { assert } from './utils/utils';
5
+
6
+ export interface ImportProjectConfig {
7
+ name: string;
8
+ url: string;
9
+ api_key: string;
10
+ git_commit_ish?: string | undefined;
11
+ }
12
+
13
+ const ensureType =
14
+ <T>(typeName: string) =>
15
+ (configName: string): T => {
16
+ const value = configModule.get(configName);
17
+ assert(typeof value === typeName);
18
+ return value as T;
19
+ };
20
+
21
+ const ensureString = ensureType<string>('string');
22
+ const ensureBoolean = ensureType<boolean>('boolean');
23
+ const ensureNumber = ensureType<number>('number');
24
+
25
+ function validatedConfig() {
26
+ if (configModule.util.getConfigSources().length === 0) {
27
+ return new Proxy({} as never, {
28
+ get() {
29
+ throw new Error('Config file not found, ensure you have "config/default.json" file.');
30
+ },
31
+ });
32
+ }
33
+
34
+ return {
35
+ production: ensureBoolean('production'),
36
+ port: ensureNumber('port'),
37
+ blocksDir: path.resolve(ensureString('blocksDir')),
38
+ cacheDir: path.resolve(ensureString('cacheDir')),
39
+ mongoConn: ensureString('mongoConn'),
40
+ mongoDbName: ensureString('mongoDbName'),
41
+ azureStorageConnection: ensureString('azureStorageConnection'),
42
+ azureStorageAccountName: ensureString('azureStorageAccountName'),
43
+ azureStorageUrl: ensureString('azureStorageUrl'),
44
+ secret: ensureString('secret'),
45
+ webFilesTable: ensureString('webFilesTable'),
46
+ webRecentFilesTable: ensureString('webRecentFilesTable'),
47
+ webFilesBlob: ensureString('webFilesBlob'),
48
+ importProjects: configModule.get<ImportProjectConfig[]>('importProjects'),
49
+ };
50
+ }
51
+
52
+ export const config = validatedConfig();
53
+ export type Configuration = typeof config;
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { config, ImportProjectConfig, Configuration } from './configuration';
2
+ export { compileSfcSource, transpileTypescript } from './utils/compile';
3
+ export { parseSFC, serializeSfc, SfcBlock, SfcIdentity, SfcSource, parseSfcIdentity, serializeSfcIdentity } from './utils/sfc';
4
+ export * as utils from './utils/utils';
5
+ export { convertSfcToV3, convertV3ToSfc, V3MongoBlock } from './utils/v3';
@@ -0,0 +1,38 @@
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 { assert, writeFileMakeDir } from './utils/utils';
6
+ import { convertSfcToV3, convertV3ToSfc } from './utils/v3';
7
+
8
+ const LAUNCHER_UTILS = {
9
+ config,
10
+ parseSFC,
11
+ serializeSfc,
12
+ convertV3ToSfc,
13
+ convertSfcToV3,
14
+ transpileTypescript,
15
+ compileSfcSource,
16
+ writeFileMakeDir,
17
+ };
18
+
19
+ export async function launchBooter(booterBlockName = 'Booter') {
20
+ const allBlocks = await loadAllBlocks();
21
+
22
+ const booterBlocks = allBlocks.filter(b => b.identity.name === booterBlockName);
23
+ if (booterBlocks.length === 0)
24
+ throw new Error(`Booter block ${booterBlockName} not found. Ensure you have it locally or it is imported.`);
25
+
26
+ // Take last because order of imports matter and local blocks always override imported.
27
+ const booterBlock = booterBlocks[booterBlocks.length - 1];
28
+ assert(booterBlock);
29
+
30
+ const compiledBooterPath = await compileSfcSource(booterBlock, 'BlockFunction');
31
+ try {
32
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
33
+ await require(compiledBooterPath).default(allBlocks, LAUNCHER_UTILS);
34
+ } catch (e) {
35
+ console.error('Booter failed with:', e);
36
+ process.exit(1);
37
+ }
38
+ }
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/utils';
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,58 @@
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 { assert, writeFileMakeDir } from './utils';
7
+
8
+ function genSourceMapComment(sourceMap: SourceMapPayload): string {
9
+ return `\n//# sourceMappingURL=data:application/json;base64,${Buffer.from(JSON.stringify(sourceMap)).toString('base64')}`;
10
+ }
11
+
12
+ export function transpileTypescript(
13
+ sourceCode: string,
14
+ options?: {
15
+ sourceRoot?: string;
16
+ originalFilePath?: string;
17
+ compiledFilePath?: string;
18
+ lineOffset?: number | undefined;
19
+ },
20
+ ): string {
21
+ const compiledFilePath = options?.compiledFilePath || '<UNKNOWN>.js';
22
+ const originalFilePath = options?.originalFilePath || '<UNKNOWN>.ts';
23
+
24
+ if (options?.lineOffset) {
25
+ sourceCode = '\n'.repeat(options.lineOffset) + sourceCode;
26
+ }
27
+
28
+ const transpiled = ts.transpileModule(sourceCode, {
29
+ compilerOptions: {
30
+ module: ts.ModuleKind.CommonJS,
31
+ target: ts.ScriptTarget.Latest,
32
+ esModuleInterop: true,
33
+ sourceMap: true,
34
+ // inlineSources: true,
35
+ // inlineSourceMap: true,
36
+ },
37
+ });
38
+ assert(transpiled.sourceMapText);
39
+
40
+ const sourceMap: SourceMapPayload = JSON.parse(transpiled.sourceMapText);
41
+ sourceMap.sourceRoot = options?.sourceRoot || '';
42
+ sourceMap.file = compiledFilePath;
43
+ sourceMap.sources = [originalFilePath];
44
+ const sourceMapComment = genSourceMapComment(sourceMap);
45
+ return transpiled.outputText.replace(/\n\/\/# sourceMappingURL=.+/, '') + sourceMapComment;
46
+ }
47
+
48
+ export async function compileSfcSource(sfcBlock: SfcBlock, sourceId: string): Promise<string> {
49
+ const sourceCode = sfcBlock.sources[sourceId];
50
+ if (!sourceCode) throw new Error(`Source code block ${sourceId} not found in ${sfcBlock.identity.filePath}`);
51
+
52
+ const compiledFilePath = path.join(config.cacheDir, 'compiled', `${sfcBlock.identity.filePath}.${sourceId}.js`);
53
+ const originalFilePath = path.join(config.blocksDir, sfcBlock.identity.filePath);
54
+ const code = transpileTypescript(sourceCode.source, { compiledFilePath, originalFilePath, lineOffset: sourceCode.lineOffset });
55
+
56
+ await writeFileMakeDir(compiledFilePath, code, 'utf-8');
57
+ return compiledFilePath;
58
+ }
@@ -0,0 +1,178 @@
1
+ import { CompilerError, TextModes } from '@vue/compiler-core';
2
+ import * as CompilerDOM from '@vue/compiler-dom';
3
+ import { NodeTypes } from '@vue/compiler-dom';
4
+ import stringify from 'json-stable-stringify';
5
+ import JSON5 from 'json5';
6
+ import _ from 'lodash';
7
+ import path from 'path';
8
+ import prettier, { Options } from 'prettier';
9
+ import { escapeCloseTag, escapeHtmlAttr, unescapeCloseTag } from './utils';
10
+
11
+ export const METADATA = 'metadata';
12
+ export const SFC_EXT = 'vue';
13
+ const SAVE_EMPTY_SOURCES = true;
14
+
15
+ export type SfcIdentity = {
16
+ folder: string;
17
+ name: string;
18
+ blockTypeName: string;
19
+ ext: string;
20
+ // Derived
21
+ fullName: string;
22
+ filePath: string;
23
+ };
24
+
25
+ export function parseSfcIdentity(filePath: string): SfcIdentity {
26
+ // Blog/BlogPost.page.vue => {id: "BlogPost.page", folder:"Blog", name: "BlogPost", blockTypeName: "page", ext: "vue", fullName: "Blog.BlogPost"}
27
+ const originalParsed = path.parse(filePath);
28
+ const withoutExt = originalParsed.name;
29
+ const ext = originalParsed.ext.slice(1);
30
+
31
+ const parsed = path.parse(withoutExt);
32
+ if (!parsed.name || !parsed.ext) throw new Error(`Invalid block file path: ${filePath}`);
33
+ const blockTypeName = parsed.ext.slice(1);
34
+
35
+ const namespace = originalParsed.dir.replace(/\//g, '.');
36
+ const fullName = namespace.length > 0 ? `${namespace}.${parsed.name}` : parsed.name;
37
+ return {
38
+ folder: originalParsed.dir,
39
+ name: parsed.name,
40
+ blockTypeName,
41
+ ext,
42
+ fullName,
43
+ filePath,
44
+ };
45
+ }
46
+
47
+ export function serializeSfcIdentity(identity: SfcIdentity): string {
48
+ return path.join(identity.folder, `${identity.name}.${identity.blockTypeName}.${identity.ext}`);
49
+ }
50
+
51
+ type EmptyObject = {
52
+ [K in string]: never;
53
+ };
54
+
55
+ const LANG_TAG_MAP = {
56
+ html: 'html',
57
+ pug: 'template',
58
+ js: 'script',
59
+ jsx: 'script',
60
+ ts: 'script',
61
+ tsx: 'script',
62
+ css: 'style',
63
+ scss: 'style',
64
+ less: 'style',
65
+ sass: 'style',
66
+ stylus: 'style',
67
+ text: 'text',
68
+ };
69
+
70
+ export type SfcSource = { source: string; lang: keyof typeof LANG_TAG_MAP | string | undefined; lineOffset?: number };
71
+
72
+ export type SfcBlock = { identity: SfcIdentity } & (
73
+ | { metadata: Record<string, unknown>; jsons: Record<string, unknown>; sources: Record<string, SfcSource>; rawContent: null }
74
+ | { metadata: EmptyObject; jsons: EmptyObject; sources: EmptyObject; rawContent: Buffer }
75
+ );
76
+
77
+ function getNodeAttr(node: CompilerDOM.ElementNode, attr: string) {
78
+ return node.props.map(p => (p.name == attr && p.type === NodeTypes.ATTRIBUTE ? p.value?.content : undefined)).find(p => !!p);
79
+ }
80
+
81
+ export function parseSFC(filePath: string, content: Buffer): SfcBlock {
82
+ const identity = parseSfcIdentity(filePath);
83
+ if (identity.ext !== SFC_EXT) {
84
+ return { identity, metadata: {}, jsons: {}, sources: {}, rawContent: content };
85
+ }
86
+
87
+ const errors: (CompilerError | SyntaxError)[] = [];
88
+
89
+ const ast = CompilerDOM.parse(content.toString('utf-8'), {
90
+ // there are no components at SFC parsing level
91
+ isNativeTag: () => true,
92
+ // preserve all whitespaces
93
+ isPreTag: () => true,
94
+ getTextMode: () => TextModes.RAWTEXT,
95
+ onError: e => errors.push(e),
96
+ });
97
+
98
+ const block: SfcBlock = {
99
+ identity: parseSfcIdentity(filePath),
100
+ metadata: {},
101
+ jsons: {},
102
+ sources: {},
103
+ rawContent: null,
104
+ };
105
+ const seenIds = new Set<string>();
106
+
107
+ ast.children.forEach(node => {
108
+ if (node.type === NodeTypes.ELEMENT) {
109
+ const child = node.children.length === 1 ? node.children[0] : null;
110
+ const content = child && child.type === NodeTypes.TEXT ? child.content : null;
111
+
112
+ if (content) {
113
+ const sectionId = getNodeAttr(node, 'id');
114
+
115
+ if (!sectionId) {
116
+ errors.push({ name: `missing-id`, message: `Id property is missing`, code: 0, loc: node.loc });
117
+ return;
118
+ }
119
+ if (seenIds.has(sectionId)) {
120
+ errors.push({ name: `duplicate-id`, message: `Duplicate section id: ${sectionId}`, code: 0, loc: node.loc });
121
+ return;
122
+ }
123
+ seenIds.add(sectionId);
124
+
125
+ if (node.tag === 'json') {
126
+ try {
127
+ const jsonData = JSON5.parse(content);
128
+ if (sectionId === METADATA) {
129
+ block.metadata = jsonData;
130
+ } else {
131
+ block.jsons[sectionId] = jsonData;
132
+ }
133
+ } catch (e) {
134
+ errors.push({ name: 'invalid-json', message: `${e}`, code: 0, loc: node.loc });
135
+ }
136
+ } else {
137
+ const source = unescapeCloseTag(content.slice(1, -1), node.tag);
138
+ const lang = getNodeAttr(node, 'lang');
139
+ const lineOffset = node.loc.start.line;
140
+ block.sources[sectionId] = { source, lang, lineOffset };
141
+ }
142
+ }
143
+ }
144
+ });
145
+
146
+ if (errors.length) {
147
+ throw new Error(`Parsing SFC ${filePath} failed with ${JSON.stringify(errors)}`);
148
+ }
149
+
150
+ return block;
151
+ }
152
+
153
+ const PRETTIER_JSON_CONFIG: Options = { parser: 'json5', printWidth: 120, trailingComma: 'all' };
154
+
155
+ export function serializeSfc(block: SfcBlock): { filePath: string; content: Buffer } {
156
+ const filePath = serializeSfcIdentity(block.identity);
157
+ if (block.identity.ext !== SFC_EXT) {
158
+ if (block.rawContent === null) throw new Error(`SfcBlock must have rawContent if ext!="${SFC_EXT}"`);
159
+ return { filePath, content: block.rawContent };
160
+ }
161
+
162
+ if (block.rawContent !== null) throw new Error(`SfcBlock cannot have rawContent if ext=="${SFC_EXT}"`);
163
+
164
+ let content = '';
165
+
166
+ for (const [name, source] of [[METADATA, block.metadata] as const, ..._.sortBy(Object.entries(block.jsons), e => e[0])]) {
167
+ const jsonStr = prettier.format('(' + stringify(source) + ')', PRETTIER_JSON_CONFIG);
168
+ content += `<json id="${escapeHtmlAttr(name)}">\n${jsonStr}</json>\n\n`;
169
+ }
170
+
171
+ for (const [name, source] of _.sortBy(Object.entries(block.sources), e => e[0])) {
172
+ if (!SAVE_EMPTY_SOURCES && !source.source.trim()) continue;
173
+ const tag = (LANG_TAG_MAP as Record<string, string>)[source.lang || ''] || 'script';
174
+ content += `<${tag} id="${escapeHtmlAttr(name)}" lang="${source.lang}">\n${escapeCloseTag(source.source, tag)}\n</${tag}>\n\n`;
175
+ }
176
+
177
+ return { filePath, content: Buffer.from(content, 'utf-8') };
178
+ }