@jxsuite/parser 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +107 -66
- package/dist/content-loader.js +16 -2
- package/dist/markdown.js +17 -3
- package/package.json +4 -4
- package/src/Csv.class.json +1 -1
- package/src/content-loader.ts +23 -4
- package/src/markdown.ts +7 -0
- package/src/md.ts +28 -2
package/README.md
CHANGED
|
@@ -1,115 +1,156 @@
|
|
|
1
1
|
# `@jxsuite/parser`
|
|
2
2
|
|
|
3
|
-
> Markdown
|
|
3
|
+
> Content & Markdown extension for Jx — file-based content collections with Markdown and CSV formats.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
-
`@jxsuite/parser`
|
|
7
|
+
`@jxsuite/parser` is the first-party content extension. Its `jx-extension.json` manifest exposes six classes plus project/document schema fragments:
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
| Class | Kind | Purpose |
|
|
10
|
+
| -------------------- | --------------- | -------------------------------------------------------------------------------------------------- |
|
|
11
|
+
| `Markdown` | Format (`.md`) | Parse/serialize Jx Markdown documents, load markdown content entries, resolve a file at runtime |
|
|
12
|
+
| `Csv` | Format (`.csv`) | Parse local CSV files or remote CSV URLs into typed content entries |
|
|
13
|
+
| `Content` | Project section | Loads the project.json `content` section into `_project.content` and expands `$paths` route params |
|
|
14
|
+
| `ContentCollection` | Runtime class | Query a content type with filter, sort, and limit |
|
|
15
|
+
| `ContentEntry` | Runtime class | Fetch a single entry from a content type by ID (or another field) |
|
|
16
|
+
| `MarkdownCollection` | Runtime class | Glob markdown files into a sorted, filterable array |
|
|
10
17
|
|
|
11
|
-
|
|
18
|
+
Built on the [`unified`](https://unifiedjs.com/) / remark ecosystem.
|
|
19
|
+
|
|
20
|
+
## Installation & registration
|
|
12
21
|
|
|
13
22
|
```bash
|
|
14
23
|
bun add @jxsuite/parser
|
|
15
24
|
```
|
|
16
25
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
Parses a single Markdown file into structured data.
|
|
26
|
+
Registration is manifest-based — add the package to `project.json`:
|
|
20
27
|
|
|
21
28
|
```json
|
|
22
29
|
{
|
|
23
|
-
"
|
|
24
|
-
"post": {
|
|
25
|
-
"$prototype": "MarkdownFile",
|
|
26
|
-
"$src": "@jxsuite/parser/MarkdownFile.class.json",
|
|
27
|
-
"src": "./content/posts/hello-world.md"
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
+
"extensions": ["@jxsuite/parser"]
|
|
30
31
|
}
|
|
31
32
|
```
|
|
32
33
|
|
|
33
|
-
|
|
34
|
+
That one entry registers the `.md`/`.csv` formats, the `content` project section, and the classes: `$prototype: "ContentEntry"` (etc.) resolves by name through the project's extension registry — no `$src` or imports wiring. There are no implicit defaults: a project without the extension supports only `.json` documents.
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
| -------------- | -------- | ----------------------------------- |
|
|
37
|
-
| `slug` | `string` | Filename without extension |
|
|
38
|
-
| `path` | `string` | Full file path |
|
|
39
|
-
| `frontmatter` | `object` | Parsed YAML frontmatter |
|
|
40
|
-
| `$body` | `string` | Rendered HTML body |
|
|
41
|
-
| `$excerpt` | `string` | First paragraph as HTML |
|
|
42
|
-
| `$toc` | `array` | Table of contents (id, text, depth) |
|
|
43
|
-
| `$readingTime` | `number` | Estimated reading time in minutes |
|
|
44
|
-
| `$wordCount` | `number` | Word count |
|
|
36
|
+
## Content collections
|
|
45
37
|
|
|
46
|
-
|
|
38
|
+
Declare content types in the `content` section (validated by this package's project schema fragment):
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"content": {
|
|
43
|
+
"docs": {
|
|
44
|
+
"source": "./content/docs",
|
|
45
|
+
"format": "Markdown",
|
|
46
|
+
"schema": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"properties": { "title": { "type": "string" }, "date": { "type": "string" } }
|
|
49
|
+
},
|
|
50
|
+
"$elements": [{ "$ref": "./components/doc-note.json" }]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
47
55
|
|
|
48
|
-
|
|
56
|
+
Query them from any page or component:
|
|
49
57
|
|
|
50
58
|
```json
|
|
51
59
|
{
|
|
52
60
|
"state": {
|
|
53
61
|
"posts": {
|
|
54
|
-
"$prototype": "
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"sortBy": "date",
|
|
58
|
-
"sortOrder": "desc",
|
|
62
|
+
"$prototype": "ContentCollection",
|
|
63
|
+
"contentType": "docs",
|
|
64
|
+
"filter": [{ "field": "draft", "op": "!=", "value": true }],
|
|
59
65
|
"limit": 10
|
|
66
|
+
},
|
|
67
|
+
"page": {
|
|
68
|
+
"$prototype": "ContentEntry",
|
|
69
|
+
"contentType": "docs",
|
|
70
|
+
"id": { "$ref": "#/$params/slug" }
|
|
60
71
|
}
|
|
61
72
|
}
|
|
62
73
|
}
|
|
63
74
|
```
|
|
64
75
|
|
|
65
|
-
|
|
76
|
+
`ContentEntry` matches on the entry id (slug) by default; set `field` to match a frontmatter field (e.g. `"sku"`) instead.
|
|
66
77
|
|
|
67
|
-
|
|
68
|
-
| ----------- | -------- | -------- | ----------------------------------- |
|
|
69
|
-
| `src` | `string` | — | Glob pattern for Markdown files |
|
|
70
|
-
| `sortBy` | `string` | `"date"` | Frontmatter field to sort by |
|
|
71
|
-
| `sortOrder` | `string` | `"desc"` | `"asc"` or `"desc"` |
|
|
72
|
-
| `limit` | `number` | — | Maximum number of results |
|
|
73
|
-
| `filter` | `string` | — | Frontmatter field filter expression |
|
|
78
|
+
## `Markdown`
|
|
74
79
|
|
|
75
|
-
|
|
80
|
+
One class carries every format capability:
|
|
76
81
|
|
|
77
|
-
|
|
82
|
+
- **static `parse`** — Jx Markdown source → Jx document (browser-safe; Studio opens `.md` files with it)
|
|
83
|
+
- **static `serialize`** — Jx document → markdown, `roundtrip` (lossless, default) or `export` (lossy clean markdown)
|
|
84
|
+
- **static `discover` / `load`** — compile-time content access: frontmatter → `data`, source preserved as `body`, parsed `$children`, and `_meta` (excerpt, toc, reading time, word count)
|
|
85
|
+
- **instance `resolve`** — runtime on-demand access for a `$prototype: "Markdown"` state entry
|
|
78
86
|
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"state": {
|
|
90
|
+
"post": { "$prototype": "Markdown", "src": "./content/posts/hello-world.md" }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
83
93
|
```
|
|
84
94
|
|
|
85
|
-
|
|
95
|
+
**Resolved value** (`MarkdownFileResult`):
|
|
86
96
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
97
|
+
| Property | Type | Description |
|
|
98
|
+
| -------------- | -------- | ------------------------------------------- |
|
|
99
|
+
| `slug` | `string` | Entry id (path-based under the source root) |
|
|
100
|
+
| `path` | `string` | Full file path |
|
|
101
|
+
| `frontmatter` | `object` | Parsed YAML frontmatter |
|
|
102
|
+
| `$children` | `array` | Body as Jx element nodes |
|
|
103
|
+
| `$excerpt` | `string` | First paragraph |
|
|
104
|
+
| `$toc` | `array` | Table of contents (id, text, depth) |
|
|
105
|
+
| `$readingTime` | `number` | Estimated reading time in minutes |
|
|
106
|
+
| `$wordCount` | `number` | Word count |
|
|
107
|
+
|
|
108
|
+
### Directives
|
|
109
|
+
|
|
110
|
+
`:::name{attrs}` container syntax converts directly to Jx element nodes — `:::my-card{title="Hello"}` becomes a `my-card` element with its attributes routed to the right Jx locations (props, `$`-annotations, styles). Capitalized directives (e.g. `:::Array`) become tagName-less `$prototype` nodes. A content type's `$elements` list declares which components its entries may use.
|
|
111
|
+
|
|
112
|
+
## `Csv`
|
|
92
113
|
|
|
93
|
-
|
|
114
|
+
CSV content format (`documentKinds: ["content"]`; Studio opens `.csv` in grid/source modes).
|
|
94
115
|
|
|
95
|
-
|
|
116
|
+
| Option | Type | Description |
|
|
117
|
+
| --------- | -------- | ----------------------------------------------------------------- |
|
|
118
|
+
| `src` | `string` | Path or http(s) URL to a CSV file (remote sources supported) |
|
|
119
|
+
| `schema` | `object` | Per-column type schema for coercion (number, boolean, array) |
|
|
120
|
+
| `idField` | `string` | Column used as entry id (default chain: id, sku, slug, row index) |
|
|
96
121
|
|
|
97
|
-
|
|
98
|
-
|
|
122
|
+
## `MarkdownCollection`
|
|
123
|
+
|
|
124
|
+
Globs markdown files into a sorted array of `MarkdownFileResult`s (runtime alternative to the content section):
|
|
99
125
|
|
|
100
|
-
|
|
101
|
-
|
|
126
|
+
```json
|
|
127
|
+
{
|
|
128
|
+
"state": {
|
|
129
|
+
"posts": {
|
|
130
|
+
"$prototype": "MarkdownCollection",
|
|
131
|
+
"src": "./content/posts/*.md",
|
|
132
|
+
"sortBy": "frontmatter.date",
|
|
133
|
+
"sortOrder": "desc",
|
|
134
|
+
"limit": 10
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
102
138
|
```
|
|
103
139
|
|
|
104
|
-
|
|
140
|
+
Options: `src` (glob), `sortBy` (dot-path, default `"frontmatter.date"`), `sortOrder` (`"asc"`/`"desc"`), `limit`, `filter` (predicate), `basePath`, `directives`.
|
|
141
|
+
|
|
142
|
+
## Module exports
|
|
105
143
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
144
|
+
| Export | Contents |
|
|
145
|
+
| -------------------------------- | ---------------------------------------------------------------------- |
|
|
146
|
+
| `@jxsuite/parser` | `processMarkdown`, `Markdown`, `MarkdownCollection` |
|
|
147
|
+
| `@jxsuite/parser/markdown` | The `Markdown` format class (browser-safe parse/serialize) |
|
|
148
|
+
| `@jxsuite/parser/csv` | `Csv`, `parseCSV`, `coerceCSVRows` |
|
|
149
|
+
| `@jxsuite/parser/content` | `ContentCollection`, `ContentEntry`, pure query functions |
|
|
150
|
+
| `@jxsuite/parser/content-loader` | `Content` project-section loader |
|
|
151
|
+
| `@jxsuite/parser/transpile` | `transpileJxMarkdown` (markdown source → Jx document), `isJxMarkdown` |
|
|
152
|
+
| `@jxsuite/parser/serialize` | `serializeJxMarkdown`, `jxToMdast`, `mdastToJx`, markdown element sets |
|
|
153
|
+
| `@jxsuite/parser/*.class.json` | The class descriptors named by the manifest |
|
|
113
154
|
|
|
114
155
|
## License
|
|
115
156
|
|
package/dist/content-loader.js
CHANGED
|
@@ -155,11 +155,13 @@ async function loadContentType(name, contentTypeDef, projectRoot, registry) {
|
|
|
155
155
|
const files = entry.capabilities.discover ? await entry.call("discover", source, {
|
|
156
156
|
baseDir: projectRoot
|
|
157
157
|
}) : [resolvedSource];
|
|
158
|
+
const sourceRoot = extname(source) ? undefined : resolvedSource;
|
|
158
159
|
const entries = [];
|
|
159
160
|
for (const filePath of files) {
|
|
160
161
|
entries.push(...await entry.call("load", filePath, {
|
|
161
162
|
directiveOptions,
|
|
162
|
-
schema
|
|
163
|
+
schema,
|
|
164
|
+
...sourceRoot !== undefined && { sourceRoot }
|
|
163
165
|
}));
|
|
164
166
|
}
|
|
165
167
|
if (schema) {
|
|
@@ -225,6 +227,7 @@ function resolveContentTypeRefs(contentTypes, section) {
|
|
|
225
227
|
}
|
|
226
228
|
const refEntries = contentTypes.get(target);
|
|
227
229
|
if (!refEntries) {
|
|
230
|
+
console.warn(`Content relationships: "${name}" field "${field}" references unknown content type "${target}"`);
|
|
228
231
|
continue;
|
|
229
232
|
}
|
|
230
233
|
for (const entry of entries) {
|
|
@@ -233,9 +236,20 @@ function resolveContentTypeRefs(contentTypes, section) {
|
|
|
233
236
|
const resolved = refEntries.find((e) => e.id === value);
|
|
234
237
|
if (resolved) {
|
|
235
238
|
entry.data[field] = resolved;
|
|
239
|
+
} else {
|
|
240
|
+
console.warn(`Content relationships: "${name}/${entry.id}" field "${field}" references missing "${target}" entry "${value}"`);
|
|
236
241
|
}
|
|
237
242
|
} else if (toMany && Array.isArray(value)) {
|
|
238
|
-
entry.data[field] = value.map((id) =>
|
|
243
|
+
entry.data[field] = value.map((id) => {
|
|
244
|
+
if (typeof id !== "string") {
|
|
245
|
+
return id;
|
|
246
|
+
}
|
|
247
|
+
const resolved = refEntries.find((e) => e.id === id);
|
|
248
|
+
if (!resolved) {
|
|
249
|
+
console.warn(`Content relationships: "${name}/${entry.id}" field "${field}" references missing "${target}" entry "${id}"`);
|
|
250
|
+
}
|
|
251
|
+
return resolved ?? id;
|
|
252
|
+
});
|
|
239
253
|
}
|
|
240
254
|
}
|
|
241
255
|
}
|
package/dist/markdown.js
CHANGED
|
@@ -46180,7 +46180,7 @@ __export(exports_md, {
|
|
|
46180
46180
|
Markdown: () => Markdown
|
|
46181
46181
|
});
|
|
46182
46182
|
import { readFileSync } from "node:fs";
|
|
46183
|
-
import { basename, extname, resolve as resolvePath2 } from "node:path";
|
|
46183
|
+
import { basename, extname, relative, resolve as resolvePath2 } from "node:path";
|
|
46184
46184
|
function visit3(tree, typeOrVisitor, maybeVisitor) {
|
|
46185
46185
|
const type = typeof typeOrVisitor === "string" ? typeOrVisitor : null;
|
|
46186
46186
|
const visitor = type ? maybeVisitor : typeOrVisitor;
|
|
@@ -46239,6 +46239,19 @@ function extractExcerpt(tree) {
|
|
|
46239
46239
|
}
|
|
46240
46240
|
return mdastToString(firstParagraph);
|
|
46241
46241
|
}
|
|
46242
|
+
function deriveSlug(filePath, sourceRoot) {
|
|
46243
|
+
if (sourceRoot) {
|
|
46244
|
+
const rel = relative(sourceRoot, filePath).split("\\").join("/");
|
|
46245
|
+
if (rel && !rel.startsWith("..") && rel.includes("/")) {
|
|
46246
|
+
let slug = rel.slice(0, rel.length - extname(rel).length);
|
|
46247
|
+
if (slug.endsWith("/index")) {
|
|
46248
|
+
slug = slug.slice(0, -"/index".length);
|
|
46249
|
+
}
|
|
46250
|
+
return slug;
|
|
46251
|
+
}
|
|
46252
|
+
}
|
|
46253
|
+
return basename(filePath, extname(filePath));
|
|
46254
|
+
}
|
|
46242
46255
|
function processMarkdown(source, filePath, config = {}) {
|
|
46243
46256
|
let processor = unified().use(remarkParse).use(remarkFrontmatter, ["yaml"]).use(import_remark_parse_frontmatter2.default).use(remarkGfm);
|
|
46244
46257
|
if (config.directives || config.directiveOptions) {
|
|
@@ -46253,7 +46266,7 @@ function processMarkdown(source, filePath, config = {}) {
|
|
|
46253
46266
|
const plainText = mdastToString(mdTree);
|
|
46254
46267
|
const toc = extractToc(tree);
|
|
46255
46268
|
const excerpt = extractExcerpt(tree);
|
|
46256
|
-
const slug =
|
|
46269
|
+
const slug = deriveSlug(filePath, config.sourceRoot);
|
|
46257
46270
|
const bodyNodes = tree.children.filter((n7) => n7.type !== "yaml" && n7.type !== "toml");
|
|
46258
46271
|
const $children = bodyNodes.map((n7) => mdastNodeToJx(n7)).filter(Boolean);
|
|
46259
46272
|
return {
|
|
@@ -46367,7 +46380,8 @@ class Markdown {
|
|
|
46367
46380
|
...options.directives !== undefined && { directives: options.directives },
|
|
46368
46381
|
...options.directiveOptions !== undefined && {
|
|
46369
46382
|
directiveOptions: options.directiveOptions
|
|
46370
|
-
}
|
|
46383
|
+
},
|
|
46384
|
+
...options.sourceRoot !== undefined && { sourceRoot: options.sourceRoot }
|
|
46371
46385
|
});
|
|
46372
46386
|
const _meta = {};
|
|
46373
46387
|
if (result.$excerpt != null) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/parser",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Jx markdown parser and external class integration",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"upgrade": "bunx npm-check-updates -u && bun install"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@jxsuite/markup": "^0.
|
|
52
|
-
"@jxsuite/schema": "^1.
|
|
51
|
+
"@jxsuite/markup": "^0.3.0",
|
|
52
|
+
"@jxsuite/schema": "^1.1.0",
|
|
53
53
|
"glob": "^13.0.6",
|
|
54
54
|
"mdast-util-to-string": "^4.0.0",
|
|
55
55
|
"remark-directive": "^4.0.0",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@happy-dom/global-registrator": "^20.10.6",
|
|
67
|
-
"@jxsuite/runtime": "^1.
|
|
67
|
+
"@jxsuite/runtime": "^1.1.0",
|
|
68
68
|
"@types/mdast": "^4.0.4",
|
|
69
69
|
"ajv": "^8.20.0"
|
|
70
70
|
},
|
package/src/Csv.class.json
CHANGED
package/src/content-loader.ts
CHANGED
|
@@ -269,12 +269,15 @@ async function loadContentType(
|
|
|
269
269
|
})) as string[])
|
|
270
270
|
: [resolvedSource];
|
|
271
271
|
|
|
272
|
+
// Directory sources pass their resolved root so formats can derive path-based ids for nested files
|
|
273
|
+
const sourceRoot = extname(source) ? undefined : resolvedSource;
|
|
272
274
|
const entries: ContentLoaderEntry[] = [];
|
|
273
275
|
for (const filePath of files) {
|
|
274
276
|
entries.push(
|
|
275
277
|
...((await entry.call("load", filePath, {
|
|
276
278
|
directiveOptions,
|
|
277
279
|
schema,
|
|
280
|
+
...(sourceRoot !== undefined && { sourceRoot }),
|
|
278
281
|
})) as ContentLoaderEntry[]),
|
|
279
282
|
);
|
|
280
283
|
}
|
|
@@ -374,7 +377,7 @@ function refTargetName(ref: string | undefined): string | undefined {
|
|
|
374
377
|
* field `$ref` is to-one: a blog post's `author: "jane-doe"` with a schema `$ref` of
|
|
375
378
|
* `#/content/authors` gets replaced by the full author entry. An array field whose `items` carry
|
|
376
379
|
* the `$ref` is to-many: each id in the stored array is replaced by its entry. Unresolvable ids are
|
|
377
|
-
* left untouched.
|
|
380
|
+
* left untouched, with a warning naming the entry and the missing target.
|
|
378
381
|
*
|
|
379
382
|
* @param {Map<string, ContentLoaderEntry[]>} contentTypes - All loaded content types
|
|
380
383
|
* @param {ContentSection} section - The `content` section value the types were loaded from
|
|
@@ -403,6 +406,9 @@ export function resolveContentTypeRefs(
|
|
|
403
406
|
}
|
|
404
407
|
const refEntries = contentTypes.get(target);
|
|
405
408
|
if (!refEntries) {
|
|
409
|
+
console.warn(
|
|
410
|
+
`Content relationships: "${name}" field "${field}" references unknown content type "${target}"`,
|
|
411
|
+
);
|
|
406
412
|
continue;
|
|
407
413
|
}
|
|
408
414
|
|
|
@@ -412,11 +418,24 @@ export function resolveContentTypeRefs(
|
|
|
412
418
|
const resolved = refEntries.find((e) => e.id === value);
|
|
413
419
|
if (resolved) {
|
|
414
420
|
entry.data[field] = resolved;
|
|
421
|
+
} else {
|
|
422
|
+
console.warn(
|
|
423
|
+
`Content relationships: "${name}/${entry.id}" field "${field}" references missing "${target}" entry "${value}"`,
|
|
424
|
+
);
|
|
415
425
|
}
|
|
416
426
|
} else if (toMany && Array.isArray(value)) {
|
|
417
|
-
entry.data[field] = value.map((id) =>
|
|
418
|
-
typeof id
|
|
419
|
-
|
|
427
|
+
entry.data[field] = value.map((id) => {
|
|
428
|
+
if (typeof id !== "string") {
|
|
429
|
+
return id;
|
|
430
|
+
}
|
|
431
|
+
const resolved = refEntries.find((e) => e.id === id);
|
|
432
|
+
if (!resolved) {
|
|
433
|
+
console.warn(
|
|
434
|
+
`Content relationships: "${name}/${entry.id}" field "${field}" references missing "${target}" entry "${id}"`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
return resolved ?? id;
|
|
438
|
+
});
|
|
420
439
|
}
|
|
421
440
|
}
|
|
422
441
|
}
|
package/src/markdown.ts
CHANGED
|
@@ -26,6 +26,12 @@ export interface MarkdownLoadOptions {
|
|
|
26
26
|
directiveOptions?: unknown;
|
|
27
27
|
/** Enable remark-directive parsing. Implied by directiveOptions. */
|
|
28
28
|
directives?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Resolved content-source root directory. When set, entries in subdirectories get path-based ids
|
|
31
|
+
* (`studio/canvas` for `studio/canvas.md`, `/index` stripped); files directly at the root keep
|
|
32
|
+
* basename ids.
|
|
33
|
+
*/
|
|
34
|
+
sourceRoot?: string;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
/**
|
|
@@ -83,6 +89,7 @@ export class Markdown {
|
|
|
83
89
|
...(options.directiveOptions !== undefined && {
|
|
84
90
|
directiveOptions: options.directiveOptions,
|
|
85
91
|
}),
|
|
92
|
+
...(options.sourceRoot !== undefined && { sourceRoot: options.sourceRoot }),
|
|
86
93
|
});
|
|
87
94
|
const _meta: ContentLoaderEntry["_meta"] = {};
|
|
88
95
|
if (result.$excerpt != null) {
|
package/src/md.ts
CHANGED
|
@@ -18,7 +18,7 @@ import remarkParseFrontmatter from "remark-parse-frontmatter";
|
|
|
18
18
|
import remarkGfm from "remark-gfm";
|
|
19
19
|
import remarkDirective from "remark-directive";
|
|
20
20
|
import { readFileSync } from "node:fs";
|
|
21
|
-
import { basename, extname, resolve as resolvePath } from "node:path";
|
|
21
|
+
import { basename, extname, relative, resolve as resolvePath } from "node:path";
|
|
22
22
|
import { globSync } from "glob";
|
|
23
23
|
import { mdastNodeToJx } from "./transpile.ts";
|
|
24
24
|
import type { MarkdownFileResult, MdastNode, TocEntry, UnifiedProcessor } from "./types.ts";
|
|
@@ -133,6 +133,30 @@ function extractExcerpt(tree: MdastNode) {
|
|
|
133
133
|
return mdastToString(firstParagraph);
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Derive an entry slug from a file path. Without a source root (or for files directly at the root),
|
|
138
|
+
* the slug is the basename — the historical behavior every flat collection relies on. Files in
|
|
139
|
+
* subdirectories of the root get path-based slugs with POSIX separators and a trailing `/index`
|
|
140
|
+
* stripped, so `studio/canvas.md` and `studio/canvas/index.md` both yield `studio/canvas`.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} filePath - Absolute path to the markdown file
|
|
143
|
+
* @param {string} [sourceRoot] - Resolved content-source root directory
|
|
144
|
+
* @returns {string} The entry slug
|
|
145
|
+
*/
|
|
146
|
+
function deriveSlug(filePath: string, sourceRoot?: string): string {
|
|
147
|
+
if (sourceRoot) {
|
|
148
|
+
const rel = relative(sourceRoot, filePath).split("\\").join("/");
|
|
149
|
+
if (rel && !rel.startsWith("..") && rel.includes("/")) {
|
|
150
|
+
let slug = rel.slice(0, rel.length - extname(rel).length);
|
|
151
|
+
if (slug.endsWith("/index")) {
|
|
152
|
+
slug = slug.slice(0, -"/index".length);
|
|
153
|
+
}
|
|
154
|
+
return slug;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return basename(filePath, extname(filePath));
|
|
158
|
+
}
|
|
159
|
+
|
|
136
160
|
/**
|
|
137
161
|
* Process a single markdown source string into a MarkdownFileResult.
|
|
138
162
|
*
|
|
@@ -143,6 +167,7 @@ function extractExcerpt(tree: MdastNode) {
|
|
|
143
167
|
* @param {object} config - Processing options
|
|
144
168
|
* @param {boolean} [config.directives] - Enable directive support
|
|
145
169
|
* @param {unknown} [config.directiveOptions] - Directive plugin options
|
|
170
|
+
* @param {string} [config.sourceRoot] - Content-source root; files below it get path-based slugs
|
|
146
171
|
* @returns {MarkdownFileResult}
|
|
147
172
|
*/
|
|
148
173
|
export function processMarkdown(
|
|
@@ -151,6 +176,7 @@ export function processMarkdown(
|
|
|
151
176
|
config: {
|
|
152
177
|
directives?: boolean;
|
|
153
178
|
directiveOptions?: unknown;
|
|
179
|
+
sourceRoot?: string;
|
|
154
180
|
} = {},
|
|
155
181
|
) {
|
|
156
182
|
let processor = (unified as unknown as () => UnifiedProcessor)()
|
|
@@ -173,7 +199,7 @@ export function processMarkdown(
|
|
|
173
199
|
const plainText = mdastToString(mdTree);
|
|
174
200
|
const toc = extractToc(tree);
|
|
175
201
|
const excerpt = extractExcerpt(tree);
|
|
176
|
-
const slug =
|
|
202
|
+
const slug = deriveSlug(filePath, config.sourceRoot);
|
|
177
203
|
|
|
178
204
|
const bodyNodes = tree.children!.filter(
|
|
179
205
|
(n: MdastNode) => n.type !== "yaml" && n.type !== "toml",
|