From cd9d16def62032a7fd12fd50d02280d2a334e29b Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 9 Oct 2026 16:31:17 -0400 Subject: [PATCH 1/2] ENG-2323 Reject unsupported placeholders in Obsidian node formats The Format field now blocks formats with placeholders other than {content}, using the same placeholder rule as decorateTitle. Also removes the unused apps/obsidian/src/utils/validateNodeFormat.ts. Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M4H5SWF8GGWS6A6NNMTMCZ0Y --- .../utils/__tests__/validateNodeType.test.ts | 48 +++++++++++++++++++ apps/obsidian/src/utils/validateNodeFormat.ts | 30 ------------ apps/obsidian/src/utils/validateNodeType.ts | 19 ++++++++ 3 files changed, 67 insertions(+), 30 deletions(-) delete mode 100644 apps/obsidian/src/utils/validateNodeFormat.ts diff --git a/apps/obsidian/src/utils/__tests__/validateNodeType.test.ts b/apps/obsidian/src/utils/__tests__/validateNodeType.test.ts index e85e90334c..c4f73f4348 100644 --- a/apps/obsidian/src/utils/__tests__/validateNodeType.test.ts +++ b/apps/obsidian/src/utils/__tests__/validateNodeType.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from "vitest"; import { decorateTitle } from "@repo/database/lib/decorateTitle"; +import type { DiscourseNode } from "~/types"; import { checkInvalidChars, normalizeImportedNodeFormat, + validateNodeFormat, } from "~/utils/validateNodeType"; describe("normalizeImportedNodeFormat", () => { @@ -55,3 +57,49 @@ describe("normalizeImportedNodeFormat", () => { expect(normalizeImportedNodeFormat(format)).toBe(format); }); }); + +describe("validateNodeFormat", () => { + const currentNode: DiscourseNode = { + id: "node-1", + name: "Evidence", + format: "", + created: 0, + modified: 0, + }; + const validate = (format: string) => + validateNodeFormat({ format, currentNode, allNodes: [currentNode] }); + + it.each([ + [ + "EVD - {content} - {Source}", + "Format contains unsupported placeholder: {Source}. Only {content} is supported.", + ], + [ + "{Author}: {content}", + "Format contains unsupported placeholder: {Author}. Only {content} is supported.", + ], + [ + "{Source} - {content} - {Author} - {Source}", + "Format contains unsupported placeholders: {Source}, {Author}. Only {content} is supported.", + ], + ])("rejects the unsupported placeholders in %s", (format, error) => { + expect(validate(format)).toEqual({ isValid: false, error }); + }); + + it.each(["CLM - {content}", "{content}", "@{content}"])( + "accepts the content-only format %s", + (format) => { + expect(validate(format)).toEqual({ isValid: true }); + }, + ); + + it.each(["CLM - title", "EVD - {Source}"])( + "reports the missing {content} for %s", + (format) => { + expect(validate(format)).toEqual({ + isValid: false, + error: 'Format must include the placeholder "{content}"', + }); + }, + ); +}); diff --git a/apps/obsidian/src/utils/validateNodeFormat.ts b/apps/obsidian/src/utils/validateNodeFormat.ts deleted file mode 100644 index 5e03281b89..0000000000 --- a/apps/obsidian/src/utils/validateNodeFormat.ts +++ /dev/null @@ -1,30 +0,0 @@ -export const validateNodeFormat = ( - format: string, -): { - isValid: boolean; - error?: string; -} => { - if (!format) { - return { - isValid: false, - error: "Format cannot be empty", - }; - } - - if (format.includes("[[") || format.includes("]]")) { - return { - isValid: false, - error: "Format should not contain double brackets [[ or ]]", - }; - } - - const hasVariable = /{[a-zA-Z]+}/.test(format); - if (!hasVariable) { - return { - isValid: false, - error: "Format must contain at least one variable in {varName} format", - }; - } - - return { isValid: true }; -}; diff --git a/apps/obsidian/src/utils/validateNodeType.ts b/apps/obsidian/src/utils/validateNodeType.ts index f577e7125a..da98c8a32a 100644 --- a/apps/obsidian/src/utils/validateNodeType.ts +++ b/apps/obsidian/src/utils/validateNodeType.ts @@ -1,3 +1,7 @@ +import { + CONTENT_PLACEHOLDER, + FORMAT_PLACEHOLDER, +} from "@repo/database/lib/decorateTitle"; import { DiscourseNode } from "~/types"; type ValidationResult = { @@ -35,6 +39,21 @@ export const validateNodeFormat = ({ }; } + // Same placeholder rule as decorateTitle, which can't build titles from other placeholders. + const unsupportedPlaceholders = [ + ...new Set( + (format.match(FORMAT_PLACEHOLDER) ?? []).filter( + (placeholder) => placeholder.toLowerCase() !== CONTENT_PLACEHOLDER, + ), + ), + ]; + if (unsupportedPlaceholders.length > 0) { + return { + isValid: false, + error: `Format contains unsupported placeholder${unsupportedPlaceholders.length > 1 ? "s" : ""}: ${unsupportedPlaceholders.join(", ")}. Only ${CONTENT_PLACEHOLDER} is supported.`, + }; + } + const invalidCharsResult = checkInvalidChars(format); if (!invalidCharsResult.isValid) { return invalidCharsResult; From 29191daf296e7bd0bd9291196046739fff274dd9 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 9 Oct 2026 16:41:07 -0400 Subject: [PATCH 2/2] ENG-2323 Add return type to validateNodeFormat test helper Co-Authored-By: Claude Opus 5.5 Entire-Checkpoint: 01M4H6BVH4WB0P9JDH4MNKJ9F4 --- apps/obsidian/src/utils/__tests__/validateNodeType.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/obsidian/src/utils/__tests__/validateNodeType.test.ts b/apps/obsidian/src/utils/__tests__/validateNodeType.test.ts index c4f73f4348..5c1e9f7145 100644 --- a/apps/obsidian/src/utils/__tests__/validateNodeType.test.ts +++ b/apps/obsidian/src/utils/__tests__/validateNodeType.test.ts @@ -66,7 +66,7 @@ describe("validateNodeFormat", () => { created: 0, modified: 0, }; - const validate = (format: string) => + const validate = (format: string): ReturnType => validateNodeFormat({ format, currentNode, allNodes: [currentNode] }); it.each([