Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/docs/FormatSyntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ title: Format syntax
| `{{VALUE}}` or `{{NAME}}` | Interchangeable. Represents the value given in an input prompt. If text is selected in the current editor, it will be used as the value. For Capture choices, selection-as-value can be disabled globally or per-capture. When using the QuickAdd API, this can be passed programmatically using the reserved variable name 'value'.<br/><br/>**Macro note:** `{{VALUE}}` / `{{NAME}}` are scoped per template step, so each template in a macro prompts independently. Use `{{VALUE:sharedName}}` when you want one prompt reused across the macro. |
| `{{VALUE:<variable name>}}` | You can now use variable names in values. They'll get saved and inserted just like values, but the difference is that you can have as many of them as you want. Use comma separation to get a suggester rather than a prompt.<br/><br/>If the same variable name appears in multiple macro steps, QuickAdd prompts once and reuses the value. |
| `{{VALUE:<variable name>\|label:<helper text>}}` | Adds helper text to the prompt for a single-value input. The helper appears below the header and is useful for reminders or instructions. For multi-value lists, use the same syntax to label the suggester (e.g., `{{VALUE:Red,Green,Blue\|label:Pick a color}}`). |
| `{{VALUE:<variable name>\|<default>}}` | Same as above, but with a default value. For single-value prompts (e.g., `{{VALUE:name\|Anonymous}}`), the default is pre-populated in the input field - press Enter to accept or clear/edit it. For multi-value suggesters without `\|custom`, you must select one of the provided options (no default applies). If you combine options like `\|label:...`, use `\|default:<value>` instead of the shorthand (mixing option keys with a bare default is not supported). |
| `{{VALUE:<variable name>\|<default>}}` | Same as above, but with a default value. For single-value prompts (e.g., `{{VALUE:name\|Anonymous}}`), the default is pre-populated in the input field - press Enter to accept or clear/edit it. For multi-value suggesters without `\|custom`, you must select one of the provided options (no default applies). If you combine keyed options like `\|label:`, `\|default:`, `\|type:`, or `\|case:`, shorthand defaults like `\|Anonymous` are ignored; use `\|default:Anonymous` instead. |
| `{{VALUE:<variable name>\|default:<value>}}` | Option-form default value, required when combining with other options like `\|label:`. Example: `{{VALUE:title\|label:Snake case\|default:My_Title}}`. |
| `{{VALUE\|type:multiline}}` / `{{VALUE:<variable>\|type:multiline}}` | Forces a multi-line input prompt/textarea for that VALUE token. Only supported for single-value prompts (no comma options / `\|custom`). Overrides the global "Use Multi-line Input Prompt" setting. If `\|type:` is present, shorthand defaults like `\|Some value` are ignored; use `\|default:` instead. |
| `{{VALUE\|case:<style>}}` / `{{NAME\|case:<style>}}` / `{{VALUE:<variable>\|case:<style>}}` | Transforms the resolved value into a casing style. Supported: `kebab`, `snake`, `camel`, `pascal`, `title`, `lower`, `upper`. Example: `{{VALUE:title\|case:kebab}}` outputs `my-new-blog` when the input is `My New Blog`. |
| `{{VALUE:<options>\|custom}}` | Allows you to type custom values in addition to selecting from the provided options. Example: `{{VALUE:Red,Green,Blue\|custom}}` will suggest Red, Green, and Blue, but also allows you to type any other value like "Purple". This is useful when you have common options but want flexibility for edge cases. **Note:** You cannot combine `\|custom` with a shorthand default value - use `\|default:` if you need both. |
| `{{LINKCURRENT}}` | A link to the file from which the template or capture was triggered (`[[link]]` format). When the append-link setting is set to **Enabled (skip if no active file)**, this token resolves to an empty string instead of throwing an error if no note is focused. |
| `{{FILENAMECURRENT}}` | The basename (without extension) of the file from which the template or capture was triggered. Honors the same **required/optional** behavior as `{{LINKCURRENT}}` - when optional and no active file exists, resolves to an empty string. |
Expand Down
6 changes: 6 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export const VARIABLE_DEFAULT_OPTION_SYNTAX =
"{{value:<variable name>|default:<value>}}";
export const VARIABLE_LABEL_SYNTAX =
"{{value:<variable name>|label:<helper text>}}";
export const VALUE_CASE_SYNTAX = "{{value|case:kebab}}";
export const VARIABLE_CASE_SYNTAX = "{{value:<variable name>|case:kebab}}";
export const FIELD_VAR_SYNTAX = "{{field:<field name>}}";
export const MATH_VALUE_SYNTAX = "{{mvalue}}";
export const LINKCURRENT_SYNTAX = "{{linkcurrent}}";
Expand All @@ -27,6 +29,8 @@ export const FORMAT_SYNTAX: string[] = [
GLOBAL_VAR_SYNTAX,
VALUE_SYNTAX,
NAME_SYNTAX,
VALUE_CASE_SYNTAX,
VARIABLE_CASE_SYNTAX,
VARIABLE_SYNTAX,
VARIABLE_DEFAULT_SYNTAX,
VARIABLE_DEFAULT_OPTION_SYNTAX,
Expand Down Expand Up @@ -54,6 +58,8 @@ export const FILE_NAME_FORMAT_SYNTAX: string[] = [
GLOBAL_VAR_SYNTAX,
VALUE_SYNTAX,
NAME_SYNTAX,
VALUE_CASE_SYNTAX,
VARIABLE_CASE_SYNTAX,
VARIABLE_SYNTAX,
VARIABLE_DEFAULT_SYNTAX,
VARIABLE_DEFAULT_OPTION_SYNTAX,
Expand Down
142 changes: 142 additions & 0 deletions src/formatters/formatter-case.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it } from "vitest";
import { Formatter } from "./formatter";

class CaseTestFormatter extends Formatter {
private valueResponse = "";

constructor() {
super();
}

public setValueResponse(value: string): void {
this.valueResponse = value;
}

public setVariable(key: string, value: unknown): void {
this.variables.set(key, value);
}

protected async format(input: string): Promise<string> {
let output = input;
output = await this.replaceValueInString(output);
output = await this.replaceVariableInString(output);
return output;
}

protected promptForValue(): string {
return this.valueResponse;
}

protected getCurrentFileLink(): string | null {
return null;
}

protected getCurrentFileName(): string | null {
return null;
}

protected getVariableValue(variableName: string): string {
const value = this.variables.get(variableName);
return typeof value === "string" ? value : "";
}

protected suggestForValue(
_suggestedValues: string[],
_allowCustomInput?: boolean,
_context?: { placeholder?: string; variableKey?: string },
): string {
return "";
}

protected suggestForField(_variableName: string): Promise<string> {
return Promise.resolve("");
}

protected promptForMathValue(): Promise<string> {
return Promise.resolve("");
}

protected getMacroValue(
_macroName: string,
_context?: { label?: string },
): string {
return "";
}

protected promptForVariable(
_variableName: string,
_context?: {
type?: string;
dateFormat?: string;
defaultValue?: string;
label?: string;
description?: string;
placeholder?: string;
variableKey?: string;
},
): Promise<string> {
return Promise.resolve("");
}

protected getTemplateContent(_templatePath: string): Promise<string> {
return Promise.resolve("");
}

protected getSelectedText(): Promise<string> {
return Promise.resolve("");
}

protected getClipboardContent(): Promise<string> {
return Promise.resolve("");
}

protected isTemplatePropertyTypesEnabled(): boolean {
return false;
}

public async testFormat(input: string): Promise<string> {
return await this.format(input);
}
}

describe("Formatter case: pipe option", () => {
let formatter: CaseTestFormatter;

beforeEach(() => {
formatter = new CaseTestFormatter();
});

it("does not mutate stored named variables; each token applies its own case", async () => {
formatter.setVariable("title", "My New Blog");
const result = await formatter.testFormat(
"title={{VALUE:title}} slug={{VALUE:title|case:kebab}}",
);
expect(result).toBe("title=My New Blog slug=my-new-blog");
});

it("applies case per token for anonymous VALUE/NAME", async () => {
formatter.setValueResponse("My New Blog");
const result = await formatter.testFormat(
"a={{VALUE}} b={{VALUE|case:kebab}} c={{NAME|case:upper}}",
);
expect(result).toBe("a=My New Blog b=my-new-blog c=MY NEW BLOG");
});

it("supports snake/camel/pascal/title/lower transforms", async () => {
formatter.setValueResponse("my new blog");
const result = await formatter.testFormat(
"snake={{VALUE|case:snake}} camel={{VALUE|case:camel}} pascal={{VALUE|case:pascal}} title={{VALUE|case:title}} lower={{VALUE|case:lower}}",
);
expect(result).toBe(
"snake=my_new_blog camel=myNewBlog pascal=MyNewBlog title=My New Blog lower=my new blog",
);
});

it("ignores unknown case styles (pass-through)", async () => {
formatter.setVariable("title", "My New Blog");
const result = await formatter.testFormat(
"slug={{VALUE:title|case:does-not-exist}}",
);
expect(result).toBe("slug=My New Blog");
});
});
20 changes: 17 additions & 3 deletions src/formatters/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { log } from "../logger/logManager";
import { TemplatePropertyCollector } from "../utils/TemplatePropertyCollector";
import { settingsStore } from "../settingsStore";
import { normalizeDateInput } from "../utils/dateAliases";
import { transformCase } from "../utils/caseTransform";
import {
parseAnonymousValueOptions,
parseValueToken,
Expand Down Expand Up @@ -169,7 +170,14 @@ export abstract class Formatter {
// Replace all occurrences in a single non-recursive pass.
// Important: use a replacer function so `$` in user input is treated literally.
const regex = new RegExp(NAME_VALUE_REGEX.source, "gi");
output = output.replace(regex, () => this.value);
output = output.replace(regex, (token) => {
const inner = token.slice(2, -2);
const optionsIndex = inner.indexOf("|");
if (optionsIndex === -1) return this.value;
const rawOptions = inner.slice(optionsIndex);
const parsed = parseAnonymousValueOptions(rawOptions);
return transformCase(this.value, parsed.caseStyle);
});

return output;
}
Expand Down Expand Up @@ -319,6 +327,7 @@ export abstract class Formatter {
variableName,
variableKey,
label,
caseStyle,
defaultValue,
allowCustomInput,
suggestedValues,
Expand Down Expand Up @@ -366,19 +375,24 @@ export abstract class Formatter {

// Get the raw value from variables
const rawValue = this.variables.get(effectiveKey);
const rawValueForCollector =
caseStyle && typeof rawValue === "string"
? transformCase(rawValue, caseStyle)
: rawValue;

// Offer this variable to the property collector for YAML post-processing
this.propertyCollector.maybeCollect({
input: output,
matchStart: match.index,
matchEnd: match.index + match[0].length,
rawValue,
rawValue: rawValueForCollector,
fallbackKey: variableName,
featureEnabled: propertyTypesEnabled,
});

// Always use string replacement initially
const replacement = this.getVariableValue(effectiveKey);
const rawReplacement = this.getVariableValue(effectiveKey);
const replacement = transformCase(rawReplacement, caseStyle);

// Replace in output and adjust regex position
output = output.slice(0, match.index) + replacement + output.slice(match.index + match[0].length);
Expand Down
21 changes: 21 additions & 0 deletions src/preflight/RequirementCollector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,27 @@ describe("RequirementCollector", () => {
expect(byId[variableKey].type).toBe("dropdown");
});

it("does not treat case option as a legacy default for named VALUE", async () => {
const app = makeApp();
const plugin = makePlugin();
const rc = new RequirementCollector(app, plugin);
await rc.scanString("{{VALUE:title|case:kebab}}");

const requirement = rc.requirements.get("title");
expect(requirement?.defaultValue).toBeUndefined();
});

it("does not treat case option as a legacy default for unnamed VALUE", async () => {
const app = makeApp();
const plugin = makePlugin();
const rc = new RequirementCollector(app, plugin);
await rc.scanString("{{VALUE|case:kebab|label:Notes}}");

const requirement = rc.requirements.get("value");
expect(requirement?.description).toBe("Notes");
expect(requirement?.defaultValue).toBeUndefined();
});

it("collects VDATE with format and default", async () => {
const app = makeApp();
const plugin = makePlugin();
Expand Down
84 changes: 84 additions & 0 deletions src/utils/caseTransform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
export type CaseStyle =
| "kebab"
| "snake"
| "camel"
| "pascal"
| "title"
| "lower"
| "upper";

function normalizeStyle(style?: string): CaseStyle | null {
if (!style) return null;
const normalized = style.trim().toLowerCase();
switch (normalized) {
case "kebab":
case "snake":
case "camel":
case "pascal":
case "title":
case "lower":
case "upper":
return normalized;
default:
return null;
}
}

function upperFirstLowerRest(word: string): string {
if (!word) return word;
const lower = word.toLowerCase();
return lower.slice(0, 1).toUpperCase() + lower.slice(1);
}

function splitWords(input: string): string[] {
let s = input;

// Handle common camel/pascal boundaries (including acronyms like XMLHttp -> XML Http).
s = s.replace(/([\p{Lu}]+)([\p{Lu}][\p{Ll}])/gu, "$1 $2");
s = s.replace(/([\p{Ll}\p{N}])([\p{Lu}])/gu, "$1 $2");

// Split letter <-> number boundaries (Blog2Post -> Blog 2 Post).
s = s.replace(/([\p{L}])([\p{N}])/gu, "$1 $2");
s = s.replace(/([\p{N}])([\p{L}])/gu, "$1 $2");

// Treat any non-letter/non-number as separators. Keep combining marks with letters.
s = s.replace(/[^\p{L}\p{N}\p{M}]+/gu, " ");

return s
.trim()
.split(/\s+/u)
.map((w) => w.trim())
.filter(Boolean);
}

export function transformCase(input: string, style?: string): string {
const normalized = normalizeStyle(style);
if (!normalized) return input;

if (normalized === "lower") return input.toLowerCase();
if (normalized === "upper") return input.toUpperCase();

const words = splitWords(input);
if (words.length === 0) return "";

switch (normalized) {
case "kebab":
return words.map((w) => w.toLowerCase()).join("-");
case "snake":
return words.map((w) => w.toLowerCase()).join("_");
case "camel": {
const [first, ...rest] = words;
return [
first?.toLowerCase() ?? "",
...rest.map((w) => upperFirstLowerRest(w)),
].join("");
}
case "pascal":
return words.map((w) => upperFirstLowerRest(w)).join("");
case "title":
return words.map((w) => upperFirstLowerRest(w)).join(" ");
default:
return input;
}
}

20 changes: 20 additions & 0 deletions src/utils/valueSyntax.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ describe("parseValueToken", () => {
expect(parsed?.defaultValue).toBe("label");
});

it("parses case style without treating it as legacy default", () => {
const parsed = parseValueToken("title|case:kebab");
expect(parsed?.caseStyle).toBe("kebab");
expect(parsed?.defaultValue).toBe("");
});

it("parses title case style", () => {
const parsed = parseValueToken("title|case:title");
expect(parsed?.caseStyle).toBe("title");
});

it("parses custom boolean values", () => {
expect(parseValueToken("a,b|custom:")?.allowCustomInput).toBe(true);
expect(parseValueToken("a,b|custom:false")?.allowCustomInput).toBe(false);
Expand Down Expand Up @@ -93,6 +104,15 @@ describe("parseAnonymousValueOptions", () => {
expect(parsed.defaultValue).toBe("Hello");
});

it("parses case style for unnamed VALUE tokens", () => {
const parsed = parseAnonymousValueOptions(
"|case:kebab|label:Notes|default:Hello",
);
expect(parsed.caseStyle).toBe("kebab");
expect(parsed.label).toBe("Notes");
expect(parsed.defaultValue).toBe("Hello");
});

it("warns and ignores unknown type for unnamed VALUE tokens", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const parsed = parseAnonymousValueOptions("|type:wide");
Expand Down
Loading