diff --git a/package-lock.json b/package-lock.json index 68897f44..1ff56a6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12341,6 +12341,10 @@ "resolved": "packages/shared", "link": true }, + "node_modules/@valbuild/tooling": { + "resolved": "packages/tooling", + "link": true + }, "node_modules/@valbuild/ui": { "resolved": "packages/ui", "link": true @@ -29355,6 +29359,14 @@ "zod-validation-error": "^3.3.0" } }, + "packages/tooling": { + "version": "0.63.5", + "dependencies": { + "@valbuild/core": "~0.63.5", + "typescript": "5" + }, + "devDependencies": {} + }, "packages/ui": { "name": "@valbuild/ui", "version": "0.63.6", diff --git a/packages/core/src/schema/file.ts b/packages/core/src/schema/file.ts index 617a1770..dcb1f1a0 100644 --- a/packages/core/src/schema/file.ts +++ b/packages/core/src/schema/file.ts @@ -136,7 +136,7 @@ export class FileSchema< return { [path]: [ { - message: `Found metadata, but it could not be validated. File metadata must be an object with the required props: width (positive number), height (positive number) and sha256 (string of length 64 of the base16 hash).`, // These validation errors will have to be picked up by logic outside of this package and revalidated. Reasons: 1) we have to read files to verify the metadata, which is handled differently in different runtimes (Browser, QuickJS, Node.js); 2) we want to keep this package dependency free. + message: `Found metadata, but it could not be validated. File metadata must be an object with the required props: width (positive number), height (positive number) and sha256 (string of length 64 of the sha256 hash of the base64 encoded data).`, // These validation errors will have to be picked up by logic outside of this package and revalidated. Reasons: 1) we have to read files to verify the metadata, which is handled differently in different runtimes (Browser, QuickJS, Node.js); 2) we want to keep this package dependency free. value: src, fixes: ["file:check-metadata"], }, diff --git a/packages/core/src/schema/image.ts b/packages/core/src/schema/image.ts index bcacf50b..1536e19d 100644 --- a/packages/core/src/schema/image.ts +++ b/packages/core/src/schema/image.ts @@ -147,7 +147,7 @@ export class ImageSchema< return { [path]: [ { - message: `Found metadata, but it could not be validated. Image metadata must be an object with the required props: width (positive number), height (positive number) and sha256 (string of length 64 of the base16 hash).`, // These validation errors will have to be picked up by logic outside of this package and revalidated. Reasons: 1) we have to read files to verify the metadata, which is handled differently in different runtimes (Browser, QuickJS, Node.js); 2) we want to keep this package dependency free. + message: `Found metadata, but it could not be validated. Image metadata must be an object with the required props: width (positive number), height (positive number) and sha256 (string of length 64 of the sha256 hash of the base64 encoded data).`, // These validation errors will have to be picked up by logic outside of this package and revalidated. Reasons: 1) we have to read files to verify the metadata, which is handled differently in different runtimes (Browser, QuickJS, Node.js); 2) we want to keep this package dependency free. value: src, fixes: ["image:replace-metadata"], }, diff --git a/packages/tooling/CHANGELOG.md b/packages/tooling/CHANGELOG.md new file mode 100644 index 00000000..e69de29b diff --git a/packages/tooling/jest.config.js b/packages/tooling/jest.config.js new file mode 100644 index 00000000..6458500a --- /dev/null +++ b/packages/tooling/jest.config.js @@ -0,0 +1,4 @@ +/** @type {import("jest").Config} */ +module.exports = { + preset: "../../jest.preset", +}; diff --git a/packages/tooling/package.json b/packages/tooling/package.json new file mode 100644 index 00000000..743cf58b --- /dev/null +++ b/packages/tooling/package.json @@ -0,0 +1,28 @@ +{ + "name": "@valbuild/tooling", + "version": "0.63.5", + "description": "Utilities to build tooling for Val (VS Code extension, CLI, ...)", + "main": "dist/valbuild-tooling.cjs.js", + "module": "dist/valbuild-tooling.esm.js", + "exports": { + ".": { + "module": "./dist/valbuild-tooling.esm.js", + "default": "./dist/valbuild-tooling.cjs.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "preconstruct": { + "entrypoints": [ + "index.ts" + ] + }, + "dependencies": { + "@valbuild/core": "~0.63.5", + "typescript": "5" + }, + "devDependencies": {} +} diff --git a/packages/tooling/src/index.ts b/packages/tooling/src/index.ts new file mode 100644 index 00000000..a1542689 --- /dev/null +++ b/packages/tooling/src/index.ts @@ -0,0 +1,2 @@ +// TODO: Add tooling code here +export default {}; diff --git a/packages/tooling/src/modulePathMap.test.ts b/packages/tooling/src/modulePathMap.test.ts new file mode 100644 index 00000000..d2ea5482 --- /dev/null +++ b/packages/tooling/src/modulePathMap.test.ts @@ -0,0 +1,211 @@ +import * as ts from "typescript"; +import { createModulePathMap, getModulePathRange } from "./modulePathMap"; + +describe("Should map source path to line / cols", () => { + test("test 1", () => { + const text = `import type { InferSchemaType } from '@valbuild/next'; +import { s, c } from '../val.config'; + +const commons = { + keepAspectRatio: s.boolean().optional(), + size: s.union(s.literal('xs'), s.literal('md'), s.literal('lg')).optional(), +}; + +export const schema = s.object({ + text: s.string({ minLength: 10 }), + nested: s.object({ + text: s.string({ minLength: 10 }), + }), + testText: s + .richtext({ + a: true, + bold: true, + headings: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], + lineThrough: true, + italic: true, + link: true, + img: true, + ul: true, + ol: true, + }) + .optional(), + testUnion: s.union( + 'type', + s.object({ + ...commons, + type: s.literal('singleImage'), + image: s.image().optional(), + }), + s.object({ + ...commons, + type: s.literal('doubleImage'), + image1: s.image().optional(), + image2: s.image().optional(), + }) + ), +}); +export type TestContent = InferSchemaType; + +export default c.define( + '/oj/test', // <- NOTE: this must be the same path as the file + schema, + { + testText: c.richtext\` +Hei dere! +Dette er gøy! +\`, + text: 'hei', + nested: { + text: 'hei', + }, + testUnion: { + type: 'singleImage', + keepAspectRatio: true, + size: 'xs', + image: c.file('/public/Screenshot 2023-11-30 at 20.20.11_dbcdb.png'), + }, + } +); +`; + const sourceFile = ts.createSourceFile( + "./oj/test.val.ts", + text, + ts.ScriptTarget.ES2015, + ); + + const modulePathMap = createModulePathMap(sourceFile); + + if (!modulePathMap) { + return expect(!!modulePathMap).toEqual("modulePathMap is undefined"); + } + + expect(getModulePathRange('"text"', modulePathMap)).toEqual({ + end: { character: 6, line: 51 }, + start: { character: 2, line: 51 }, + }); + expect(getModulePathRange('"nested"."text"', modulePathMap)).toEqual({ + end: { character: 8, line: 53 }, + start: { character: 4, line: 53 }, + }); + }); + + test("test 2", () => { + const text = `import { s, c } from '../val.config'; + +const commons = { + keepAspectRatio: s.boolean().optional(), + size: s.union(s.literal('xs'), s.literal('md'), s.literal('lg')).optional(), +}; + +export const schema = s.object({ + ingress: s.string({ maxLength: 1 }), + theme: s.string().raw(), + header: s.string(), + image: s.image(), +}); + +export default c.define('/content/aboutUs', schema, { + ingress: + 'Vi elsker å bytestgge digitale tjenester som betyr noe for folk, helt fra bunn av, og helt ferdig. Vi tror på iterative utviklingsprosesser, tverrfaglige team, designdrevet produktutvikling og brukersentrerte designmetoder.', + header: 'SPESIALISTER PÅ DIGITAL PRODUKTUTVIKLING', + image: c.file( + '/public/368032148_1348297689148655_444423253678040057_n_64374.png', + { + sha256: + '6437456f9b596355e54df8bbbe9bf32228a7b79ddbdd17cca5679931bd80ea84', + width: 1283, + height: 1121, + } + ), +}); +`; + const sourceFile = ts.createSourceFile( + "./oj/test.val.ts", + text, + ts.ScriptTarget.ES2015, + ); + + const modulePathMap = createModulePathMap(sourceFile); + if (!modulePathMap) { + return expect(!!modulePathMap).toEqual("modulePathMap is undefined"); + } + + // console.log(getModulePathRange('"ingress"', modulePathMap)); + expect(getModulePathRange('"ingress"', modulePathMap)).toEqual({ + start: { line: 15, character: 2 }, + end: { line: 15, character: 9 }, + }); + }); + + test("test 3", () => { + const text = `import { s, c } from '../val.config'; + +export const schema = s.object({ + first: s.array(s.object({ second: s.record(s.array(s.string()))})) +}); + +export default c.define('/content', schema, { + first: [{ second: { a: ['a', 'b'] } }] +}); +`; + const sourceFile = ts.createSourceFile( + "./content.val.ts", + text, + ts.ScriptTarget.ES2015, + ); + + const modulePathMap = createModulePathMap(sourceFile); + if (!modulePathMap) { + return expect(!!modulePathMap).toEqual("modulePathMap is undefined"); + } + expect( + getModulePathRange('"first".0."second"."a".1', modulePathMap), + ).toEqual({ + start: { line: 7, character: 31 }, + end: { line: 7, character: 34 }, + }); + }); + + test("test 4: string literal object properties", () => { + const text = `import { c } from "../../val.config"; +import { docsSchema } from "./docsSchema.val"; + +export default c.define("/app/docs/docs", docsSchema, { + "getting-started": { + title: "Getting started", + content: c.richtext\` +Text +\`, + subPages: { + installation: { + title: "Installation", + subPagesL2: null, + content: null, + }, + }, + }, +}); +`; + + const sourceFile = ts.createSourceFile( + "./content.val.ts", + text, + ts.ScriptTarget.ES2015, + ); + + const modulePathMap = createModulePathMap(sourceFile); + if (!modulePathMap) { + return expect(!!modulePathMap).toEqual("modulePathMap is undefined"); + } + // console.log(JSON.stringify(modulePathMap, null, 2)); + expect( + getModulePathRange( + '"getting-started"."subPages"."installation"', + modulePathMap, + ), + ).toEqual({ + end: { character: 18, line: 10 }, + start: { character: 6, line: 10 }, + }); + }); +}); diff --git a/packages/tooling/src/modulePathMap.ts b/packages/tooling/src/modulePathMap.ts new file mode 100644 index 00000000..3761f1b3 --- /dev/null +++ b/packages/tooling/src/modulePathMap.ts @@ -0,0 +1,227 @@ +import * as ts from "typescript"; + +export type ModulePathMap = { + [modulePath: string]: { + children: ModulePathMap; + start: { + line: number; + character: number; + }; + end: { + line: number; + character: number; + }; + }; +}; + +export function getModulePathRange( + modulePath: string, + modulePathMap: ModulePathMap, +) { + const segments = modulePath.split(".").map((segment) => JSON.parse(segment)); // TODO: this is not entirely correct, but works for now. We have a function I think that does this so replace this with it + let range = modulePathMap[segments[0]]; + for (const pathSegment of segments.slice(1)) { + if (!range) { + break; + } + range = range?.children?.[pathSegment]; + } + return ( + range?.start && + range?.end && { + start: range.start, + end: range.end, + } + ); +} + +export function createModulePathMap( + sourceFile: ts.SourceFile, +): ModulePathMap | undefined { + for (const child of sourceFile + .getChildren() + .flatMap((child) => child.getChildren())) { + if (ts.isExportAssignment(child)) { + const contentNode = + child.expression && + ts.isCallExpression(child.expression) && + child.expression.arguments[2]; + + if (contentNode) { + return traverse(contentNode, sourceFile); + } + } + } +} + +function traverse( + node: ts.Expression, + sourceFile: ts.SourceFile, +): ModulePathMap | undefined { + if (ts.isStringLiteral(node) || ts.isNumericLiteral(node)) { + const tsEnd = sourceFile.getLineAndCharacterOfPosition(node.end); + const start = { + line: tsEnd.line, + character: tsEnd.character - node.getWidth(sourceFile), + }; + const end = { + line: tsEnd.line, + character: tsEnd.character, + }; + return { + "": { + children: {}, + start, + end, + }, + }; + } + if (ts.isObjectLiteralExpression(node)) { + return traverseObjectLiteral(node, sourceFile); + } + if (ts.isArrayLiteralExpression(node)) { + return traverseArrayLiteral(node, sourceFile); + } + if (ts.isCallExpression(node)) { + return traverseCallExpression(node, sourceFile); + } +} + +function traverseCallExpression( + node: ts.CallExpression, + sourceFile: ts.SourceFile, +): ModulePathMap | undefined { + if (ts.isPropertyAccessExpression(node.expression)) { + if ( + node.expression.expression.getText(sourceFile) === "c" && + node.expression.name.getText(sourceFile) === "file" + ) { + const val = { + children: {}, + start: sourceFile.getLineAndCharacterOfPosition( + node.getStart(sourceFile), + ), // TODO: We do + 1 to line up the diagnostics error exactly below a normal + end: sourceFile.getLineAndCharacterOfPosition(node.getEnd()), + }; + if (node.arguments[0]) { + const firstArgEnd = sourceFile.getLineAndCharacterOfPosition( + node.arguments[0].end, + ); + const _ref = { + children: {}, + start: { + line: firstArgEnd.line, + character: + firstArgEnd.character - node.arguments[0].getWidth(sourceFile), + }, + end: { + line: firstArgEnd.line, + character: firstArgEnd.character, + }, + }; + if (!node.arguments[1]) { + return { + val, + _ref, + }; + } + const metadataEnd = sourceFile.getLineAndCharacterOfPosition( + node.arguments[1].end, + ); + return { + val, + _ref, + metadata: { + children: {}, + start: { + line: metadataEnd.line, + character: + metadataEnd.character - node.arguments[1].getWidth(sourceFile), + }, + end: { + line: metadataEnd.line, + character: metadataEnd.character, + }, + }, + }; + } + } + } +} + +function traverseArrayLiteral( + node: ts.ArrayLiteralExpression, + sourceFile: ts.SourceFile, +): ModulePathMap { + return node.elements.reduce((acc, element, index) => { + if (ts.isExpression(element)) { + const tsEnd = sourceFile.getLineAndCharacterOfPosition(element.end); + const start = { + line: tsEnd.line, + character: tsEnd.character - element.getWidth(sourceFile), + }; + const end = { + line: tsEnd.line, + character: tsEnd.character, + }; + return { + ...acc, + [index]: { + children: traverse(element, sourceFile), + start, + end, + }, + }; + } + return acc; + }, {}); +} + +function traverseObjectLiteral( + node: ts.ObjectLiteralExpression, + sourceFile: ts.SourceFile, +): ModulePathMap { + return node.properties.reduce((acc, property) => { + if (ts.isPropertyAssignment(property)) { + const key = + property.name && + (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) && + property.name.text; + const value = property.initializer; + if (key) { + const tsEnd = sourceFile.getLineAndCharacterOfPosition( + property.name.getEnd(), + ); + const start = { + line: tsEnd.line, + character: tsEnd.character - property.name.getWidth(sourceFile), + }; + const end = { + line: tsEnd.line, + character: tsEnd.character, + }; + const val = { + children: {}, + start: sourceFile.getLineAndCharacterOfPosition( + property.initializer.getStart(sourceFile), + ), + end: sourceFile.getLineAndCharacterOfPosition( + property.initializer.getEnd(), + ), + }; + return { + ...acc, + [key]: { + children: { + val, + ...traverse(value, sourceFile), + }, + start, + end, + }, + }; + } + } + return acc; + }, {}); +} diff --git a/packages/tooling/tsconfig.json b/packages/tooling/tsconfig.json new file mode 100644 index 00000000..b8e692ce --- /dev/null +++ b/packages/tooling/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "lib": [ + "es2020" + ], + "strict": true, + "isolatedModules": true, + "noEmit": true, + "skipLibCheck": true + } +} \ No newline at end of file