Skip to content

Commit 9439b7a

Browse files
Fix curly quotes in remark-capitalize-titles (#34)
* fix curly quotes and hypenated compounds * fix lint issues
1 parent e4ad172 commit 9439b7a

6 files changed

Lines changed: 228 additions & 6 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@fujocoded/remark-capitalize-titles": patch
3+
---
4+
5+
Capitalize words correctly when a heading contains curly quotes (as produced by
6+
`remark-smartypants`). Previously, the upstream `title` library only recognized
7+
straight quotes as punctuation, so a word following a curly `` or `` would
8+
stay lowercase. The plugin now converts curly quotes to straight quotes before
9+
title-casing, then restores the original curly characters in the output.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@fujocoded/remark-capitalize-titles": minor
3+
---
4+
5+
Lowercase the second-and-later segments of a hyphenated compound during title
6+
casing, so output follows AP-style ("Three-way Merges", "Pre-commit Hooks",
7+
"Up-to-date") instead of capitalizing every segment ("Three-Way", "Pre-Commit",
8+
"Up-To-Date"). A segment is kept capitalized when either the full compound or
9+
the individual segment is listed in `special`.
10+
11+
This is a breaking change for callers that expected every segment of a
12+
hyphenated word to be capitalized.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
"lint:fix": "oxlint --type-aware --fix",
1616
"lint:ci": "oxlint --type-aware --deny-warnings",
1717
"format": "oxfmt && prettier --write \"**/*.astro\"",
18-
"format:check": "oxfmt --check && prettier --check \"**/*.astro\""
18+
"format:check": "oxfmt --check && prettier --check \"**/*.astro\"",
19+
"check": "npm run format:check && npm run lint:ci && npm run sherif && npm run typecheck && npm run build",
20+
"fix": "npm run format && npm run lint:fix"
1921
},
2022
"devDependencies": {
2123
"@changesets/cli": "^2.29.8",

remark-capitalize-titles/index.ts

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,59 @@ import type { MdxJsxFlowElement } from "mdast-util-mdx-jsx";
77

88
import { DEFAULT_CAPITALIZATIONS as DEFAULT_CAPITALIZATIONS_ } from "./capitalizations.ts";
99

10+
// Astro's MDX integration runs remark-smartypants before user plugins, so
11+
// straight quotes arrive here as curly. The `title` library's regex only lists
12+
// straight quotes as punctuation, so curly quotes would otherwise prevent the
13+
// next word from being capitalized.
14+
const CURLY_TO_STRAIGHT: Record<string, string> = {
15+
"\u201C": '"',
16+
"\u201D": '"',
17+
"\u2018": "'",
18+
"\u2019": "'",
19+
};
20+
const CURLY_QUOTE_REGEX = /[\u201C\u201D\u2018\u2019]/g;
21+
22+
// Matches a hyphenated compound like "Three-Way" or "Up-To-Date" so the
23+
// second-and-later segments can be lowercased (AP-style: "Three-way").
24+
const HYPHENATED_COMPOUND_REGEX = /[A-Za-z][A-Za-z']*(?:-[A-Za-z][A-Za-z']*)+/g;
25+
26+
const lowercaseHyphenatedTails = (text: string, special: string[]) =>
27+
text.replace(HYPHENATED_COMPOUND_REGEX, (match) => {
28+
if (special.includes(match)) return match;
29+
const parts = match.split("-");
30+
return parts
31+
.map((part, index) => {
32+
if (index === 0) return part;
33+
if (special.includes(part)) return part;
34+
return part.charAt(0).toLowerCase() + part.slice(1);
35+
})
36+
.join("-");
37+
});
38+
1039
const title = (...params: Parameters<typeof libraryTitle>) => {
1140
const [text, options] = params;
12-
const textChunks = text.split(")");
13-
const intermediateTitle = textChunks
14-
.map((title) => libraryTitle(title, options))
41+
const curlyPositions: Array<[number, string]> = [];
42+
const normalized = text.replace(
43+
CURLY_QUOTE_REGEX,
44+
(match, offset: number) => {
45+
curlyPositions.push([offset, match]);
46+
return CURLY_TO_STRAIGHT[match] ?? match;
47+
},
48+
);
49+
const textChunks = normalized.split(")");
50+
const titleCased = textChunks
51+
.map((chunk) => libraryTitle(chunk, options))
1552
.join(")");
16-
return intermediateTitle;
53+
const intermediateTitle = lowercaseHyphenatedTails(
54+
titleCased,
55+
options?.special ?? [],
56+
);
57+
if (curlyPositions.length === 0) return intermediateTitle;
58+
const chars = intermediateTitle.split("");
59+
for (const [offset, original] of curlyPositions) {
60+
chars[offset] = original;
61+
}
62+
return chars.join("");
1763
};
1864

1965
type PluginArgs = { special: string[]; componentNames: string[] };

