diff --git a/docs/documentation.toml b/docs/documentation.toml index c99b4a3e..f906f354 100644 --- a/docs/documentation.toml +++ b/docs/documentation.toml @@ -2,6 +2,9 @@ name = "app" description = "This module exposes the app instance. Prefer to use this over the global app instance." +[tp.user] +name = "user" +description = "This module exposes custom made scripts, written by yourself within the script file folder location" [tp.config] name = "config" diff --git a/docs/src/user-functions/script-user-functions.md b/docs/src/user-functions/script-user-functions.md index b533b6c6..a1ec4b2d 100644 --- a/docs/src/user-functions/script-user-functions.md +++ b/docs/src/user-functions/script-user-functions.md @@ -43,3 +43,20 @@ However, you can't access the template engine scoped variables like `tp` or `tR` You can pass as many arguments as you want to your function, depending on how you defined it. You can for example pass the `tp` object to your function, to be able to use all of the [internal variables / functions](../internal-variables-functions/overview.md) of Templater: `<% tp.user.(tp) %>` + +## User Script Documentation + +Optionally you can document what a script does using the [TSDoc Standard](https://tsdoc.org/) at the **top** of your method file. If provided, this will provide an intellisense-like experience for your user scripts similar to the experience of the other templater functions. + +### Example of User Script with Documentation + +```javascript +/** + * This does something cool + */ +function doSomething() { + console.log('Something was done') +} + +module.exports = doSomething; +``` diff --git a/package.json b/package.json index df1c7608..a06c0c2f 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@codemirror/language": "github:lishid/cm-language", "@codemirror/state": "^6.1.2", "@codemirror/view": "^6.4.1", + "@microsoft/tsdoc": "^0.15.1", "@popperjs/core": "^2.11.6", "@silentvoid13/rusty_engine": "^0.4.0", "child_process": "^1.0.2", diff --git a/src/editor/Autocomplete.ts b/src/editor/Autocomplete.ts index d3768636..8375427d 100644 --- a/src/editor/Autocomplete.ts +++ b/src/editor/Autocomplete.ts @@ -16,6 +16,7 @@ import { TpSuggestDocumentation, } from "./TpDocumentation"; import TemplaterPlugin from "main"; +import { append_bolded_label_with_value_to_parent } from "utils/Utils"; export class Autocomplete extends EditorSuggest { //private in_command = false; @@ -72,10 +73,10 @@ export class Autocomplete extends EditorSuggest { return trigger_info; } - getSuggestions(context: EditorSuggestContext): TpSuggestDocumentation[] { + async getSuggestions(context: EditorSuggestContext): Promise { let suggestions: Array; if (this.module_name && this.function_trigger) { - suggestions = this.documentation.get_all_functions_documentation( + suggestions = await this.documentation.get_all_functions_documentation( this.module_name as ModuleName, this.function_name ) as TpFunctionDocumentation[]; @@ -92,7 +93,21 @@ export class Autocomplete extends EditorSuggest { renderSuggestion(value: TpSuggestDocumentation, el: HTMLElement): void { el.createEl("b", { text: value.name }); - el.createEl("br"); + if (is_function_documentation(value)) + { + if (value.args && + this.getNumberOfArguments(value.args) > 0 + ) { + el.createEl('p', {text: "Parameter list:"}) + const list = el.createEl("ol"); + for (const [key, val] of Object.entries(value.args)) { + append_bolded_label_with_value_to_parent(list, key, val.description) + } + } + if (value.returns) { + append_bolded_label_with_value_to_parent(el, 'Returns', value.returns) + } + } if (this.function_trigger && is_function_documentation(value)) { el.createEl("code", { text: value.definition }); } @@ -126,4 +141,14 @@ export class Autocomplete extends EditorSuggest { active_editor.editor.setCursor(cursor_pos); } } + + getNumberOfArguments( + args: object + ): number { + try { + return new Map(Object.entries(args)).size; + } catch (error) { + return 0; + } + } } diff --git a/src/editor/TpDocumentation.ts b/src/editor/TpDocumentation.ts index 4c7f04b4..95d426d6 100644 --- a/src/editor/TpDocumentation.ts +++ b/src/editor/TpDocumentation.ts @@ -1,6 +1,6 @@ import TemplaterPlugin from "main"; -import { errorWrapperSync } from "utils/Error"; -import { get_fn_params, get_tfiles_from_folder, is_object } from "utils/Utils"; +import { errorWrapper } from "utils/Error"; +import { get_fn_params, get_tfiles_from_folder, is_object, populate_docs_from_user_scripts } from "utils/Utils"; import documentation from "../../docs/documentation.toml"; const module_names = [ @@ -42,6 +42,7 @@ export type TpFunctionDocumentation = { queryKey: string; definition: string; description: string; + returns: string; example: string; args?: { [key: string]: TpArgumentDocumentation; @@ -55,12 +56,15 @@ export type TpArgumentDocumentation = { export type TpSuggestDocumentation = | TpModuleDocumentation - | TpFunctionDocumentation; + | TpFunctionDocumentation + | TpArgumentDocumentation; export function is_function_documentation( x: TpSuggestDocumentation ): x is TpFunctionDocumentation { - if ((x as TpFunctionDocumentation).definition) { + if ((x as TpFunctionDocumentation).definition || + (x as TpFunctionDocumentation).returns || + (x as TpFunctionDocumentation).args) { return true; } return false; @@ -72,16 +76,24 @@ export class Documentation { constructor(private plugin: TemplaterPlugin) {} get_all_modules_documentation(): TpModuleDocumentation[] { - return Object.values(this.documentation.tp).map((mod) => { + let tp = this.documentation.tp + + // Remove 'user' if no user scripts found + if (!this.plugin.settings || + !this.plugin.settings.user_scripts_folder) { + tp = Object.values(tp).filter((x) => x.name !== 'user') + } + + return Object.values(tp).map((mod) => { mod.queryKey = mod.name; return mod; }); } - get_all_functions_documentation( + async get_all_functions_documentation( module_name: ModuleName, function_name: string - ): TpFunctionDocumentation[] | undefined { + ): Promise { if (module_name === "app") { return this.get_app_functions_documentation( this.plugin.app, @@ -94,28 +106,43 @@ export class Documentation { !this.plugin.settings.user_scripts_folder ) return; - const files = errorWrapperSync( - () => - get_tfiles_from_folder( + const files = await errorWrapper( + async () => { + const files = get_tfiles_from_folder( this.plugin.app, this.plugin.settings.user_scripts_folder - ), + ).filter(x => x.extension == "js") + const docFiles = await populate_docs_from_user_scripts( + this.plugin.app, + files + ) + return docFiles; + }, `User Scripts folder doesn't exist` ); if (!files || files.length === 0) return; return files.reduce( (processedFiles, file) => { if (file.extension !== "js") return processedFiles; - return [ + const values = [ ...processedFiles, { name: file.basename, queryKey: file.basename, definition: "", - description: "", + description: file.description, + returns: file.returns, + args: file.arguments.reduce<{[key: string]: TpArgumentDocumentation}>((acc, arg) => { + acc[arg.name] = { + name: arg.name, + description: arg.description + }; + return acc; + }, {}), example: "", }, ]; + return values; }, [] ); @@ -174,6 +201,7 @@ export class Documentation { )})` : definition, description: "", + returns: "", example: "", }); } diff --git a/src/utils/TJDocFile.ts b/src/utils/TJDocFile.ts new file mode 100644 index 00000000..2c1ed701 --- /dev/null +++ b/src/utils/TJDocFile.ts @@ -0,0 +1,21 @@ +import { TFile } from 'obsidian' + +export class TJDocFile extends TFile { + public description: string + public returns: string + public arguments: TJDocFileArgument[] + + constructor(file: TFile) { + super(file.vault, file.path) + Object.assign(this, file) + } +} + +export class TJDocFileArgument { + public name: string + public description: string + constructor(name: string, desc: string) { + this.name = name; + this.description = desc; + } +} diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index f644b8e1..0517cdaf 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -1,3 +1,7 @@ +import { DocBlock, DocNode, DocParamBlock, DocParamCollection, DocPlainText, DocSection, ParserContext, TSDocParser } from "@microsoft/tsdoc"; + +import { TJDocFile, TJDocFileArgument } from "./TJDocFile"; + import { TemplaterError } from "./Error"; import { App, @@ -72,6 +76,91 @@ export function get_tfiles_from_folder( return files; } +export async function populate_docs_from_user_scripts( + app: App, + files: Array +): Promise { + const docFiles = await Promise.all(files.map(async file => { + // Get file contents + const content = await app.vault.cachedRead(file) + + const newDocFile = generate_jsdoc(file, content); + + return newDocFile; + } + )); + + return docFiles; +} + +function generate_jsdoc( + file: TFile, + content: string +): TJDocFile{ + // Parse the content + const tsdocParser = new TSDocParser(); + const parsedDoc = tsdocParser.parseString(content); + + // Copy and extract information into the TJDocFile + const newDocFile = new TJDocFile(file); + + newDocFile.description = generate_jsdoc_description(parsedDoc.docComment.summarySection); + newDocFile.returns = generate_jsdoc_return(parsedDoc.docComment.returnsBlock); + newDocFile.arguments = generate_jsdoc_arguments(parsedDoc.docComment.params); + + return newDocFile +} + +function generate_jsdoc_description( + summarySection: DocSection +) : string { + try { + const description = summarySection.nodes.map((node: DocNode) => + node.getChildNodes() + .filter((node: DocNode) => node instanceof DocPlainText) + .map((x: DocPlainText) => x.text) + .join("\n") + ); + + return description.join("\n"); + } catch (error) { + console.error('Failed to parse sumamry section'); + throw error; + } +} + +function generate_jsdoc_return( + returnSection : DocBlock | undefined +): string { + if (!returnSection) return ""; + + try { + const returnValue = returnSection.content.nodes[0].getChildNodes()[0].text.trim(); + return returnValue; + } catch (error) { + return ""; + } +} + +function generate_jsdoc_arguments( + paramSection: DocParamCollection +) : TJDocFileArgument[] { + try { + const blocks = paramSection.blocks; + const args = blocks.map((block) => { + const name = block.parameterName; + const description = block.content.getChildNodes()[0].getChildNodes() + .filter(x => x instanceof DocPlainText) + .map(x => x.text).join(" ") + return new TJDocFileArgument(name, description); + }) + + return args; + } catch (error) { + return []; + } +} + export function arraymove( arr: T[], fromIndex: number, @@ -113,3 +202,27 @@ export function get_fn_params(func: (...args: unknown[]) => unknown) { .replace(/ /g, "") .split(","); } + +/** + * Use a parent HtmlElement to create a label with a value + * @param parent The parent HtmlElement; Use HtmlOListElement to return a `li` element + * @param title The title for the label which will be bolded + * @param value The value of the label + * @returns A label HtmlElement (p | li) + */ +export function append_bolded_label_with_value_to_parent( + parent: HTMLElement, + title: string, + value: string +): HTMLElement{ + const tag = parent instanceof HTMLOListElement ? "li" : "p"; + + const para = parent.createEl(tag); + const bold = parent.createEl('b', {text: title}); + para.appendChild(bold); + para.appendChild(document.createTextNode(`: ${value}`)) + + // Returns a p or li element + // Resulting in Title: value + return para; +}