diff --git a/browser/.eslintrc.cjs b/browser/.eslintrc.cjs index aa270894a..e9382c47f 100644 --- a/browser/.eslintrc.cjs +++ b/browser/.eslintrc.cjs @@ -31,8 +31,9 @@ module.exports = { tsconfigRootDir: __dirname, project: [ 'lib/tsconfig.json', + 'cli/tsconfig.json', 'react/tsconfig.json', - 'data-browser/tsconfig.json', + 'data-browser/tsconfig.json' ], }, plugins: ['react', '@typescript-eslint', 'prettier', 'react-hooks', 'jsx-a11y'], diff --git a/browser/bun.lockb b/browser/bun.lockb deleted file mode 100755 index a2e44c79c..000000000 Binary files a/browser/bun.lockb and /dev/null differ diff --git a/browser/cli/.gitignore b/browser/cli/.gitignore new file mode 100644 index 000000000..5e56e040e --- /dev/null +++ b/browser/cli/.gitignore @@ -0,0 +1 @@ +/bin diff --git a/browser/cli/package.json b/browser/cli/package.json new file mode 100644 index 000000000..5ec18dfb2 --- /dev/null +++ b/browser/cli/package.json @@ -0,0 +1,32 @@ +{ + "version": "0.35.1", + "author": "Polle Pas", + "dependencies": { + "@tomic/lib": "^0.35.1", + "chalk": "^5.3.0", + "typescript": "^4.8" + }, + "description": "", + "license": "MIT", + "name": "@tomic/cli", + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc", + "lint": "eslint ./src --ext .js,.ts", + "lint-fix": "eslint ./src --ext .js,.ts --fix", + "prepublishOnly": "pnpm run build && pnpm run lint-fix", + "watch": "tsc --build --watch", + "start": "pnpm watch", + "tsc": "tsc --build", + "typecheck": "tsc --noEmit" + }, + "bin": { + "ad-generate": "./bin/src/index.js" + }, + "type": "module", + "peerDependencies": { + "@tomic/lib": "^0.35.1" + } +} diff --git a/browser/cli/readme.md b/browser/cli/readme.md new file mode 100644 index 000000000..5540532ec --- /dev/null +++ b/browser/cli/readme.md @@ -0,0 +1,161 @@ +# @tomic/cli + +@tomic/cli is a cli tool that helps the developer with creating a front-end for their atomic data project by providing typesafety on resources. + +In atomic data you can create [ontologies](https://atomicdata.dev/class/ontology) that describe your business model. Then you use this tool to generate Typscript types for these ontologies in your front-end. + +```typescript +import { Post } from './ontolgies/blog'; // <--- generated + +const myBlogpost = await store.getResourceAsync( + 'https://myblog.com/atomic-is-awesome', +); + +const comments = myBlogpost.props.comments; // string[] automatically infered! +``` + +## Getting started + +### Installation + +You can install the package globally or as a dev dependancy of your project. + +**Globally**: + +``` +npm install -g @tomic/cli +``` + +**Dev Dependancy:** + +``` +npm install -D @tomic/cli +``` + +If you've installed it globally you can now run the `ad-generate` command in your command line. +When installing as a dependancy your PATH won't know about the command and so you will have to make a script in your `package.json` and run it via `npm ` instead. + +```json +"scripts": { + "generate": "ad-generate" +} +``` + +### Generating the files + +To start generating your ontologies you first need to configure the cli. Start by creating the config file by running: + +``` +ad-generate init +``` + +There should now be a file called `atomic.config.json` in the folder where you ran this command. The contents will look like this: + +```json +{ + "outputFolder": "./src/ontologies", + "moduleAlias": "@tomic/lib", + "ontologies": [] +} +``` + +> If you want to change the location where the files are generated you can change the `outputFolder` field. + +Next add the subjects of your atomic ontologies to the `ontologies` array in the config. + +Now we will generate the ontology files. We do this by running the `ad-generate ontologies` command. If your ontologies don't have public read rights you will have to add an agent secret to the command that has access to these resources. + +``` +ad-generate ontologies --agent +``` + +> Agent secret can also be preconfigured in the config **but be careful** when using version control as you can easily leak your secret this way. + +After running the command the files will have been generated in the specified output folder along with an `index.ts` file. The only thing left to do is to register our ontologies with @tomic/lib. This should be done as soon in your apps runtime lifecycle as possible, for example in your App.tsx when using React or root index.ts in most cases. + +```typescript +import { initOntologies } from './ontologies'; + +initOntologies(); +``` + +### Using the types + +If everything went well the generated files should now be in the output folder. +In order to gain the benefit of the typings we will need to annotate our resource with its respective class like follows: + +```typescript +import { Book, creativeWorks } from './ontologies/creativeWorks.js'; + +const book = await store.getResourceAsync( + 'https://mybookstore.com/books/1', +); +``` + +Now we know what properties are required and recommend on this resource so we can safely infer the types + +Because we know `written-by` is a required property on book we can safely infer type string; + +```typescript +const authorSubject = book.get(creativeWorks.properties.writtenBy); // string +``` + +`description` has datatype Markdown and is inferred as string but it is a recommended property and might therefore be undefined + +```typescript +const description = book.get(core.properties.description); // string | undefined +``` + +If the property is not in any ontology we can not infer the type so it will be of type `JSONValue` +(this type includes `undefined`) + +```typescript +const unknownProp = book.get('https://unknownprop.site/prop/42'); // JSONValue +``` + +### Props shorthand + +Because you have initialised your ontologies before lib is aware of what properties exist and what their name and type is. Because of this it is possible to use the props field on a resource and get full intellisense and typing on it. + +```typescript +const book = await store.getResourceAsync( + 'https://mybookstore.com/books/1', +); + +const name = book.props.name; // string +const description = book.props.description; // string | undefined +``` + +> The props field is a computed property and is readonly. +> +> If you have to read very large number of properties at a time it is more efficient to use the `resource.get()` method instead of the props field because the props field iterates over the resources propval map. + +## Configuration + +@tomic/cli loads the config file from the root of your project. This file should be called `atomic.config.json` and needs to conform to the following interface. + +```typescript +interface AtomicConfig { + /** + * Path relative to this file where the generated files should be written to. + */ + outputFolder: string; + + /** + * [OPTIONAL] The @tomic/lib module identifier. + * The default should be sufficient in most but if you have given the module an alias you should change this value + */ + moduleAlias?: string; + + /** + * [OPTIONAL] The secret of the agent that is used to access your atomic data server. This can also be provided as a command line argument if you don't want to store it in the config file. + * If left empty the public agent is used. + */ + agentSecret?: string; + + /** The list of subjects of your ontologies */ + ontologies: string[]; +} +``` + +Running `ad-generate init` will create this file for you that you can then tweak to your own preferences. diff --git a/browser/cli/src/commands/init.ts b/browser/cli/src/commands/init.ts new file mode 100644 index 000000000..5d85acf71 --- /dev/null +++ b/browser/cli/src/commands/init.ts @@ -0,0 +1,33 @@ +/* eslint-disable no-console */ +import chalk from 'chalk'; +import * as fs from 'fs'; +import * as path from 'path'; + +const TEMPLATE_CONFIG_FILE = { + outputFolder: './src/ontologies', + moduleAlias: '@tomic/lib', + ontologies: [], +}; + +export const initCommand = async (args: string[]) => { + const forced = args.includes('--force') || args.includes('-f'); + const filePath = path.join(process.cwd(), 'atomic.config.json'); + const stat = fs.statSync(filePath); + + if (stat.isFile() && !forced) { + return console.error( + chalk.red( + `ERROR: File already exists. If you meant to override the existing file, use the command with the ${chalk.cyan( + '--force', + )} flag.`, + ), + ); + } + + console.log(chalk.cyan('Creating atomic.config.json')); + + const template = JSON.stringify(TEMPLATE_CONFIG_FILE, null, 2); + fs.writeFileSync(filePath, template); + + console.log(chalk.green('Done!')); +}; diff --git a/browser/cli/src/commands/ontologies.ts b/browser/cli/src/commands/ontologies.ts new file mode 100644 index 000000000..6553b10b7 --- /dev/null +++ b/browser/cli/src/commands/ontologies.ts @@ -0,0 +1,49 @@ +/* eslint-disable no-console */ + +import * as fs from 'fs'; +import chalk from 'chalk'; + +import * as path from 'path'; +import { generateOntology } from '../generateOntology.js'; +import { atomicConfig } from '../config.js'; +import { generateIndex } from '../generateIndex.js'; + +export const ontologiesCommand = async (_args: string[]) => { + console.log( + chalk.blue( + `Found ${chalk.red( + Object.keys(atomicConfig.ontologies).length, + )} ontologies`, + ), + ); + + for (const subject of Object.values(atomicConfig.ontologies)) { + write(await generateOntology(subject)); + } + + console.log(chalk.blue('Generating index...')); + + write(generateIndex(atomicConfig.ontologies)); + + console.log(chalk.green('Done!')); +}; + +const write = ({ + filename, + content, +}: { + filename: string; + content: string; +}) => { + console.log(chalk.blue(`Writing ${chalk.red(filename)}...`)); + + const filePath = path.join( + process.cwd(), + atomicConfig.outputFolder, + filename, + ); + + fs.writeFileSync(filePath, content); + + console.log(chalk.blue('Wrote to'), chalk.cyan(filePath)); +}; diff --git a/browser/cli/src/config.ts b/browser/cli/src/config.ts new file mode 100644 index 000000000..111904059 --- /dev/null +++ b/browser/cli/src/config.ts @@ -0,0 +1,28 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export interface AtomicConfig { + /** + * Path relative to this file where the generated files should be written to. + */ + outputFolder: string; + /** + * [OPTIONAL] The @tomic/lib module identifier. + * The default should be sufficient in most but if you have given the module an alias you should change this value + */ + moduleAlias?: string; + /** + * [OPTIONAL] The secret of the agent that is used to access your atomic data server. This can also be provided as a command line argument if you don't want to store it in the config file. + * If left empty the public agent is used. + */ + agentSecret?: string; + /** The list of subjects of your ontologies */ + + ontologies: string[]; +} + +export const atomicConfig: AtomicConfig = JSON.parse( + fs + .readFileSync(path.resolve(process.cwd(), './atomic.config.json')) + .toString(), +); diff --git a/browser/cli/src/generateBaseObject.ts b/browser/cli/src/generateBaseObject.ts new file mode 100644 index 000000000..f48cef3a6 --- /dev/null +++ b/browser/cli/src/generateBaseObject.ts @@ -0,0 +1,72 @@ +import { Resource, urls } from '@tomic/lib'; +import { store } from './store.js'; +import { camelCaseify } from './utils.js'; + +export type ReverseMapping = Record; + +type BaseObject = { + classes: Record; + properties: Record; +}; + +export const generateBaseObject = async ( + ontology: Resource, +): Promise<[string, ReverseMapping]> => { + if (ontology.error) { + throw ontology.error; + } + + const classes = ontology.get(urls.properties.classes) as string[]; + const properties = ontology.get(urls.properties.properties) as string[]; + const name = camelCaseify(ontology.title); + + const baseObj = { + classes: await listToObj(classes), + properties: await listToObj(properties), + }; + + const objStr = `export const ${name} = { + classes: ${recordToString(baseObj.classes)}, + properties: ${recordToString(baseObj.properties)}, + } as const`; + + return [objStr, createReverseMapping(name, baseObj)]; +}; + +const listToObj = async (list: string[]): Promise> => { + const entries = await Promise.all( + list.map(async subject => { + const resource = await store.getResourceAsync(subject); + + return [camelCaseify(resource.title), subject]; + }), + ); + + return Object.fromEntries(entries); +}; + +const recordToString = (obj: Record): string => { + const innerSting = Object.entries(obj).reduce( + (acc, [key, value]) => `${acc}\n\t${key}: '${value}',`, + '', + ); + + return `{${innerSting}\n }`; +}; + +const createReverseMapping = ( + ontologyTitle: string, + obj: BaseObject, +): ReverseMapping => { + const reverseMapping: ReverseMapping = {}; + + for (const [name, subject] of Object.entries(obj.classes)) { + reverseMapping[subject] = `${ontologyTitle}.classes.${name}`; + } + + for (const [name, subject] of Object.entries(obj.properties)) { + reverseMapping[subject] = `${ontologyTitle}.properties.${name}`; + } + + return reverseMapping; +}; diff --git a/browser/cli/src/generateClassExports.ts b/browser/cli/src/generateClassExports.ts new file mode 100644 index 000000000..604955f95 --- /dev/null +++ b/browser/cli/src/generateClassExports.ts @@ -0,0 +1,29 @@ +import { Resource, urls } from '@tomic/lib'; +import { ReverseMapping } from './generateBaseObject.js'; +import { store } from './store.js'; +import { camelCaseify } from './utils.js'; + +export const generateClassExports = ( + ontology: Resource, + reverseMapping: ReverseMapping, +): string => { + const classes = ontology.getArray(urls.properties.classes) as string[]; + + return classes + .map(subject => { + const res = store.getResourceLoading(subject); + const objectPath = reverseMapping[subject]; + + return createExportLine(res.title, objectPath); + }) + .join('\n'); +}; + +const createExportLine = (title: string, objectPath: string) => + `export type ${capitalize(title)} = typeof ${objectPath};`; + +const capitalize = (str: string): string => { + const camelCased = camelCaseify(str); + + return camelCased.charAt(0).toUpperCase() + camelCased.slice(1); +}; diff --git a/browser/cli/src/generateClasses.ts b/browser/cli/src/generateClasses.ts new file mode 100644 index 000000000..7c2094a01 --- /dev/null +++ b/browser/cli/src/generateClasses.ts @@ -0,0 +1,64 @@ +import { Resource } from '@tomic/lib'; +import { store } from './store.js'; +import { ReverseMapping } from './generateBaseObject.js'; + +export const generateClasses = ( + ontology: Resource, + reverseMapping: ReverseMapping, +): string => { + const classes = ontology.get( + 'https://atomicdata.dev/properties/classes', + ) as string[]; + const classStringList = classes.map(subject => { + return generateClass(subject, reverseMapping); + }); + + const innerStr = classStringList.join('\n'); + + return `interface Classes { + ${innerStr} + }`; +}; + +const generateClass = ( + subject: string, + reverseMapping: ReverseMapping, +): string => { + const resource = store.getResourceLoading(subject); + + const transformSubject = (str: string) => { + const name = reverseMapping[str]; + + if (!name) { + return `'${str}'`; + } + + return `typeof ${name}`; + }; + + const requires = (resource.get( + 'https://atomicdata.dev/properties/requires', + ) ?? []) as string[]; + const recommends = (resource.get( + 'https://atomicdata.dev/properties/recommends', + ) ?? []) as string[]; + + return classString( + reverseMapping[subject], + requires.map(transformSubject), + recommends.map(transformSubject), + ); +}; + +const classString = ( + key: string, + requires: string[], + recommends: string[], +): string => { + return `[${key}]: { + requires: BaseProps${ + requires.length > 0 ? ' | ' + requires.join(' | ') : '' + }; + recommends: ${recommends.length > 0 ? recommends.join(' | ') : 'never'}; + };`; +}; diff --git a/browser/cli/src/generateIndex.ts b/browser/cli/src/generateIndex.ts new file mode 100644 index 000000000..6b01c5307 --- /dev/null +++ b/browser/cli/src/generateIndex.ts @@ -0,0 +1,49 @@ +import { store } from './store.js'; +import { camelCaseify } from './utils.js'; +import { atomicConfig } from './config.js'; + +enum Inserts { + MODULE_ALIAS = '{{1}}', + IMPORTS = '{{2}}', + REGISTER_ARGS = '{{3}}', +} + +const TEMPLATE = ` +/* ----------------------------------- +* GENERATED WITH ATOMIC-GENERATE +* -------------------------------- */ + +import { registerOntologies } from '${Inserts.MODULE_ALIAS}'; + +${Inserts.IMPORTS} + +export function initOntologies(): void { + registerOntologies(${Inserts.REGISTER_ARGS}); +} +`; + +export const generateIndex = (ontologies: string[]) => { + const names = ontologies.map(x => { + const res = store.getResourceLoading(x); + + return camelCaseify(res.title); + }); + + const importLines = names.map(createImportLine).join('\n'); + const registerArgs = names.join(', '); + + const content = TEMPLATE.replaceAll( + Inserts.MODULE_ALIAS, + atomicConfig.moduleAlias ?? '@tomic/lib', + ) + .replace(Inserts.IMPORTS, importLines) + .replace(Inserts.REGISTER_ARGS, registerArgs); + + return { + filename: 'index.ts', + content, + }; +}; + +const createImportLine = (name: string) => + `import { ${name} } from './${name}.js';`; diff --git a/browser/cli/src/generateOntology.ts b/browser/cli/src/generateOntology.ts new file mode 100644 index 000000000..7eb1066ac --- /dev/null +++ b/browser/cli/src/generateOntology.ts @@ -0,0 +1,68 @@ +import { generateBaseObject } from './generateBaseObject.js'; +import { generateClasses } from './generateClasses.js'; +import { store } from './store.js'; +import { camelCaseify } from './utils.js'; +// TODO: Replace with actual project config file. +import { generatePropTypeMapping } from './generatePropTypeMapping.js'; +import { generateSubjectToNameMapping } from './generateSubjectToNameMapping.js'; +import { generateClassExports } from './generateClassExports.js'; + +import { atomicConfig } from './config.js'; + +enum Inserts { + MODULE_ALIAS = '{{1}}', + BASE_OBJECT = '{{2}}', + CLASS_EXPORTS = '{{3}}', + CLASSES = '{{4}}', + PROP_TYPE_MAPPING = '{{7}}', + PROP_SUBJECT_TO_NAME_MAPPING = '{{8}}', +} + +const TEMPLATE = ` +/* ----------------------------------- +* GENERATED WITH ATOMIC-GENERATE +* -------------------------------- */ + +import { BaseProps } from '${Inserts.MODULE_ALIAS}' + +${Inserts.BASE_OBJECT} + +${Inserts.CLASS_EXPORTS} + +declare module '${Inserts.MODULE_ALIAS}' { + ${Inserts.CLASSES} + + ${Inserts.PROP_TYPE_MAPPING} + + ${Inserts.PROP_SUBJECT_TO_NAME_MAPPING} +} +`; + +export const generateOntology = async ( + subject: string, +): Promise<{ + filename: string; + content: string; +}> => { + const ontology = await store.getResourceAsync(subject); + const [baseObjStr, reverseMapping] = await generateBaseObject(ontology); + const classesStr = generateClasses(ontology, reverseMapping); + const propertiesStr = generatePropTypeMapping(ontology, reverseMapping); + const subToNameStr = generateSubjectToNameMapping(ontology, reverseMapping); + const classExportsStr = generateClassExports(ontology, reverseMapping); + + const content = TEMPLATE.replaceAll( + Inserts.MODULE_ALIAS, + atomicConfig.moduleAlias ?? '@tomic/lib', + ) + .replace(Inserts.BASE_OBJECT, baseObjStr) + .replace(Inserts.CLASS_EXPORTS, classExportsStr) + .replace(Inserts.CLASSES, classesStr) + .replace(Inserts.PROP_TYPE_MAPPING, propertiesStr) + .replace(Inserts.PROP_SUBJECT_TO_NAME_MAPPING, subToNameStr); + + return { + filename: `${camelCaseify(ontology.title)}.ts`, + content, + }; +}; diff --git a/browser/cli/src/generatePropTypeMapping.ts b/browser/cli/src/generatePropTypeMapping.ts new file mode 100644 index 000000000..2f72dd467 --- /dev/null +++ b/browser/cli/src/generatePropTypeMapping.ts @@ -0,0 +1,43 @@ +import { Datatype, Resource } from '@tomic/lib'; +import { store } from './store.js'; +import { ReverseMapping } from './generateBaseObject.js'; + +const DatatypeToTSTypeMap = { + [Datatype.ATOMIC_URL]: 'string', + [Datatype.RESOURCEARRAY]: 'string[]', + [Datatype.BOOLEAN]: 'boolean', + [Datatype.DATE]: 'string', + [Datatype.TIMESTAMP]: 'string', + [Datatype.INTEGER]: 'number', + [Datatype.FLOAT]: 'number', + [Datatype.STRING]: 'string', + [Datatype.SLUG]: 'string', + [Datatype.MARKDOWN]: 'string', + [Datatype.UNKNOWN]: 'JSONValue', +}; + +export const generatePropTypeMapping = ( + ontology: Resource, + reverseMapping: ReverseMapping, +): string => { + const properties = (ontology.get( + 'https://atomicdata.dev/properties/properties', + ) ?? []) as string[]; + + const lines = properties + .map(subject => generateLine(subject, reverseMapping)) + .join('\n'); + + return `interface PropTypeMapping { + ${lines} + }`; +}; + +const generateLine = (subject: string, reverseMapping: ReverseMapping) => { + const resource = store.getResourceLoading(subject); + const datatype = resource.get( + 'https://atomicdata.dev/properties/datatype', + ) as Datatype; + + return `[${reverseMapping[subject]}]: ${DatatypeToTSTypeMap[datatype]}`; +}; diff --git a/browser/cli/src/generateSubjectToNameMapping.ts b/browser/cli/src/generateSubjectToNameMapping.ts new file mode 100644 index 000000000..1b596d83b --- /dev/null +++ b/browser/cli/src/generateSubjectToNameMapping.ts @@ -0,0 +1,23 @@ +import { Resource } from '@tomic/lib'; +import { ReverseMapping } from './generateBaseObject.js'; + +export function generateSubjectToNameMapping( + ontology: Resource, + reverseMapping: ReverseMapping, +) { + const properties = ontology.getArray( + 'https://atomicdata.dev/properties/properties', + ) as string[]; + + const lines = properties.map(prop => propLine(prop, reverseMapping)); + + return `interface PropSubjectToNameMapping { + ${lines.join('\n')} + }`; +} + +const propLine = (subject: string, reverseMapping: ReverseMapping) => { + const name = reverseMapping[subject].split('.')[2]; + + return `[${reverseMapping[subject]}]: '${name}',`; +}; diff --git a/browser/cli/src/index.ts b/browser/cli/src/index.ts new file mode 100644 index 000000000..2ca1e9547 --- /dev/null +++ b/browser/cli/src/index.ts @@ -0,0 +1,26 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ + +import chalk from 'chalk'; +import { usage } from './usage.js'; + +const command = process.argv[2]; + +const commands = new Map Promise>(); + +commands.set('ontologies', () => + import('./commands/ontologies.js').then(m => + m.ontologiesCommand(process.argv.slice(3)), + ), +); + +commands.set('init', () => + import('./commands/init.js').then(m => m.initCommand(process.argv.slice(3))), +); + +if (commands.has(command)) { + commands.get(command)?.(); +} else { + console.error(chalk.red('Unknown command'), chalk.cyan(command ?? '')); + console.log(usage); +} diff --git a/browser/cli/src/store.ts b/browser/cli/src/store.ts new file mode 100644 index 000000000..a0a88f98c --- /dev/null +++ b/browser/cli/src/store.ts @@ -0,0 +1,35 @@ +import { Agent, Store } from '@tomic/lib'; +import { atomicConfig } from './config.js'; + +const getCommandIndex = (): number | undefined => { + const agentIndex = process.argv.indexOf('--agent'); + if (agentIndex !== -1) return agentIndex; + + const shortAgentIndex = process.argv.indexOf('-a'); + if (shortAgentIndex !== -1) return shortAgentIndex; + + return undefined; +}; + +const getAgent = (): Agent | undefined => { + let secret; + const agentCommandIndex = getCommandIndex(); + + if (agentCommandIndex) { + secret = process.argv[agentCommandIndex + 1]; + } else { + secret = atomicConfig.agentSecret; + } + + if (!secret) return undefined; + + return Agent.fromSecret(secret); +}; + +export const store = new Store(); + +const agent = getAgent(); + +if (agent) { + store.setAgent(agent); +} diff --git a/browser/cli/src/usage.ts b/browser/cli/src/usage.ts new file mode 100644 index 000000000..0169269ee --- /dev/null +++ b/browser/cli/src/usage.ts @@ -0,0 +1,7 @@ +export const usage = ` +ad-generate + +Commands: + ontologies Generates typescript files for ontologies specified in the config file. + init Creates a template config file. +`; diff --git a/browser/cli/src/utils.ts b/browser/cli/src/utils.ts new file mode 100644 index 000000000..fe6e14062 --- /dev/null +++ b/browser/cli/src/utils.ts @@ -0,0 +1,4 @@ +export const camelCaseify = (str: string) => + str.replace(/-([a-z])/g, g => { + return g[1].toUpperCase(); + }); diff --git a/browser/cli/tsconfig.json b/browser/cli/tsconfig.json new file mode 100644 index 000000000..8a3f78764 --- /dev/null +++ b/browser/cli/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "outDir": "./bin", + "rootDir": ".", + "target": "ESNext", + "moduleResolution": "nodeNext", + "module": "nodeNext", + "noImplicitAny": true, + "strictNullChecks": true, + // We don't need type declarations for a cli app. + "declaration": false + }, + "include": [ + "./src", + ], + "references": [], +} diff --git a/browser/lib/package.json b/browser/lib/package.json index 447d281e6..2be95f095 100644 --- a/browser/lib/package.json +++ b/browser/lib/package.json @@ -1,5 +1,5 @@ { - "version": "0.35.1", + "version": "0.35.2", "author": "Joep Meindertsma", "dependencies": { "@noble/ed25519": "1.6.0", diff --git a/browser/lib/src/index.ts b/browser/lib/src/index.ts index 49cf13edd..f0071a603 100644 --- a/browser/lib/src/index.ts +++ b/browser/lib/src/index.ts @@ -46,3 +46,4 @@ export * from './urls.js'; export * from './truncate.js'; export * from './collection.js'; export * from './collectionBuilder.js'; +export * from './ontology.js'; diff --git a/browser/lib/src/ontology.ts b/browser/lib/src/ontology.ts new file mode 100644 index 000000000..ae6fc3f9d --- /dev/null +++ b/browser/lib/src/ontology.ts @@ -0,0 +1,77 @@ +import { JSONValue } from './value.js'; + +export type BaseObject = { + classes: Record; + properties: Record; +}; + +// Extended via module augmentation +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface Classes {} + +export type BaseProps = + | 'https://atomicdata.dev/properties/isA' + | 'https://atomicdata.dev/properties/parent'; + +// Extended via module augmentation +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PropTypeMapping {} + +// Extended via module augmentation +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PropSubjectToNameMapping {} + +export type Requires = Classes[C]['requires']; +export type Recommends = Classes[C]['recommends']; + +type PropsOfClass = { + [P in Requires]: P; +} & { + [P in Recommends]?: P; +}; + +/** + * Infers the js type a value can have on a resource for the given property. + * If the property is not known in any ontology, it will return JSONValue. + */ +export type InferTypeOfValueInTriple< + Class extends keyof Classes | never = never, + Prop extends string = string, + Returns = Prop extends keyof PropTypeMapping + ? Prop extends Requires + ? PropTypeMapping[Prop] + : Prop extends Recommends + ? PropTypeMapping[Prop] | undefined + : PropTypeMapping[Prop] | undefined + : JSONValue, +> = Returns; + +/** Type of the dynamically created resource.props field */ +export type QuickAccesPropType = { + readonly [Prop in keyof PropsOfClass as PropSubjectToNameMapping[Prop]]: InferTypeOfValueInTriple< + Class, + Prop + >; +}; + +export type OptionalClass = keyof Classes | never; + +// A map of all known classes and properties to their camelcased shortname. +const globalReverseNameMapping = new Map(); + +/** Let atomic lib know your custom ontologies exist */ +export function registerOntologies(...ontologies: BaseObject[]): void { + for (const ontology of ontologies) { + for (const [key, value] of Object.entries(ontology.classes)) { + globalReverseNameMapping.set(value, key); + } + + for (const [key, value] of Object.entries(ontology.properties)) { + globalReverseNameMapping.set(value, key); + } + } +} + +export function getKnownNameBySubject(subject: string): string | undefined { + return globalReverseNameMapping.get(subject); +} diff --git a/browser/lib/src/resource.ts b/browser/lib/src/resource.ts index c98175864..faf8b3fc8 100644 --- a/browser/lib/src/resource.ts +++ b/browser/lib/src/resource.ts @@ -14,6 +14,10 @@ import { applyCommitToResource, Commit, parseCommitResource, + InferTypeOfValueInTriple, + QuickAccesPropType, + getKnownNameBySubject, + OptionalClass, } from './index.js'; /** Contains the PropertyURL / Value combinations */ @@ -29,7 +33,7 @@ export const unknownSubject = 'unknown-subject'; * Describes an Atomic Resource, which has a Subject URL and a bunch of Property * / Value combinations. */ -export class Resource { +export class Resource { /** If the resource could not be fetched, we put that info here. */ public error?: Error; /** If the commit could not be saved, we put that info here. */ @@ -75,6 +79,20 @@ export class Resource { this.subject) as string; } + public get props(): QuickAccesPropType { + const props: QuickAccesPropType = {}; + + for (const prop of this.propvals.keys()) { + const name = getKnownNameBySubject(prop); + + if (name) { + props[name] = this.get(prop); + } + } + + return props; + } + /** Checks if the content of two Resource instances is equal * Warning: does not check CommitBuilder, loading state */ @@ -138,7 +156,7 @@ export class Resource { * Creates a clone of the Resource, which makes sure the reference is * different from the previous one. This can be useful when doing reference compares. */ - public clone(): Resource { + public clone(): Resource { const res = new Resource(this.subject); res.propvals = structuredClone(this.propvals); res.loading = this.loading; @@ -159,8 +177,10 @@ export class Resource { } /** Get a Value by its property */ - public get(propUrl: string): T { - return this.propvals.get(propUrl) as T; + public get>( + propUrl: Prop, + ): Returns { + return this.propvals.get(propUrl) as Returns; } /** @@ -185,7 +205,7 @@ export class Resource { return valToArray(result); } - /** Get a Value by its property */ + /** Returns a list of classes of this resource */ public getClasses(): string[] { return this.getSubjects(properties.isA); } @@ -518,9 +538,12 @@ export class Resource { * * When undefined is passed as value, the property is removed from the resource. */ - public async set( - prop: string, - value: JSONValue, + public async set< + Prop extends string, + Value extends InferTypeOfValueInTriple, + >( + prop: Prop, + value: Value, store: Store, /** * Disable validation if you don't need it. It might cause a fetch if the diff --git a/browser/lib/src/store.ts b/browser/lib/src/store.ts index b652b8449..e3efe2670 100644 --- a/browser/lib/src/store.ts +++ b/browser/lib/src/store.ts @@ -15,11 +15,14 @@ import { Commit, JSONADParser, FileOrFileLike, + OptionalClass, } from './index.js'; import { authenticate, fetchWebSocket, startWebsocket } from './websockets.js'; /** Function called when a resource is updated or removed */ -type ResourceCallback = (resource: Resource) => void; +type ResourceCallback = ( + resource: Resource, +) => void; /** Callback called when the stores agent changes */ type AgentCallback = (agent: Agent | undefined) => void; type ErrorCallback = (e: Error) => void; @@ -202,7 +205,7 @@ export class Store { /** * Always fetches resource from the server then adds it to the store. */ - public async fetchResourceFromServer( + public async fetchResourceFromServer( /** The resource URL to be fetched */ subject: string, opts: { @@ -220,9 +223,9 @@ export class Store { /** HTTP Body for POSTing */ body?: ArrayBuffer | string; } = {}, - ): Promise { + ): Promise> { if (opts.setLoading) { - const newR = new Resource(subject); + const newR = new Resource(subject); newR.loading = true; this.addResources(newR); } @@ -304,13 +307,13 @@ export class Store { * done in the background . If the subject is undefined, an empty non-saved * resource will be returned. */ - public getResourceLoading( + public getResourceLoading( subject: string = unknownSubject, opts: FetchOpts = {}, - ): Resource { + ): Resource { // This is needed because it can happen that the useResource react hook is called while there is no subject passed. if (subject === unknownSubject || subject === null) { - const newR = new Resource(unknownSubject, opts.newResource); + const newR = new Resource(unknownSubject, opts.newResource); return newR; } @@ -318,7 +321,7 @@ export class Store { const found = this.resources.get(subject); if (!found) { - const newR = new Resource(subject, opts.newResource); + const newR = new Resource(subject, opts.newResource); newR.loading = true; this.addResources(newR); @@ -345,7 +348,9 @@ export class Store { * store. Not recommended to use this for rendering, because it might cause * resources to be fetched multiple times. */ - public async getResourceAsync(subject: string): Promise { + public async getResourceAsync( + subject: string, + ): Promise> { const found = this.resources.get(subject); if (found && found.isReady()) { @@ -357,7 +362,7 @@ export class Store { return new Promise((resolve, reject) => { const defaultTimeout = 5000; - const cb = res => { + const cb: ResourceCallback = res => { this.unsubscribe(subject, cb); resolve(res); }; diff --git a/browser/package.json b/browser/package.json index 48f6e3359..e98b483ac 100644 --- a/browser/package.json +++ b/browser/package.json @@ -54,7 +54,8 @@ "packages": [ "lib", "react", - "data-browser" + "data-browser", + "cli" ] }, "packageManager": "pnpm@8.6.12", diff --git a/browser/pnpm-lock.yaml b/browser/pnpm-lock.yaml index 6cabf0cce..006441aab 100644 --- a/browser/pnpm-lock.yaml +++ b/browser/pnpm-lock.yaml @@ -97,6 +97,18 @@ importers: specifier: ^3.0.5 version: 3.2.7(@types/node@16.18.39) + cli: + dependencies: + '@tomic/lib': + specifier: ^0.35.1 + version: link:../lib + chalk: + specifier: ^5.3.0 + version: 5.3.0 + typescript: + specifier: ^4.8 + version: 4.9.5 + data-browser: dependencies: '@bugsnag/core': @@ -240,7 +252,7 @@ importers: version: 1.1.0 vite: specifier: ^4.0.4 - version: 4.4.8 + version: 4.4.8(@types/node@16.18.39) vite-plugin-pwa: specifier: ^0.14.1 version: 0.14.7(vite@4.4.8)(workbox-build@6.6.0)(workbox-window@6.6.0) @@ -269,6 +281,9 @@ importers: '@types/fast-json-stable-stringify': specifier: ^2.1.0 version: 2.1.0 + '@types/yargs': + specifier: ^17.0.24 + version: 17.0.24 chai: specifier: ^4.3.4 version: 4.3.7 @@ -4125,6 +4140,11 @@ packages: ansi-styles: 4.3.0 supports-color: 7.2.0 + /chalk@5.3.0: + resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + dev: false + /char-regex@1.0.2: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} @@ -9660,7 +9680,7 @@ packages: fast-glob: 3.3.1 pretty-bytes: 6.1.1 rollup: 3.27.2 - vite: 4.4.8 + vite: 4.4.8(@types/node@16.18.39) workbox-build: 6.6.0 workbox-window: 6.6.0 transitivePeerDependencies: @@ -9701,7 +9721,7 @@ packages: fsevents: 2.3.2 dev: true - /vite@4.4.8: + /vite@4.4.8(@types/node@16.18.39): resolution: {integrity: sha512-LONawOUUjxQridNWGQlNizfKH89qPigK36XhMI7COMGztz8KNY0JHim7/xDd71CZwGT4HtSRgI7Hy+RlhG0Gvg==} engines: {node: ^14.18.0 || >=16.0.0} hasBin: true @@ -9729,6 +9749,7 @@ packages: terser: optional: true dependencies: + '@types/node': 16.18.39 esbuild: 0.18.17 postcss: 8.4.27 rollup: 3.27.2 diff --git a/browser/react/package.json b/browser/react/package.json index cfd525f99..21303c125 100644 --- a/browser/react/package.json +++ b/browser/react/package.json @@ -1,5 +1,5 @@ { - "version": "0.35.0", + "version": "0.35.2", "author": "Joep Meindertsma", "description": "Atomic Data React library", "dependencies": { diff --git a/browser/react/src/hooks.ts b/browser/react/src/hooks.ts index b6d8152f0..4ae645abf 100644 --- a/browser/react/src/hooks.ts +++ b/browser/react/src/hooks.ts @@ -22,6 +22,7 @@ import { FetchOpts, unknownSubject, JSONArray, + OptionalClass, } from '@tomic/lib'; import { useDebouncedCallback } from './index.js'; @@ -29,12 +30,12 @@ import { useDebouncedCallback } from './index.js'; * Hook for getting a Resource in a React component. Will try to fetch the * subject and add its parsed values to the store. */ -export function useResource( +export function useResource( subject: string = unknownSubject, opts?: FetchOpts, -): Resource { +): Resource { const store = useStore(); - const [resource, setResource] = useState( + const [resource, setResource] = useState>( store.getResourceLoading(subject, opts), ); @@ -45,7 +46,7 @@ export function useResource( // When a component mounts, it needs to let the store know that it will subscribe to changes to that resource. useEffect(() => { - function handleNotify(updated: Resource) { + function handleNotify(updated: Resource) { // When a change happens, set the new Resource. setResource(updated); } diff --git a/browser/react/src/useMemberFromCollection.ts b/browser/react/src/useMemberFromCollection.ts index 8b42ac2c8..dc59786b4 100644 --- a/browser/react/src/useMemberFromCollection.ts +++ b/browser/react/src/useMemberFromCollection.ts @@ -1,14 +1,19 @@ -import { Collection, Resource, unknownSubject } from '@tomic/lib'; +import { + Collection, + OptionalClass, + Resource, + unknownSubject, +} from '@tomic/lib'; import { useEffect, useState } from 'react'; import { useResource } from './hooks.js'; /** * Gets a member from a collection by index. Handles pagination for you. */ -export function useMemberFromCollection( +export function useMemberFromCollection( collection: Collection, index: number, -): Resource { +): Resource { const [subject, setSubject] = useState(unknownSubject); const resource = useResource(subject);