diff --git a/EXAMPLE_VAULT/.obsidian/plugins/modal-form/data.json b/EXAMPLE_VAULT/.obsidian/plugins/modal-form/data.json index 89ab684..28fa6ad 100644 --- a/EXAMPLE_VAULT/.obsidian/plugins/modal-form/data.json +++ b/EXAMPLE_VAULT/.obsidian/plugins/modal-form/data.json @@ -450,7 +450,6 @@ ], "version": "1", "template": { - "createCommand": true, "parsedTemplate": [ { "_tag": "text", @@ -514,7 +513,8 @@ }, { "_tag": "variable", - "value": "dateOfBirth" + "value": "dateOfBirth", + "transformation": "stringify" }, { "_tag": "text", @@ -576,7 +576,8 @@ "_tag": "text", "value": "\n\n---\n> Last modified: <% tp.file.last_modified_date(\"dddd, MMMM Do YYYY HH:mm:ss\") %>" } - ] + ], + "createCommand": true } } ] diff --git a/docs/templates.md b/docs/templates.md index 536ff8a..0720b56 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -20,6 +20,103 @@ Variables are placeholders that get replaced with form field values. The variabl Hello {{name}}! Your favorite color is {{color}}. ``` +#### Basic Variable Syntax + +- **Variable Declaration**: + + ```plaintext + {{ variableName }} + ``` + + Example: + + ```plaintext + {{ name }} + ``` + +Note that spaces around the variable name are ignored, allowing for flexibility in formatting. + +```plaintext +This is a {{variableName}}. And this is also a valid {{ variableName }} +``` + +#### Variable with Transformations + +- **Transformation Syntax**: You can apply transformations to a variable by using a pipe `|` followed by the transformation name. + + ```plaintext + {{ variableName | transformation }} + ``` + + Example: + + ```plaintext + {{ name | trim }} + ``` + +### Supported Transformations + +The following transformations can be applied to variables: + +1. **`upper`**: Converts the variable's value to uppercase. + - Usage: + + ```plaintext + {{ name | upper }} + ``` + +2. **`lower`**: Converts the variable's value to lowercase. + - Usage: + + ```plaintext + {{ name | lower }} + ``` + +3. **`trim`**: Removes whitespace from both ends of the variable's value. + - Usage: + + ```plaintext + {{ name | trim }} + ``` + +4. **`stringify`**: Converts the variable's value to a JSON string. + - Usage: + + ```plaintext + {{ name | stringify }} + ``` + +### Example Templates + +Here are some examples of how to use the new template syntax: + +- **Single Variable**: + + ```plaintext + Hello, {{ name }}! + ``` + +- **Variable with Transformation**: + + ```plaintext + Hello, {{ name | upper }}! + ``` + +- **Multiple Variables with Transformations**: + + ```plaintext + Hello, {{ name | trim }}! You are {{ age }} years old. + ``` + +### Notes + +- If an invalid transformation is specified, it will be silently ignored, and the variable will be processed without transformation. +- Spaces around the transformation operator (`|`) are ignored, allowing for flexibility in formatting: + + ```plaintext + {{ name | trim }} or {{ name|trim }} are both valid. + ``` + ### Available Commands #### Frontmatter Command @@ -27,7 +124,7 @@ Hello {{name}}! Your favorite color is {{color}}. The frontmatter command controls which form fields appear in the YAML frontmatter section of your note: ``` -{{# frontmatter pick: title, tags #}} +{# frontmatter pick: title, tags #} ``` Options: @@ -38,13 +135,13 @@ Options: You can combine both options: ``` -{{# frontmatter pick: title, tags, date omit: draft #}} +{# frontmatter pick: title, tags, date omit: draft #} ``` If no options are specified, all form fields will be included in the frontmatter: ``` -{{# frontmatter #}} +{# frontmatter #} ``` ## Templater Support @@ -67,7 +164,7 @@ Note: This feature is only available if you have the Templater plugin installed Here's a complete template example that combines variables and frontmatter: ``` -{{# frontmatter pick: title, tags #}} +{# frontmatter pick: title, tags #} # {{title}} diff --git a/src/core/template/templateParser.test.ts b/src/core/template/templateParser.test.ts index 7a00ac5..c0895eb 100644 --- a/src/core/template/templateParser.test.ts +++ b/src/core/template/templateParser.test.ts @@ -1,83 +1,112 @@ -import { parseTemplate, anythingUntilOpenOrEOF, executeTemplate } from "./templateParser"; -import * as S from 'parser-ts/string' -import * as E from "fp-ts/Either"; import { pipe, tap } from "@std"; +import * as E from "fp-ts/Either"; import { stringifyYaml } from "obsidian"; +import * as S from "parser-ts/string"; +import { anythingUntilOpenOrEOF, executeTemplate, parseTemplate } from "./templateParser"; const inspect = (val: unknown) => { console.dir(val, { depth: 10 }); return val; -} +}; const logError = E.mapLeft(console.log); describe("parseTemplate", () => { it.skip("test", () => { pipe( + // stupid prettier S.run("al{nam{{e}}")(anythingUntilOpenOrEOF), - inspect) + inspect, + ); }); it("should parse a single identifier template", () => { const template = "{{name}}"; const result = parseTemplate(template); expect(result).toEqual(E.of([{ _tag: "variable", value: "name" }])); - }); it("templates can start with an identifier", () => { const template = "{{name}} is a name"; const result = parseTemplate(template); - expect(result).toEqual(E.of([ - { _tag: "variable", value: "name" }, - { _tag: "text", value: " is a name" }, - ])); + expect(result).toEqual( + E.of([ + { _tag: "variable", value: "name" }, + { _tag: "text", value: " is a name" }, + ]), + ); }); it("should parse a valid template", () => { const template = "Hello, {{name}}!"; const result = parseTemplate(template); logError(result); - expect(result).toEqual(E.of( - [ + expect(result).toEqual( + E.of([ { _tag: "text", value: "Hello, " }, { _tag: "variable", value: "name" }, { _tag: "text", value: "!" }, - ], - )); + ]), + ); }); it("should parse a valid template with several variables", () => { const template = "Hello, {{name}}! You are {{age}} years old."; const result = parseTemplate(template); logError(result); - expect(result).toEqual(E.of( - [ + expect(result).toEqual( + E.of([ { _tag: "text", value: "Hello, " }, { _tag: "variable", value: "name" }, { _tag: "text", value: "! You are " }, { _tag: "variable", value: "age" }, { _tag: "text", value: " years old." }, - ], - )); + ]), + ); + }); + + it("should parse a variables with transformations", () => { + const template = "Hello, {{name|uppercase}}! You are {{age|stringify}} years old."; + const result = parseTemplate(template); + logError(result); + expect(result).toEqual( + E.of([ + { _tag: "text", value: "Hello, " }, + { _tag: "variable", value: "name", transformation: "upper" }, + { _tag: "text", value: "! You are " }, + { _tag: "variable", value: "age", transformation: "stringify" }, + { _tag: "text", value: " years old." }, + ]), + ); + }); + + it("should silently ignore invalid transformations", () => { + const template = "Hello, {{name|invalid}}! You are {{age|stringify}} years old."; + const result = parseTemplate(template); + logError(result); + expect(result).toEqual( + E.of([ + { _tag: "text", value: "Hello, " }, + { _tag: "variable", value: "name" }, + { _tag: "text", value: "! You are " }, + { _tag: "variable", value: "age", transformation: "stringify" }, + { _tag: "text", value: " years old." }, + ]), + ); }); it("should allow single braces in a template", () => { const template = "This is code {bla}"; const result = parseTemplate(template); logError(result); - expect(result).toEqual(E.of( - [ - { _tag: "text", value: "This is code {bla}" }, - ], - )); - }) + expect(result).toEqual(E.of([{ _tag: "text", value: "This is code {bla}" }])); + }); it("should allow single braces in a template even if it has variables", () => { const template = "This is code {bla} {{name}}"; const result = parseTemplate(template); logError(result); - expect(result).toEqual(E.of( - [ + expect(result).toEqual( + E.of([ { _tag: "text", value: "This is code {bla} " }, { _tag: "variable", value: "name" }, - ], - )); - }) + ]), + ); + }); it.skip("should return a parse error for an invalid template", () => { const template = "Hey, {{name}!"; @@ -96,59 +125,137 @@ describe("parseTemplate", () => { const template = "Hey, {{ name }}!"; const result = parseTemplate(template); logError(result); - expect(result).toEqual(E.of( - [ + expect(result).toEqual( + E.of([ { _tag: "text", value: "Hey, " }, { _tag: "variable", value: "name" }, { _tag: "text", value: "!" }, - ], - )); - }) + ]), + ); + }); + + it("should properly execute a template with transformations", () => { + const template = "Hello, {{name|upper}}! You are {{age|stringify}} years old."; + const parsed = parseTemplate(template); + const result = pipe( + parsed, + E.map((parsedTemplate) => executeTemplate(parsedTemplate, { name: "John", age: 18 })), + E.map(tap("executed")), + ); + expect(result).toEqual(E.of("Hello, JOHN! You are 18 years old.")); + }); + + it("Should execute a template with lowercase transformations", () => { + const template = "Hello, {{name|lower}}! You are {{age}} years old."; + const parsed = parseTemplate(template); + const result = pipe( + parsed, + E.map((parsedTemplate) => executeTemplate(parsedTemplate, { name: "John", age: 18 })), + E.map(tap("executed")), + ); + expect(result).toEqual(E.of("Hello, john! You are 18 years old.")); + }); + + it("Should execute a template with trim transformations", () => { + const template = "Hello, {{name|trim}}!"; + const parsed = parseTemplate(template); + const result = pipe( + parsed, + E.map((parsedTemplate) => executeTemplate(parsedTemplate, { name: " John ", age: 18 })), + E.map(tap("executed")), + ); + expect(result).toEqual(E.of("Hello, John!")); + }); + + it("Should execute a template with stringify transformations", () => { + const template = "Hello, {{name|stringify}}!"; + const parsed = parseTemplate(template); + const result = pipe( + parsed, + E.map((parsedTemplate) => executeTemplate(parsedTemplate, { name: "John", age: 18 })), + E.map(tap("executed")), + ); + expect(result).toEqual(E.of('Hello, "John"!')); + }); + + it("Spaces around transformations should be ignored", () => { + const template = "Hello, {{name | stringify }}!"; + const parsed = parseTemplate(template); + const result = pipe( + parsed, + E.map((parsedTemplate) => executeTemplate(parsedTemplate, { name: "John", age: 18 })), + E.map(tap("executed")), + ); + expect(result).toEqual(E.of('Hello, "John"!')); + }); + + it("Spaces around transformations AND variables should be ignored", () => { + const template = "Hello, {{ name | stringify }}!"; + const parsed = parseTemplate(template); + const result = pipe( + parsed, + E.map((parsedTemplate) => executeTemplate(parsedTemplate, { name: "John", age: 18 })), + E.map(tap("executed")), + ); + expect(result).toEqual(E.of('Hello, "John"!')); + }); + + it("Should execute a template with no transformations", () => { + const template = "Hello, {{name}}! You are {{age}} years old."; + const parsed = parseTemplate(template); + const result = pipe( + parsed, + E.map((parsedTemplate) => executeTemplate(parsedTemplate, { name: "John", age: 18 })), + E.map(tap("executed")), + ); + expect(result).toEqual(E.of("Hello, John! You are 18 years old.")); + }); + it("should parse a frontmatter command", () => { const template = "{#frontmatter#}"; const result = parseTemplate(template); expect(result).toEqual(E.of([{ _tag: "frontmatter-command", pick: [], omit: [] }])); - }) + }); it("should parse a frontmatter command that includes spaces", () => { const template = "{# frontmatter #}"; const result = parseTemplate(template); expect(result).toEqual(E.of([{ _tag: "frontmatter-command", pick: [], omit: [] }])); - }) + }); it("should parse a frontmatter command with pick values", () => { const template = "{# frontmatter pick: name,age #}"; const result = parseTemplate(template); - expect(result).toEqual(E.of([{ _tag: "frontmatter-command", pick: ['name', 'age'], omit: [] }])); - }) + expect(result).toEqual( + E.of([{ _tag: "frontmatter-command", pick: ["name", "age"], omit: [] }]), + ); + }); it("should parse a frontmatter command with pick values that can be separated by spaces", () => { const template = "{# frontmatter pick: name, age #}"; const result = parseTemplate(template); - expect(result).toEqual(E.of([{ _tag: "frontmatter-command", pick: ['name', 'age'], omit: [] }])); - }) + expect(result).toEqual( + E.of([{ _tag: "frontmatter-command", pick: ["name", "age"], omit: [] }]), + ); + }); it("Should properly execute a template with a frontmatter command", () => { const template = "{# frontmatter #}"; const parsed = parseTemplate(template); const result = pipe( parsed, - E.map((parsedTemplate) => - executeTemplate(parsedTemplate, { name: 'John', age: 18 }) - ), - E.map(tap('executed')), - ) - expect(result).toEqual(E.of(stringifyYaml({ name: 'John', age: 18 }))) - }) + E.map((parsedTemplate) => executeTemplate(parsedTemplate, { name: "John", age: 18 })), + E.map(tap("executed")), + ); + expect(result).toEqual(E.of(stringifyYaml({ name: "John", age: 18 }))); + }); it("Should properly execute a template with a frontmatter command that specifies a pick", () => { const template = "{# frontmatter pick: name #}"; const parsed = parseTemplate(template); const result = pipe( parsed, - E.map((parsedTemplate) => - executeTemplate(parsedTemplate, { name: 'John', age: 18 }) - ), - E.map(tap('executed')), - ) - expect(result).toEqual(E.of(stringifyYaml({ name: 'John' }))) - }) + E.map((parsedTemplate) => executeTemplate(parsedTemplate, { name: "John", age: 18 })), + E.map(tap("executed")), + ); + expect(result).toEqual(E.of(stringifyYaml({ name: "John" }))); + }); }); diff --git a/src/core/template/templateParser.ts b/src/core/template/templateParser.ts index e47c400..43a71f0 100644 --- a/src/core/template/templateParser.ts +++ b/src/core/template/templateParser.ts @@ -1,23 +1,29 @@ -import { Either, O, pipe } from "@std"; +import { Either, O, parse, pipe } from "@std"; import * as A from "fp-ts/Array"; import * as E from "fp-ts/Either"; import * as R from "fp-ts/Record"; -import { absurd, identity } from "fp-ts/function"; +import { absurd, constUndefined, identity } from "fp-ts/function"; import * as St from "fp-ts/string"; import { stringifyYaml } from "obsidian"; import * as P from "parser-ts/Parser"; import * as C from "parser-ts/char"; import * as S from "parser-ts/string"; -import { ModalFormData } from "../FormResult"; -import type { FrontmatterCommand, TemplateText, TemplateVariable } from "./templateSchema"; +import { ModalFormData, Val } from "../FormResult"; +import { + transformations, + type FrontmatterCommand, + type TemplateText, + type TemplateVariable, + type Transformations, +} from "./templateSchema"; type Token = TemplateText | TemplateVariable | FrontmatterCommand; export type ParsedTemplate = Token[]; function TemplateText(value: string): TemplateText { return { _tag: "text", value }; } -function TemplateVariable(value: string): TemplateVariable { - return { _tag: "variable", value }; +function TemplateVariable(value: string, transformation?: Transformations): TemplateVariable { + return { _tag: "variable", value, transformation }; } function FrontmatterCommand(pick: string[] = [], omit: string[] = []): FrontmatterCommand { @@ -36,10 +42,30 @@ const EofStr = pipe( const open = S.fold([S.string("{{"), S.spaces]); const close = P.expected(S.fold([S.spaces, S.string("}}")]), 'closing variable tag: "}}"'); const identifier = S.many1(C.alphanum); +const transformation = pipe( + // dam prettier + S.fold([S.spaces, S.string("|"), S.spaces]), + P.apSecond(identifier), + P.map((x) => { + return pipe( + parse(transformations, x), + E.fold(constUndefined, (x) => x), + ); + }), +); + const templateIdentifier: TokenParser = pipe( - identifier, + identifier, // First, we parse the variable name + // chain takes a function that accepts the result of the previous parser and returns a new parser + P.chain((value) => + pipe( + // Within this pipe we build a parser of Parser also using the value of the previous parser + P.optional(transformation), + P.map((trans) => TemplateVariable(value, O.toUndefined(trans))), + ), + ), + // finally we wrap the resulting parser in between the open and close strings P.between(open, close), - P.map(TemplateVariable), ); // === Command Parser === @@ -115,6 +141,9 @@ export function parseTemplate(template: string): Either // return S.run(template)(P.many(Template)) } +/** + * Given a parsed template, returns a list of the variables used in the template + */ export function templateVariables(parsedTemplate: ReturnType): string[] { return pipe( parsedTemplate, @@ -148,7 +177,7 @@ function tokenToString(token: Token): string { case "text": return token.value; case "variable": - return `{{${token.value}}}`; + return `{{${token.value}${token.transformation ? `|${token.transformation}` : ""}}}`; case "frontmatter-command": return `{{# frontmatter pick: ${token.pick.join(", ")}, omit: ${token.omit.join( ", ", @@ -160,7 +189,7 @@ function tokenToString(token: Token): string { function matchToken( onText: (value: string) => T, - onVariable: (variable: string) => T, + onVariable: (variable: string, transformation?: Transformations) => T, onCommand: (command: FrontmatterCommand) => T, ) { return (token: Token): T => { @@ -168,7 +197,7 @@ function matchToken( case "text": return onText(token.value); case "variable": - return onVariable(token.value); + return onVariable(token.value, token.transformation); case "frontmatter-command": return onCommand(token); default: @@ -206,6 +235,28 @@ function asFrontmatterString(data: Record) { ); } +function executeTransformation( + transformation: Transformations | undefined, +): (value: Val) => string { + return (value) => { + if (transformation === undefined) { + return String(value); + } + switch (transformation) { + case "upper": + return String(value).toUpperCase(); + case "lower": + return String(value).toLowerCase(); + case "stringify": + return JSON.stringify(value); + case "trim": + return String(value).trim(); + default: + return absurd(transformation); + } + }; +} + export function executeTemplate(parsedTemplate: ParsedTemplate, formData: ModalFormData) { const toFrontmatter = asFrontmatterString(formData); // Build it upfront rater than on every call return pipe( @@ -213,7 +264,11 @@ export function executeTemplate(parsedTemplate: ParsedTemplate, formData: ModalF A.filterMap( matchToken( O.some, - (key) => O.fromNullable(formData[key]), + (key, transformation) => + pipe( + O.fromNullable(formData[key]), + O.map(executeTransformation(transformation)), + ), (command) => pipe( //prettier diff --git a/src/core/template/templateSchema.ts b/src/core/template/templateSchema.ts index a43eb8f..a7209b6 100644 --- a/src/core/template/templateSchema.ts +++ b/src/core/template/templateSchema.ts @@ -1,13 +1,35 @@ -import { Output, array, literal, object, string, union } from "valibot"; +import { + Output, + array, + enumType, + literal, + object, + optional, + string, + transform, + union, +} from "valibot"; const TemplateTextSchema = object({ _tag: literal("text"), value: string(), }); +const upper = transform(enumType(["upper", "uppercase"]), (_) => "upper" as const); + +export const transformations = union([ + upper, + literal("lower"), + literal("trim"), + literal("stringify"), +]); + +export type Transformations = Output; + const TemplateVariableSchema = object({ _tag: literal("variable"), value: string(), + transformation: optional(transformations), }); const FrontmatterCommandSchema = object({ @@ -16,8 +38,9 @@ const FrontmatterCommandSchema = object({ omit: array(string()), }); -export const ParsedTemplateSchema = array(union([TemplateTextSchema, TemplateVariableSchema, FrontmatterCommandSchema])) - +export const ParsedTemplateSchema = array( + union([TemplateTextSchema, TemplateVariableSchema, FrontmatterCommandSchema]), +); export type TemplateText = Output; export type TemplateVariable = Output; diff --git a/src/views/components/Form/MarkdownBlock.svelte b/src/views/components/Form/MarkdownBlock.svelte index f7d1d61..423f26e 100644 --- a/src/views/components/Form/MarkdownBlock.svelte +++ b/src/views/components/Form/MarkdownBlock.svelte @@ -1,7 +1,6 @@