Skip to content

Commit e0c0ffd

Browse files
essential-randomnessMs Boba
authored andcommitted
Unify handling of component title
1 parent 017e141 commit e0c0ffd

9 files changed

Lines changed: 277 additions & 46 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@fujocoded/remark-capitalize-titles": patch
3+
---
4+
5+
Export `capitalizeTitle` for title-casing a standalone string outside a Markdown
6+
tree: pass the title, an optional `{ special }` override, and have your Markdown
7+
string capitalized while preserving inline code, emphasis, and escapes.
8+
9+
Component `title` props now run through this same Markdown path, so inline
10+
code spans inside a title keep their exact casing instead of being lowercased.
11+
12+
Also extends the default capitalization list with `JavaScript`, `HTML`, and
13+
`CSS`.

remark-capitalize-titles/LICENSE

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,31 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121
SOFTWARE.
22+
23+
---
24+
25+
This software includes portions derived from the following MIT-licensed
26+
projects. Their copyright notices are reproduced here as required by the MIT
27+
License:
28+
29+
vercel/title (https://github.com/vercel/title)
30+
31+
Copyright (c) 2022 Vercel, Inc.
32+
33+
Permission is hereby granted, free of charge, to any person obtaining a copy
34+
of this software and associated documentation files (the "Software"), to deal
35+
in the Software without restriction, including without limitation the rights
36+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
37+
copies of the Software, and to permit persons to whom the Software is
38+
furnished to do so, subject to the following conditions:
39+
40+
The above copyright notice and this permission notice shall be included in all
41+
copies or substantial portions of the Software.
42+
43+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
44+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
45+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
46+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
47+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
48+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
49+
SOFTWARE.

remark-capitalize-titles/README.md

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,9 @@ capitalizeTitle("merging with github via npm");
4747
// => "Merging with GitHub via NPM"
4848

4949
// Override the capitalization exceptions:
50-
capitalizeTitle("my title", { special: ["MyBrand"] });
50+
capitalizeTitle("my title", { special: ["MyPersonalNitpick"] });
5151
```
5252

53-
The default exception list is exported as `DEFAULT_CAPITALIZATIONS`.
54-
55-
### TODOs:
56-
57-
- [ ] Fix issue where a title in a heading that has a previous sibling will be capitalized as if the first
58-
word was the beginning of the sentence
59-
- [ ] Something like "`head`ing" in titles where the word is compounded with a code thing will capitalize "ing".
53+
The default exception list is exported as `DEFAULT_CAPITALIZATIONS`, and
54+
overridden if special is passed as a parameter. If the change is meant to be
55+
additive, then import `DEFAULT_CAPITALIZATIONS` and extend it instead.

remark-capitalize-titles/index.ts

Lines changed: 46 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,46 @@
11
import { visit } from "unist-util-visit";
2+
import { fromMarkdown } from "mdast-util-from-markdown";
23
import type { Plugin } from "unified";
34

45
import type mdast from "mdast";
56
import type { MdxJsxFlowElement } from "mdast-util-mdx-jsx";
67

78
import { DEFAULT_CAPITALIZATIONS } from "./capitalizations.ts";
8-
import { titleCase } from "./title-case.ts";
9+
import { collectTitleItemsFromChildren, titleCase } from "./title-case.ts";
910

10-
// Matches a Markdown inline code span (backtick-wrapped) so titles can be
11-
// split around code spans and left untouched.
12-
const CODE_REGEX = /(`[a-z0-9_\-\s]+`)/gi;
13-
14-
// Title-case a standalone string (treated as a whole title), leaving inline
15-
// code spans untouched. The plugin uses this for frontmatter and component
16-
// title props; it's exported for callers who want title-casing outside a
17-
// Markdown tree.
11+
// Title-case a standalone string (treated as a whole markdown title), handling
12+
// inline code spans, emphasis, escapes, etc. The plugin uses this for
13+
// frontmatter and component title props; it's exported for callers who want
14+
// title-casing outside a remark plugin.
1815
export const capitalizeTitle = (
1916
title: string,
2017
{ special = DEFAULT_CAPITALIZATIONS }: { special?: string[] } = {},
2118
): string => {
22-
const parts = title.split(new RegExp(CODE_REGEX));
23-
return parts
24-
.map((part, idx) => {
25-
if (part.startsWith("`") && part.endsWith("`")) return part;
26-
return titleCase(part, {
27-
special,
28-
isFirstTextNode: idx === 0,
29-
isLastTextNode: idx === parts.length - 1,
30-
});
31-
})
32-
.join("");
19+
const tree = fromMarkdown(title);
20+
const items = collectTitleItemsFromChildren(tree.children);
21+
22+
// Re-case the original source bytes (via each text node's position offsets),
23+
// NOT node.value: value is unescaped (`a \* b` => `a * b`) while its position
24+
// spans the *escaped* source, so splicing value back would drop escapes and
25+
// shift later offsets. Since titleCase only flips letters and copies all other
26+
// characters through, escapes, `_`/`*` delimiters, and exact spacing survives.
27+
let result = "";
28+
let cursor = 0;
29+
items.forEach((item, i) => {
30+
if (item.type !== "text") return;
31+
const start = item.node.position!.start.offset!;
32+
const end = item.node.position!.end.offset!;
33+
result += title.slice(cursor, start);
34+
result += titleCase(title.slice(start, end), {
35+
special,
36+
isFirstTextNode: i === 0,
37+
isLastTextNode: i === items.length - 1,
38+
firstWordIsContinuation: item.firstWordIsContinuation,
39+
});
40+
cursor = end;
41+
});
42+
result += title.slice(cursor);
43+
return result;
3344
};
3445

3546
type AstroFrontmatterData = {
@@ -49,7 +60,7 @@ const plugin: Plugin<[PluginOptions?], mdast.Root> =
4960
special = DEFAULT_CAPITALIZATIONS,
5061
componentNames = [],
5162
frontmatterTitle = true,
52-
}: PluginOptions = {}) =>
63+
} = {}) =>
5364
(tree, file) => {
5465
// If frontmatterTitle is true, it will also format the title in the
5566
// frontmatter, but only if Astro is exposing it.
@@ -61,29 +72,30 @@ const plugin: Plugin<[PluginOptions?], mdast.Root> =
6172
}
6273
}
6374

64-
// Pass 1: every heading. A heading's text can be split across multiple text
65-
// nodes (e.g. emphasis, links, inline code), so we collect them first to
66-
// find out which is the first/last text node.
75+
// Pass 1: every heading. A heading's text can be split across multiple
76+
// phrasing nodes (emphasis, links, inline code), so we flatten them into
77+
// ordered title items first — that tells us which text node is first/last
78+
// and which ones continue a preceding code span ("`head`ing"). Headings
79+
// mutate text nodes in place; the serializer re-emits them (and normalizes
80+
// the body, e.g. `_em_` → `*em*`, which is the expected heading behavior).
6781
visit(tree, "heading", (node) => {
68-
// Get every text node for this heading
69-
const textNodes: { value?: string }[] = [];
70-
visit(node, "text", (textNode) => {
71-
textNodes.push(textNode);
72-
});
73-
// Lowercase each node, but be mindful of which one is first/last
74-
textNodes.forEach((textNode, i) => {
75-
textNode.value = titleCase(textNode.value ?? "", {
82+
const items = collectTitleItemsFromChildren(node.children);
83+
items.forEach((item, i) => {
84+
if (item.type !== "text") return;
85+
item.node.value = titleCase(item.node.value, {
7686
special,
7787
isFirstTextNode: i === 0,
78-
isLastTextNode: i === textNodes.length - 1,
88+
isLastTextNode: i === items.length - 1,
89+
firstWordIsContinuation: item.firstWordIsContinuation,
7990
});
8091
});
8192
});
8293
if (componentNames.length === 0) {
8394
return;
8495
}
8596
// Pass 2: title props of the named MDX components. Their value is one raw
86-
// string, so capitalizeTitle handles code-span splitting itself.
97+
// string, so capitalizeTitle parses and re-cases it (code spans, emphasis,
98+
// escapes and all) on its own.
8799
visit(
88100
tree,
89101
(node): node is MdxJsxFlowElement => {

remark-capitalize-titles/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,14 @@
4242
"typecheck": "tsc --noEmit"
4343
},
4444
"dependencies": {
45+
"mdast-util-from-markdown": "^2.0.2",
4546
"unist-util-visit": "^5.0.0"
4647
},
4748
"devDependencies": {
4849
"mdast": "^3.0.0",
4950
"mdast-util-mdx-jsx": "^3.1.2",
5051
"remark": "^15.0.1",
52+
"remark-mdx": "^3.1.1",
5153
"tsup": "^8.1.0",
5254
"typescript": "^5.5.2",
5355
"unified": "^11.0.4",

remark-capitalize-titles/tests/capitalize-title.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,34 @@ describe("capitalizeTitle as standalone function export", () => {
3131
"The Flavors of `git reset`: Soft or Hard",
3232
);
3333
});
34+
35+
test("treats a suffix attached to a code span as a continuation", () => {
36+
expect(capitalizeTitle("the `head`ing story")).toBe(
37+
"The `head`ing Story",
38+
);
39+
});
40+
41+
test("does not treat text after a leading code span as the title start", () => {
42+
expect(capitalizeTitle("`foo` and bar")).toBe("`foo` and Bar");
43+
});
44+
45+
// The string path re-cases the original source bytes, so emphasis delimiters
46+
// and backslash escapes are preserved verbatim (no `_em_` → `*em*`).
47+
test("preserves emphasis delimiters and escapes without normalizing", () => {
48+
expect(capitalizeTitle("a _really_ big \\* moment")).toBe(
49+
"A _Really_ Big \\* Moment",
50+
);
51+
});
52+
53+
// A title can start with block-markdown punctuation; fromMarkdown parses it
54+
// as a heading/list/blockquote, but the enclosing block is transparent, so
55+
// the leading marker is preserved and the inline text (including code spans)
56+
// is cased exactly as in a plain title.
57+
test("preserves a leading heading marker and respects code spans", () => {
58+
expect(capitalizeTitle("# the `git` thing")).toBe("# The `git` Thing");
59+
});
60+
61+
test("preserves a leading blockquote marker", () => {
62+
expect(capitalizeTitle("> push and pull")).toBe("> Push and Pull");
63+
});
3464
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, test } from "vitest";
2+
import { remark } from "remark";
3+
import remarkMdx from "remark-mdx";
4+
import remarkCapitalizeTitles from "../index.ts";
5+
6+
const processComponent = async (
7+
value: string,
8+
componentNames: string[] = ["Callout"],
9+
) => {
10+
const file = await remark()
11+
.use(remarkMdx)
12+
.use(remarkCapitalizeTitles, { componentNames })
13+
.process(value);
14+
return file.toString().slice(0, -1);
15+
};
16+
17+
describe("Capitalizes named component titles", () => {
18+
test("title-cases a plain component title", async () => {
19+
expect(
20+
await processComponent('<Callout title="merging with github via npm" />'),
21+
).toBe('<Callout title="Merging with GitHub via NPM" />');
22+
});
23+
24+
test("preserves an inline code span inside a component title", async () => {
25+
expect(
26+
await processComponent(
27+
'<Callout title="the flavors of `git reset`: soft, hard, or mixed" />',
28+
),
29+
).toBe(
30+
'<Callout title="The Flavors of `git reset`: Soft, Hard, or Mixed" />',
31+
);
32+
});
33+
34+
test("ignores components not in the list", async () => {
35+
expect(await processComponent('<Other title="leave this alone" />')).toBe(
36+
'<Other title="leave this alone" />',
37+
);
38+
});
39+
});

remark-capitalize-titles/tests/index.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,26 @@ describe("Handles inline code spans", () => {
197197
"#### The Jokes Write Themselves: `ours`, `theirs`, and Three-way Merges",
198198
);
199199
});
200+
201+
test("treats a parenthetical suffix attached to a trailing code span as a continuation", async () => {
202+
expect(
203+
await processMarkdown(
204+
"#### easier than real life: finding your `remote`(s)",
205+
),
206+
).toBe("#### Easier than Real Life: Finding Your `remote`(s)");
207+
});
208+
209+
test("does not treat text after a leading code span as the title start", async () => {
210+
expect(await processMarkdown("## `push` and pull")).toBe(
211+
"## `push` and Pull",
212+
);
213+
});
214+
215+
test("treats a suffix attached directly to a code span as a continuation", async () => {
216+
expect(await processMarkdown("## the `head`ing story")).toBe(
217+
"## The `head`ing Story",
218+
);
219+
});
200220
});
201221

202222
describe("Handles hyphenated compound words", () => {

0 commit comments

Comments
 (0)