remark-capitalize-titles/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"scripts": {
3939
"build": "tsup index.ts --format cjs,esm --dts --clean",
4040
"validate": " npx publint",
41+
"test": "vitest",
4142
"typecheck": "tsc --noEmit"
4243
},
4344
"dependencies": {
@@ -51,6 +52,7 @@
5152
"remark": "^15.0.1",
5253
"tsup": "^8.1.0",
5354
"typescript": "^5.5.2",
54-
"unified": "^11.0.4"
55+
"unified": "^11.0.4",
56+
"vitest": "^3.0.5"
5557
}
5658
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { describe, expect, test } from "vitest";
2+
import { remark } from "remark";
3+
import type { Compatible } from "vfile";
4+
import remarkCapitalizeTitles from "../index.ts";
5+
6+
const processMarkdown = async (value: Compatible) => {
7+
const file = await remark().use(remarkCapitalizeTitles).process(value);
8+
return file.toString().slice(0, -1);
9+
};
10+
11+
describe("Handles the basics", () => {
12+
test("title-cases a simple heading", async () => {
13+
expect(
14+
await processMarkdown("# cloning: not just for mad scientists"),
15+
).toBe("# Cloning: Not Just for Mad Scientists");
16+
});
17+
18+
test("leaves non-heading text untouched", async () => {
19+
expect(
20+
await processMarkdown(
21+
"git mindwiped: fully discarding changes\n\n# git mindwiped: fully discarding changes",
22+
),
23+
).toBe(
24+
"git mindwiped: fully discarding changes\n\n# Git Mindwiped: Fully Discarding Changes",
25+
);
26+
});
27+
});
28+
29+
describe("Preserves special cases", () => {
30+
test("preserves GitHub, FujoCoded, LLC, and NPM", async () => {
31+
expect(
32+
await processMarkdown(
33+
"## merging with github's interface: pull requests",
34+
),
35+
).toBe("## Merging with GitHub's Interface: Pull Requests");
36+
expect(await processMarkdown("### an intro to fujocoded llc")).toBe(
37+
"### An Intro to FujoCoded LLC",
38+
);
39+
expect(await processMarkdown("## next up: building with npm")).toBe(
40+
"## Next Up: Building with NPM",
41+
);
42+
});
43+
44+
test("preserves SHA", async () => {
45+
expect(await processMarkdown("#### sha: your commit's unique name")).toBe(
46+
"#### SHA: Your Commit's Unique Name",
47+
);
48+
});
49+
50+
test("preserves TL;DR", async () => {
51+
expect(await processMarkdown("# tl;dr: why this matters")).toBe(
52+
"# TL;DR: Why This Matters",
53+
);
54+
});
55+
});
56+
57+
describe("Respects tricky punctuations", () => {
58+
test("handles curly quotes (as produced by smartypants)", async () => {
59+
expect(
60+
await processMarkdown(
61+
"#### “answer me, darling~”: git & github’s connection check!",
62+
),
63+
).toBe("#### “Answer Me, Darling~”: Git & GitHub’s Connection Check!");
64+
});
65+
66+
test("handles apostrophes inside a word", async () => {
67+
expect(
68+
await processMarkdown("## git'ing good: more commit scenarios"),
69+
).toBe("## Git'ing Good: More Commit Scenarios");
70+
});
71+
72+
test("handles a trailing question mark with an inner apostrophe", async () => {
73+
expect(await processMarkdown("## i'm ready to practice, now what?")).toBe(
74+
"## I'm Ready to Practice, Now What?",
75+
);
76+
});
77+
78+
test("handles repeated punctuation (???)", async () => {
79+
expect(await processMarkdown("### step ???: git advanced")).toBe(
80+
"### Step ???: Git Advanced",
81+
);
82+
});
83+
84+
test("handles a leading ellipsis", async () => {
85+
expect(await processMarkdown("### ...and more!")).toBe("### ...and More!");
86+
});
87+
88+
test("handles parenthesized possessives", async () => {
89+
expect(
90+
await processMarkdown("### traveling through (your code's) history"),
91+
).toBe("### Traveling Through (Your Code's) History");
92+
});
93+
});
94+
95+
describe("Handles inline code spans", () => {
96+
test("handles an inline code span inside a heading", async () => {
97+
expect(
98+
await processMarkdown(
99+
"### the flavors of `git reset`: soft, hard, or mixed",
100+
),
101+
).toBe("### The Flavors of `git reset`: Soft, Hard, or Mixed");
102+
});
103+
104+
test("handles multiple inline code spans with separators", async () => {
105+
expect(await processMarkdown("## git & github's `push`/`pull` dance")).toBe(
106+
"## Git & GitHub's `push`/`pull` Dance",
107+
);
108+
});
109+
110+
test("handles punctuation immediately following an inline code span", async () => {
111+
expect(
112+
await processMarkdown("## multiverse collapse: prepare to `merge`!"),
113+
).toBe("## Multiverse Collapse: Prepare to `merge`!");
114+
});
115+
116+
test("handles a comma-separated list of inline code spans with a hyphenated term", async () => {
117+
expect(
118+
await processMarkdown(
119+
"#### the jokes write themselves: `ours`, `theirs`, and three-way merges",
120+
),
121+
).toBe(
122+
"#### The Jokes Write Themselves: `ours`, `theirs`, and Three-way Merges",
123+
);
124+
});
125+
});
126+
127+
describe("Handles hyphenated compound words", () => {
128+
test("keeps the second word lowercase in a hyphenated name with a possessive", async () => {
129+
expect(
130+
await processMarkdown("### our toy project: boba-tan's sexyman shrine"),
131+
).toBe("### Our Toy Project: Boba-tan's Sexyman Shrine");
132+
});
133+
134+
test("handles hyphenated words alongside a slash separator", async () => {
135+
expect(await processMarkdown("## push/pull: git's memory-sync dance")).toBe(
136+
"## Push/pull: Git's Memory-sync Dance",
137+
);
138+
});
139+
140+
test("handles common hyphenated prefixes", async () => {
141+
expect(
142+
await processMarkdown("### pre-commit hooks for post-merge cleanups"),
143+
).toBe("### Pre-commit Hooks for Post-merge Cleanups");
144+
});
145+
146+
test("handles hyphenated phrases with small words inside", async () => {
147+
expect(await processMarkdown("## up-to-date and ready-to-merge")).toBe(
148+
"## Up-to-date and Ready-to-merge",
149+
);
150+
});
151+
});

0 commit comments

Comments
 (0)