Skip to content

Commit 5720bea

Browse files
committed
✨ Allow for custom ID slugging functions
1 parent 61238e4 commit 5720bea

File tree

9 files changed

+138
-52
lines changed

9 files changed

+138
-52
lines changed

README.md

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -265,22 +265,22 @@ const config = {
265265

266266
## Preprocessor Options
267267

268-
| Option | Type | Default | Description |
269-
| ----------------- | ------------------- | -------------------------------- | ------------------------------------------------------------------------- |
270-
| `comments` | boolean | `true` | Enable [Markdown comments](https://spec.commonmark.org/0.30/#example-624) |
271-
| `components` | string | `"$lib/components"` | Svelte components directory for custom nodes and tags |
272-
| `extensions` | string[] | `[".mdoc", ".md"]` | Files to process with Markdoc |
273-
| `functions` | Config['functions'] | - | [Functions config](#functions) |
274-
| `headingIds` | boolean | `false` | Add IDs to headings without them and include them in export |
275-
| `layout` | string | - | Default layout for all processed Markdown files |
276-
| `linkify` | boolean | `false` | Auto-convert bare URLs to links |
277-
| `nodes` | Config['nodes'] | - | [Nodes config](#nodes) |
278-
| `partials` | string | - | [Partials](#partials) directory path |
279-
| `schema` | string | `["./markdoc", "./src/markdoc"]` | Schema directory path |
280-
| `tags` | Config['tags'] | - | [Tags config](#tags) |
281-
| `typographer` | boolean | `false` | Enable [typography replacements](#typographer) |
282-
| `validationLevel` | ValidationLevel | `"error"` | [Validation strictness level](#validation-level) |
283-
| `variables` | Config['variables'] | - | [Variables config](#variables) |
268+
| Option | Type | Default | Description |
269+
| ----------------- | ---------------------------------------- | -------------------------------- | ------------------------------------------------------------------------- |
270+
| `comments` | boolean | `true` | Enable [Markdown comments](https://spec.commonmark.org/0.30/#example-624) |
271+
| `components` | string | `"$lib/components"` | Svelte components directory for custom nodes and tags |
272+
| `extensions` | string[] | `[".mdoc", ".md"]` | Files to process with Markdoc |
273+
| `functions` | Config['functions'] | - | [Functions config](#functions) |
274+
| `headingIds` | boolean \| `((value: string) => string)` | `false` | Add IDs to headings without them and include them in export |
275+
| `layout` | string | - | Default layout for all processed Markdown files |
276+
| `linkify` | boolean | `false` | Auto-convert bare URLs to links |
277+
| `nodes` | Config['nodes'] | - | [Nodes config](#nodes) |
278+
| `partials` | string | - | [Partials](#partials) directory path |
279+
| `schema` | string | `["./markdoc", "./src/markdoc"]` | Schema directory path |
280+
| `tags` | Config['tags'] | - | [Tags config](#tags) |
281+
| `typographer` | boolean | `false` | Enable [typography replacements](#typographer) |
282+
| `validationLevel` | ValidationLevel | `"error"` | [Validation strictness level](#validation-level) |
283+
| `variables` | Config['variables'] | - | [Variables config](#variables) |
284284

285285
### Functions
286286

@@ -325,6 +325,28 @@ Each heading element in the generated HTML has an `id` attribute you can use to
325325
Each page then also exports a `headings` property: a list of all headings with their text, level, and ID.
326326
Use the list to generate a [table of contents](#page-table-of-contents).
327327

328+
#### Custom ID Creation (Slugifying Function)
329+
330+
By default, the preprocessor uses the [`slug` package](https://www.npmjs.com/package/slug).
331+
If you have requirements for ID creation, pass a function to the `headingIds` option.
332+
This function is used to generate the IDs.
333+
334+
```javascript
335+
import { markdocPreprocess } from "markdoc-svelte";
336+
337+
const customSlugger = (str: string): string => str.replaceAll(/[^a-z]/gi, "-");
338+
339+
/** @type {import('@sveltejs/kit').Config} */
340+
const config = {
341+
extensions: [".svelte", ".mdoc"],
342+
preprocess: [
343+
markdocPreprocess({
344+
headingIds: customSlugger,
345+
}),
346+
],
347+
};
348+
```
349+
328350
### Nodes
329351

330352
[Nodes](https://markdoc.dev/docs/nodes) are elements built into Markdown from the CommonMark specification.
@@ -591,7 +613,7 @@ export const load = async () => {
591613
slug: module.slug,
592614
frontmatter: module.frontmatter,
593615
};
594-
}),
616+
})
595617
);
596618
return { content };
597619
};

package-lock.json

Lines changed: 15 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"license": "MIT",
3838
"dependencies": {
3939
"@markdoc/markdoc": "^0.5.2",
40-
"slugify": "^1.6.6",
40+
"slug": "^11.0.0",
4141
"yaml": "^2.7.1"
4242
},
4343
"devDependencies": {
@@ -47,6 +47,7 @@
4747
"@rollup/plugin-typescript": "^12.1.2",
4848
"@tsconfig/svelte": "^5.0.4",
4949
"@types/node": "^22.15.2",
50+
"@types/slug": "^5.0.9",
5051
"@vitest/coverage-v8": "^3.1.4",
5152
"eslint": "^9.25.1",
5253
"eslint-config-prettier": "^10.1.2",

src/headings.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Tag } from "@markdoc/markdoc";
22
import type { RenderableTreeNode, Schema } from "@markdoc/markdoc";
3-
import slugify from "slugify";
3+
4+
import type { MarkdocSvelteConfig, SluggerType } from "./types.ts";
45

56
export interface Heading {
67
/**
@@ -29,16 +30,14 @@ const getTextContent = (children: RenderableTreeNode[]): string => {
2930
};
3031

3132
const getSlug = (
33+
sluggifier: SluggerType,
3234
attributes: Record<string, any>, // eslint-disable-line @typescript-eslint/no-explicit-any
3335
children: RenderableTreeNode[],
3436
): string => {
3537
if (attributes.id && typeof attributes.id === "string") {
3638
return attributes.id;
3739
}
38-
return slugify(getTextContent(children), {
39-
lower: true,
40-
strict: true,
41-
}) as string;
40+
return sluggifier(getTextContent(children));
4241
};
4342
/**
4443
* Recursively collects all heading nodes from a Markdoc AST
@@ -47,12 +46,13 @@ const getSlug = (
4746
*/
4847
export function collectHeadings(
4948
node: RenderableTreeNode | RenderableTreeNode[],
49+
sluggifier: SluggerType,
5050
sections: Heading[] = [],
5151
): Heading[] {
5252
// Handle array of nodes
5353
if (Array.isArray(node)) {
5454
for (const child of node) {
55-
sections.push(...collectHeadings(child));
55+
sections.push(...collectHeadings(child, sluggifier));
5656
}
5757
return sections;
5858
}
@@ -67,7 +67,7 @@ export function collectHeadings(
6767
sections.push({
6868
level: node.attributes?.level,
6969
title: getTextContent(node.children),
70-
id: getSlug(node.attributes, node.children),
70+
id: getSlug(sluggifier, node.attributes, node.children),
7171
});
7272
}
7373

@@ -79,14 +79,14 @@ export function collectHeadings(
7979
sections.push({
8080
level: parseInt(tag.name[1]),
8181
title: getTextContent(tag.children),
82-
id: getSlug(tag.attributes, tag.children),
82+
id: getSlug(sluggifier, tag.attributes, tag.children),
8383
});
8484
}
8585

8686
// Handle node children
8787
if (tag.children) {
8888
for (const child of tag.children) {
89-
collectHeadings(child, sections);
89+
collectHeadings(child, sluggifier, sections);
9090
}
9191
}
9292
}
@@ -101,11 +101,11 @@ export const heading: Schema = {
101101
id: { type: String },
102102
level: { type: Number, required: true, default: 1 },
103103
},
104-
transform(node, config) {
104+
transform(node, config: MarkdocSvelteConfig) {
105105
const { level, ...attributes } = node.transformAttributes(config);
106106
const children = node.transformChildren(config);
107107

108-
const slug = getSlug(node.attributes, children);
108+
const slug = getSlug(config.headingSlugger, node.attributes, children);
109109

110110
const render = config.nodes?.heading?.render ?? `h${level}`;
111111

src/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
export { heading as headingNode } from "./headings.ts";
22
export { markdocPreprocess } from "./main.ts";
3-
export type { MarkdocModule } from "./types.ts";
3+
export type { MarkdocModule, MarkdocSvelteConfig as Config } from "./types.ts";
44

55
export { default as Markdoc } from "@markdoc/markdoc";
6-
export type { Config } from "@markdoc/markdoc";

src/main.ts

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { basename, extname } from "path";
22

33
import Markdoc from "@markdoc/markdoc";
4-
import type { Config, ParserArgs } from "@markdoc/markdoc";
4+
import type { ParserArgs } from "@markdoc/markdoc";
5+
import slug from "slug";
56
import type { PreprocessorGroup } from "svelte/compiler";
67
import YAML from "yaml";
78

@@ -11,12 +12,12 @@ import {
1112
} from "./components.ts";
1213
import { handleValidationErrors } from "./errors.ts";
1314
import { findFirstDirectory, makePathProjectRelative } from "./files.ts";
14-
import { collectHeadings, heading } from "./headings.ts";
15+
import { collectHeadings, heading, type Heading } from "./headings.ts";
1516
import log from "./logs.ts";
1617
import loadPartials from "./partials.ts";
1718
import render from "./render.ts";
1819
import loadSchemas from "./schema.ts";
19-
import type { Options } from "./types.ts";
20+
import type { MarkdocSvelteConfig, Options } from "./types.ts";
2021

2122
const validOptionKeys: (keyof Options)[] = [
2223
"comments",
@@ -45,7 +46,7 @@ export const markdocPreprocess = (options: Options = {}): PreprocessorGroup => {
4546
for (const key in options) {
4647
if (!validOptionKeys.includes(key as keyof Options)) {
4748
log.warn(
48-
`Invalid option "${key}" provided and ignored. Check the documentation for valid options.`,
49+
`Invalid option "${key}" provided and ignored. Check the documentation for valid options.`
4950
);
5051
}
5152
}
@@ -69,6 +70,9 @@ export const markdocPreprocess = (options: Options = {}): PreprocessorGroup => {
6970
const layoutPath = options.layout;
7071
const allowComments = options.comments ?? true;
7172
const processHeadings = options.headingIds ?? false;
73+
// If passed `true`, use the default for slugging headings
74+
// Otherwise, use the passed function
75+
const headingSlugger = typeof processHeadings === "boolean" ? slug : processHeadings;
7276
const linkify = options.linkify ?? false;
7377
const typographer = options.typographer ?? false;
7478
const validationLevel = options.validationLevel || "error";
@@ -117,9 +121,9 @@ export const markdocPreprocess = (options: Options = {}): PreprocessorGroup => {
117121

118122
// Prepare to load schemas & partials
119123
const dependencies: string[] = [];
120-
let configFromSchema: Config = {};
121-
let partialsFromSchema: Config["partials"] = {};
122-
let partialsFromPartials: Config["partials"] = {};
124+
let configFromSchema: MarkdocSvelteConfig = {};
125+
let partialsFromSchema: MarkdocSvelteConfig["partials"] = {};
126+
let partialsFromPartials: MarkdocSvelteConfig["partials"] = {};
123127

124128
// Discover optional schema directory
125129
const schemaDir = findFirstDirectory(schemaPaths);
@@ -143,7 +147,7 @@ export const markdocPreprocess = (options: Options = {}): PreprocessorGroup => {
143147
}
144148

145149
// Assemble full config
146-
const fullConfig: Config = {
150+
const fullConfig: MarkdocSvelteConfig = {
147151
// Start with base config loaded from the schema directory
148152
// Explicitly set options overwrite the base config
149153
// For example, if processing headings,
@@ -158,6 +162,7 @@ export const markdocPreprocess = (options: Options = {}): PreprocessorGroup => {
158162
partials: { ...partialsFromSchema, ...partialsFromPartials },
159163
// Make $frontmatter available as variable
160164
variables: { ...configFromSchema.variables, ...variables, frontmatter },
165+
headingSlugger
161166
};
162167

163168
// Validate Markdoc AST
@@ -172,10 +177,14 @@ export const markdocPreprocess = (options: Options = {}): PreprocessorGroup => {
172177
// eslint-disable-next-line @typescript-eslint/await-thenable
173178
const transformedContent = await Markdoc.transform(ast, fullConfig);
174179

175-
// --- Collect headings from transformed content ---
176-
const headings = processHeadings
177-
? collectHeadings(transformedContent)
178-
: [];
180+
// Collect headings from transformed content
181+
const getHeadings = (): Heading[] => {
182+
if (processHeadings) {
183+
return collectHeadings(transformedContent, headingSlugger);
184+
}
185+
return [];
186+
};
187+
const headings = getHeadings()
179188

180189
// Render Markdoc AST to Svelte
181190
const svelteContent = render(transformedContent);
@@ -203,7 +212,7 @@ export const markdocPreprocess = (options: Options = {}): PreprocessorGroup => {
203212
extractUsedSvelteComponents(transformedContent);
204213
const componentImportStatements = getComponentImports(
205214
usedSvelteComponentNames,
206-
componentsPath,
215+
componentsPath
207216
);
208217

209218
// Construct script tag content

src/types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export interface Options {
4343
* Whether to add IDs to all headings and generate and export a list of headings.
4444
* @default false
4545
*/
46-
headingIds?: boolean;
46+
headingIds?: boolean | SluggerType;
4747
/**
4848
* Specify a Svelte component to use as a layout for the Markdoc file.
4949
* Use import paths and aliases that Svelte can resolve.
@@ -120,3 +120,9 @@ export interface MarkdocModule {
120120
*/
121121
headings?: Heading[];
122122
}
123+
124+
export type SluggerType = ((value: string) => string)
125+
126+
export interface MarkdocSvelteConfig extends Config {
127+
headingSlugger?: SluggerType
128+
}

test/__snapshots__/heading.test.ts.snap

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,19 @@ exports[`Headings > adds IDs and exports headings even when the custom heading i
1717
</script>
1818
<article><HeadingComponent id="this-is-some-basic-markdoc">This is some basic Markdoc</HeadingComponent><p>With a paragraph.</p><HeadingComponent id="more-fancy-stuff">More fancy stuff</HeadingComponent><p>Content. Such great content.</p></article>"
1919
`;
20+
21+
exports[`Headings > adds IDs when passed a custom slugifying function 1`] = `
22+
"<script module>
23+
export const slug = "test";
24+
export const headings = [{"level":1,"title":"This is some basic Markdoc","id":"This-is-some-basic-Markdoc"},{"level":2,"title":"More fancy stuff","id":"More-fancy-stuff"}];
25+
</script>
26+
<article><h1 id="This-is-some-basic-Markdoc">This is some basic Markdoc</h1><p>With a paragraph.</p><h2 id="More-fancy-stuff">More fancy stuff</h2><p>Content. Such great content.</p></article>"
27+
`;
28+
29+
exports[`Headings > adds IDs when passed a custom slugifying function even for custom headings 1`] = `
30+
"<script module>
31+
export const slug = "test";
32+
export const headings = [{"level":1,"title":"This is some basic Markdoc","id":"This-is-some-basic-Markdoc"},{"level":1,"title":"More fancy stuff","id":"More-fancy-stuff"}];
33+
</script>
34+
<article><h1 class="custom-heading" id="This-is-some-basic-Markdoc">This is some basic Markdoc</h1><p>With a paragraph.</p><h1 class="custom-heading" id="More-fancy-stuff">More fancy stuff</h1><p>Content. Such great content.</p></article>"
35+
`;

0 commit comments

Comments
 (0)