Skip to content

feat: create markdown rules in notch #5945

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jun 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
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
157 changes: 157 additions & 0 deletions core/config/markdown/createMarkdownRule.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import {
createMarkdownWithFrontmatter,
createRuleFilePath,
createRuleMarkdown,
sanitizeRuleName,
} from "./createMarkdownRule";
import { parseMarkdownRule } from "./parseMarkdownRule";

describe("sanitizeRuleName", () => {
it("should sanitize rule names for filenames", () => {
expect(sanitizeRuleName("My Test Rule")).toBe("my-test-rule");
expect(sanitizeRuleName("Rule with @#$% chars")).toBe("rule-with-chars");
expect(sanitizeRuleName("Multiple spaces")).toBe("multiple-spaces");
expect(sanitizeRuleName("UPPERCASE-rule")).toBe("uppercase-rule");
expect(sanitizeRuleName("already-sanitized")).toBe("already-sanitized");
});

it("should handle empty and edge case inputs", () => {
expect(sanitizeRuleName("")).toBe("");
expect(sanitizeRuleName(" ")).toBe("");
expect(sanitizeRuleName("123")).toBe("123");
expect(sanitizeRuleName("rule-with-numbers-123")).toBe(
"rule-with-numbers-123",
);
});
});

describe("createRuleFilePath", () => {
it("should create correct rule file path", () => {
const result = createRuleFilePath("/workspace", "My Test Rule");
expect(result).toBe("/workspace/.continue/rules/my-test-rule.md");
});

it("should handle special characters in rule name", () => {
const result = createRuleFilePath("/home/user", "Rule with @#$% chars");
expect(result).toBe("/home/user/.continue/rules/rule-with-chars.md");
});

it("should handle edge case rule names", () => {
const result = createRuleFilePath("/test", " Multiple Spaces ");
expect(result).toBe("/test/.continue/rules/multiple-spaces.md");
});
});

describe("createMarkdownWithFrontmatter", () => {
it("should create properly formatted markdown with frontmatter", () => {
const frontmatter = {
name: "Test Rule",
description: "A test rule",
globs: "*.ts",
};
const markdown = "# Test Rule\n\nThis is a test rule.";

const result = createMarkdownWithFrontmatter(frontmatter, markdown);

// The exact quote style doesn't matter as long as it parses correctly
expect(result).toContain("name: Test Rule");
expect(result).toContain("description: A test rule");
expect(result).toContain("globs:");
expect(result).toContain("*.ts");
expect(result).toContain("---\n\n# Test Rule\n\nThis is a test rule.");
});

it("should handle empty frontmatter", () => {
const frontmatter = {};
const markdown = "# Simple Rule\n\nJust markdown content.";

const result = createMarkdownWithFrontmatter(frontmatter, markdown);

const expected = `---
{}
---

# Simple Rule

Just markdown content.`;

expect(result).toBe(expected);
});

it("should create content that can be parsed back correctly", () => {
const originalFrontmatter = {
name: "Roundtrip Test",
description: "Testing roundtrip parsing",
globs: ["*.js", "*.ts"],
alwaysApply: true,
};
const originalMarkdown =
"# Roundtrip Test\n\nThis should parse back correctly.";

const created = createMarkdownWithFrontmatter(
originalFrontmatter,
originalMarkdown,
);
const parsed = parseMarkdownRule(created);

expect(parsed.frontmatter).toEqual(originalFrontmatter);
expect(parsed.markdown).toBe(originalMarkdown);
});
});

describe("createRuleMarkdown", () => {
it("should create rule markdown with all options", () => {
const result = createRuleMarkdown("Test Rule", "This is the rule content", {
description: "Test description",
globs: ["*.ts", "*.js"],
alwaysApply: true,
});

const parsed = parseMarkdownRule(result);

expect(parsed.frontmatter.description).toBe("Test description");
expect(parsed.frontmatter.globs).toEqual(["*.ts", "*.js"]);
expect(parsed.frontmatter.alwaysApply).toBe(true);
expect(parsed.markdown).toBe("# Test Rule\n\nThis is the rule content");
});

it("should create rule markdown with minimal options", () => {
const result = createRuleMarkdown("Simple Rule", "Simple content");

const parsed = parseMarkdownRule(result);

expect(parsed.frontmatter.description).toBeUndefined();
expect(parsed.frontmatter.globs).toBeUndefined();
expect(parsed.frontmatter.alwaysApply).toBeUndefined();
expect(parsed.markdown).toBe("# Simple Rule\n\nSimple content");
});

it("should handle string globs", () => {
const result = createRuleMarkdown("String Glob Rule", "Content", {
globs: "*.py",
});

const parsed = parseMarkdownRule(result);
expect(parsed.frontmatter.globs).toBe("*.py");
});

it("should trim description and globs", () => {
const result = createRuleMarkdown("Trim Test", "Content", {
description: " spaced description ",
globs: " *.ts ",
});

const parsed = parseMarkdownRule(result);
expect(parsed.frontmatter.description).toBe("spaced description");
expect(parsed.frontmatter.globs).toBe("*.ts");
});

it("should handle alwaysApply false explicitly", () => {
const result = createRuleMarkdown("Always Apply False", "Content", {
alwaysApply: false,
});

const parsed = parseMarkdownRule(result);
expect(parsed.frontmatter.alwaysApply).toBe(false);
});
});
75 changes: 75 additions & 0 deletions core/config/markdown/createMarkdownRule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import * as YAML from "yaml";
import { joinPathsToUri } from "../../util/uri";
import { RuleFrontmatter } from "./parseMarkdownRule";

export const RULE_FILE_EXTENSION = "md";

/**
* Sanitizes a rule name for use in filenames (removes special chars, replaces spaces with dashes)
*/
export function sanitizeRuleName(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/^-+|-+$/g, ""); // Remove leading/trailing dashes
}

/**
* Creates the file path for a rule in the workspace .continue/rules directory
*/
export function createRuleFilePath(
workspaceDir: string,
ruleName: string,
): string {
const safeRuleName = sanitizeRuleName(ruleName);
return joinPathsToUri(
workspaceDir,
".continue",
"rules",
`${safeRuleName}.${RULE_FILE_EXTENSION}`,
);
}

/**
* Creates markdown content with YAML frontmatter in the format expected by parseMarkdownRule
*/
export function createMarkdownWithFrontmatter(
frontmatter: RuleFrontmatter,
markdown: string,
): string {
const frontmatterStr = YAML.stringify(frontmatter).trim();
return `---\n${frontmatterStr}\n---\n\n${markdown}`;
}

/**
* Creates a rule markdown file content from rule components
*/
export function createRuleMarkdown(
name: string,
ruleContent: string,
options: {
description?: string;
globs?: string | string[];
alwaysApply?: boolean;
} = {},
): string {
const frontmatter: RuleFrontmatter = {};

if (options.globs) {
frontmatter.globs =
typeof options.globs === "string" ? options.globs.trim() : options.globs;
}

if (options.description) {
frontmatter.description = options.description.trim();
}

if (options.alwaysApply !== undefined) {
frontmatter.alwaysApply = options.alwaysApply;
}

const markdownBody = `# ${name}\n\n${ruleContent}`;

return createMarkdownWithFrontmatter(frontmatter, markdownBody);
}
3 changes: 3 additions & 0 deletions core/config/markdown/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./createMarkdownRule";
export * from "./loadMarkdownRules";
export * from "./parseMarkdownRule";
Loading
Loading