import { CollectionState, TreePath, createSDTFEngine, GroupState, MergeDedupeFn, QueryResult, SDTFEngine, SDTFError, SDTFNodeState, SDTFQuery, SpecifyDesignTokenFormat, TokenState, } from '@specifyapp/specify-design-token-format'; import { SpecifyError, specifyErrors } from '../errors/index.js'; import { UpdaterFunction } from './updaters/definitions.js'; import { SpecifyRepository } from '../httpApi/index.js'; import { createParsersEngine } from '../parsersEngine/createParsersEngine.js'; import { ParserFunction } from '../parsersEngine/definitions/ParserFunction.js'; import { SDTFEngineDataBox } from '../parsersEngine/index.js'; import { BuiltInGenerationParserRule } from '../builtInParsers/builtInParserRule.js'; /** * The main Specify client to interact with Specify repositories and the SDTF token tree. */ export class SDTFClient { readonly #initialTokenTree: SpecifyDesignTokenFormat; readonly engine: SDTFEngine; readonly repository: { readonly id: string; readonly name: string; readonly version: number; readonly createdAt: string; readonly updatedAt: string; }; constructor(repository: SpecifyRepository, tokenTree: SpecifyDesignTokenFormat) { this.repository = repository; this.#initialTokenTree = tokenTree; this.engine = createSDTFEngine(this.#initialTokenTree); } /** * Returns the current repository information. * @deprecated Use `this.repository` instead. */ get information() { return this.repository; } /** * Returns the JSON token tree from the current repository.* */ getJSONTokenTree() { return this.engine.query.renderJSONTree(); } /** * Create a new SDTFClient instance to avoid mutating the current token tree. * Especially useful when you want to perform multiple distinct operations on the same token tree. */ clone() { return new SDTFClient(this.repository, this.engine.renderJSONTree()); } /** * Narrow the current token tree by picking a subtree based on the given path. * @param path */ pick(path: Array) { const hasTrailingWildcard = path[path.length - 1] === '*'; const finalPath = new TreePath(hasTrailingWildcard ? path.slice(0, -1) : path); let maybeCollectionState: CollectionState | undefined; let maybeGroupState: GroupState | undefined; let maybeTokenState: TokenState | undefined; maybeCollectionState = this.engine.query.getCollectionState(finalPath); if (!maybeCollectionState) { maybeGroupState = this.engine.query.getGroupState(finalPath); if (!maybeGroupState) { // @ts-ignore maybeTokenState = this.engine.query.getTokenState(finalPath); } } if (maybeCollectionState) { let tokens: SpecifyDesignTokenFormat = {}; if (hasTrailingWildcard) { const { $collection, $description, $extensions, ...rest } = maybeCollectionState.toJSON(); tokens = rest; } else { tokens = { [maybeCollectionState.name]: maybeCollectionState.toJSON(), }; } return new SDTFClient(this.repository, tokens); } else if (maybeGroupState) { let tokens: SpecifyDesignTokenFormat = {}; if (hasTrailingWildcard) { const { $description, $extensions, ...rest } = maybeGroupState.toJSON(); tokens = rest as SpecifyDesignTokenFormat; } else { tokens = { [maybeGroupState.name]: maybeGroupState.toJSON(), }; } return new SDTFClient(this.repository, tokens); } else if (maybeTokenState) { if (hasTrailingWildcard) { throw new SpecifyError({ errorKey: specifyErrors.SDK_INVALID_TREE_PATH.errorKey, publicMessage: `The wildcard "*" is not supported for pick on tokens. Use it on collections or groups instead.`, }); } return new SDTFClient(this.repository, { [maybeTokenState.name]: maybeTokenState.toJSON(), }); } else { throw new SpecifyError({ errorKey: specifyErrors.SDK_INVALID_TREE_PATH.errorKey, publicMessage: `The path "${finalPath}" does not exist in the token tree.`, }); } } /** * Create a clone of the current SDTF and only keep the selection of the query. * When creating a new tree it's possible that tokens, groups and collections names will collide. * To avoid it, you can either pass `true` or a function on the 2nd parameter. * If `true` we will add a number at the end of the name if we find a collision. E.g: token, token-1, token-2, etc... * If you pass a function, you'll be able to rename the token exactly the way you want * @param query {SDTFQuery} - All the nodes matched by the query will be incuded in the new SDTF * @param dedupeFn {true | (treeState: TreeState, node: SDTFNodeState) => void} - Useful if you have names that are colliding */ query(query: SDTFQuery, dedupeFn?: MergeDedupeFn) { const { treeState } = this.engine.query.run(query).merge(dedupeFn); return new SDTFClient(this.repository, treeState.toJSON()); } /** * Rename a node to a new name. If you specify a type, it'll rename the node only if it's matching the type parameter. * If it doesn't, it'll throw an error. * Example: * ``` * sdtf.rename({ atPath: ['my', 'group'], name: 'newName' }) * ``` */ renameNode(options: { atPath: Array; name: string; type?: 'group' | 'collection' | 'token'; }) { const options_ = { atPath: new TreePath(options.atPath), name: options.name, type: options.type, }; switch (options.type) { case 'group': this.engine.mutation.renameGroup(options_); break; case 'collection': this.engine.mutation.renameCollection(options_); break; case 'token': this.engine.mutation.renameToken(options_); break; default: this.engine.mutation.renameNode(options_); break; } return this; } /** * Execute an update on the current token tree. Note that the update won't be applied to the remote repository. */ update(...updaters: Array) { for (let i = 0; i < updaters.length; i++) { updaters[i](this.engine); } return this; } /** * Execute multiple updater functions with the same query */ withQuery(query: SDTFQuery): { update: (...updaters: Array) => SDTFClient } { const self = this; return { /* * Execute an update on the current token tree. Note that the update won't be applied to the remote repository. */ update: (...updaters: Array) => { for (let i = 0; i < updaters.length; i++) { updaters[i](self.engine, query); } return this; }, }; } /** * Resolve aliases in the current token tree. * Useful before pick to avoid unresolvable aliases. */ resolveAliases() { this.engine.query.getAllTokenStates().forEach(tokenState => { tokenState.resolveValueAliases(); }); return this; } /** * Narrow the current token tree by removing any matching node based on the given query. * @param query */ remove(query: SDTFQuery) { this.engine.query .run(query) .sort((a, b) => a.path.length - b.path.length) .forEach(treeNodeState => { try { if (treeNodeState.isToken) { this.engine.mutation.deleteToken({ atPath: treeNodeState.path }); } else if (treeNodeState.isCollection) { this.engine.mutation.deleteCollection({ atPath: treeNodeState.path }); } else if (treeNodeState.isGroup) { this.engine.mutation.deleteGroup({ atPath: treeNodeState.path }); } } catch (error) { if (error instanceof SDTFError && error.errorKey === 'SDTF_TREE_NODE_NOT_FOUND') { return; } throw error; } }); return this; } /** * Resets the current token tree to its initial value. * The initial value being the token tree of the repository at the time of the creation of the first SDTFClient instance. */ reset() { this.engine.mutation.loadTokenTree({ tokens: this.#initialTokenTree, }); return this; } /** * Tap into the current token tree to perform custom side effects. * @param fn */ executeEngine(fn: (engine: SDTFEngine) => void) { fn(this.engine); return this; } /** * Iterate against the tokenStates of the current token tree. * @param fn */ forEachTokenState(fn: (tokenState: TokenState, engine: SDTFEngine) => void) { this.engine.query.getAllTokenStates().forEach(tokenState => fn(tokenState, this.engine)); return this; } /** * Iterate against the tokenStates of the current token tree, and accumulate the results in an array * @param fn */ mapTokenStates(fn: (tokenState: TokenState, engine: SDTFEngine) => T) { return this.engine.query.getAllTokenStates().map(tokenState => fn(tokenState, this.engine)); } /** * Get a token state for a given path * @param {Array} path */ getTokenState(path: Array) { return this.engine.query.getTokenState(new TreePath(path)); } /** * Get all the token states */ getAllTokenStates() { return this.engine.query.getAllTokenStates(); } /** * Iterate against the collectionStates of the current token tree. * @param fn */ forEachCollectionState(fn: (collectionState: CollectionState, engine: SDTFEngine) => void) { this.engine.query .getAllCollectionStates() .forEach(collectionState => fn(collectionState, this.engine)); return this; } /** * Iterate against the collectionStates of the current token tree, and accumulate the results in an array * @param fn */ mapCollectionStates(fn: (collectionState: CollectionState, engine: SDTFEngine) => T) { return this.engine.query .getAllCollectionStates() .map(collectionState => fn(collectionState, this.engine)); } /** * Get a collection state for a given path * @param {Array} path */ getCollectionState(path: Array) { return this.engine.query.getCollectionState(new TreePath(path)); } /** * Get all the collection states */ getAllCollectionStates() { return this.engine.query.getAllCollectionStates(); } /** * Iterate against the groupStates of the current token tree. * @param fn */ forEachGroupState(fn: (groupState: GroupState, engine: SDTFEngine) => void) { this.engine.query.getAllGroupStates().forEach(groupState => fn(groupState, this.engine)); return this; } /** * Iterate against the groupStates of the current token tree, and accumulate the results in an array * @param fn */ mapGroupStates(fn: (groupState: GroupState, engine: SDTFEngine) => T) { return this.engine.query.getAllGroupStates().map(groupState => fn(groupState, this.engine)); } /** * Get a group state for a given path * @param {Array} path */ getGroupState(path: Array) { return this.engine.query.getGroupState(new TreePath(path)); } /** * Get all the group states */ getAllGroupStates() { return this.engine.query.getAllGroupStates(); } /** * Iterate against the nodeStates given by the query. * @param query * @param fn */ forEachQueryResult( query: SDTFQuery, fn: (treeNodeState: SDTFNodeState, engine: SDTFEngine, queryResult: QueryResult) => void, ) { const queryResult = this.engine.query.run(query); queryResult.forEach(treeNodeState => { fn(treeNodeState, this.engine, queryResult); }); return this; } /** * Iterate against the nodeStates given by the query, and accumulate the result in an array * @param query * @param fn */ mapQueryResults( query: SDTFQuery, fn: (treeNodeState: SDTFNodeState, engine: SDTFEngine, queryResult: QueryResult) => T, ) { const queryResult = this.engine.query.run(query); return queryResult.map(treeNodeState => fn(treeNodeState, this.engine, queryResult)); } /** * Register a view to the SDTF Engine. * @param name * @param query - the SDTF query describing the expected view * @param shouldSetActive - whether the view should be set as active */ registerView(name: string, query: SDTFQuery, shouldSetActive: boolean = false) { this.engine.mutation.registerView({ name, query, shouldSetActive }); } /** * Set the active view of the SDTF Engine. * @param name */ setActiveView(name: string | null) { this.engine.mutation.setActiveView({ name }); } /** * Create a parsers engine executor from the custom or built-in parser functions passed as arguments. * All pipelines are executed in parallel, if you need to chain parsers, have a look to the chainParserFunctions util. * @param parsersPipelines * @example * const executePipelines = sdtfClient.createParsersPipelines( * parsers.toTailwind({}), * async (dataBox, toolbox) => { * // custom code * return dataBox; * }, * ); * * const results = await executePipelines() */ createParsersPipelines(...parsersPipelines: Array>) { return createParsersEngine( parsersPipelines, { type: 'SDTF Engine', engine: this.engine.clone(), }, { isRemote: false, builtInParserKind: 'generation', }, ); } /** * Create a parsers engine executor from the built-in parser rules passed as arguments. * All pipelines are executed in parallel, if you need to chain parsers, have a look to the chainParserFunctions util. * @param parserRules * @example * const executePipelines = sdtfClient.createParsersPipelinesFromRules({ * name: 'My awesome rule', * parsers: [ * { * name: 'to-tailwind', * output: { type: 'file', filePath: './tokens.js' }, * }, * ], * }); * * const results = await executePipelines(); */ createParsersPipelinesFromRules(...parserRules: Array) { return createParsersEngine( parserRules, { type: 'SDTF Engine', engine: this.engine.clone(), }, { isRemote: false, builtInParserKind: 'generation', }, ); } }