@jasonshimmy/vite-plugin-cer-app 0.20.0 → 0.20.1
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/CHANGELOG.md +4 -0
- package/commits.txt +1 -2
- package/dist/plugin/content/parser.d.ts.map +1 -1
- package/dist/plugin/content/parser.js +63 -0
- package/dist/plugin/content/parser.js.map +1 -1
- package/docs/content.md +17 -0
- package/e2e/cypress/e2e/content.cy.ts +67 -4
- package/e2e/kitchen-sink/app/pages/content-fallback.ts +36 -0
- package/e2e/kitchen-sink/content/blog/no-frontmatter.md +7 -0
- package/package.json +1 -1
- package/src/__tests__/plugin/content/parser.test.ts +143 -1
- package/src/plugin/content/parser.ts +67 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
|
+
## [v0.20.1] - 2026-04-11
|
|
5
|
+
|
|
6
|
+
- fix(content): add fallback title and description extraction from Markdown body (9bab321)
|
|
7
|
+
|
|
4
8
|
## [v0.20.0] - 2026-04-11
|
|
5
9
|
|
|
6
10
|
- feat(content): implement file-based content layer with Markdown/JSON support (#1) (c6ef0f7)
|
package/commits.txt
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
-
|
|
2
|
-
- chore: update dependencies for @jasonshimmy/custom-elements-runtime and typescript-eslint (15095bc)
|
|
1
|
+
- fix(content): add fallback title and description extraction from Markdown body (9bab321)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../../../src/plugin/content/parser.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAkB,WAAW,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAA;AACtF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;
|
|
1
|
+
{"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../../../src/plugin/content/parser.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAkB,WAAW,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAA;AACtF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AA6N/C;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,WAAW,EACjB,UAAU,EAAE,MAAM,GACjB,WAAW,CAGb;AAED;;;GAGG;AACH,wBAAsB,qBAAqB,CACzC,IAAI,EAAE,WAAW,EACjB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,WAAW,CAAC,CAGtB;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,WAAW,GAAG,WAAW,CAI5D"}
|
|
@@ -40,6 +40,60 @@ function extractHeadings(tokens) {
|
|
|
40
40
|
walk(tokens);
|
|
41
41
|
return headings;
|
|
42
42
|
}
|
|
43
|
+
// ─── Fallback title / description extraction ─────────────────────────────────
|
|
44
|
+
/**
|
|
45
|
+
* Converts a list of inline marked tokens to plain text by recursing into
|
|
46
|
+
* formatted tokens (strong, em, link, etc.) and collecting their leaf text.
|
|
47
|
+
* Used to derive readable fallback values from body content.
|
|
48
|
+
*/
|
|
49
|
+
function inlineToPlainText(tokens) {
|
|
50
|
+
let result = '';
|
|
51
|
+
for (const t of tokens) {
|
|
52
|
+
const children = t.tokens;
|
|
53
|
+
if (Array.isArray(children) && children.length > 0) {
|
|
54
|
+
result += inlineToPlainText(children);
|
|
55
|
+
}
|
|
56
|
+
else if (t.type === 'br') {
|
|
57
|
+
result += ' ';
|
|
58
|
+
}
|
|
59
|
+
else if ('text' in t && typeof t.text === 'string') {
|
|
60
|
+
result += t.text;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
const DESCRIPTION_MAX_LEN = 160;
|
|
66
|
+
/**
|
|
67
|
+
* Scans the top-level token list for a fallback `title` (first depth-1 heading)
|
|
68
|
+
* and `description` (first paragraph). Both are `undefined` when no matching
|
|
69
|
+
* token is found.
|
|
70
|
+
*
|
|
71
|
+
* These are applied only when the corresponding frontmatter field is absent, so
|
|
72
|
+
* frontmatter always wins.
|
|
73
|
+
*/
|
|
74
|
+
function extractFallbacks(tokens) {
|
|
75
|
+
let title;
|
|
76
|
+
let description;
|
|
77
|
+
for (const token of tokens) {
|
|
78
|
+
if (title === undefined && token.type === 'heading' && token.depth === 1) {
|
|
79
|
+
const text = inlineToPlainText(token.tokens ?? []).trim();
|
|
80
|
+
if (text)
|
|
81
|
+
title = text;
|
|
82
|
+
}
|
|
83
|
+
if (description === undefined && token.type === 'paragraph') {
|
|
84
|
+
const text = inlineToPlainText(token.tokens ?? []).trim();
|
|
85
|
+
if (text) {
|
|
86
|
+
description =
|
|
87
|
+
text.length > DESCRIPTION_MAX_LEN
|
|
88
|
+
? text.slice(0, DESCRIPTION_MAX_LEN).trimEnd() + '…'
|
|
89
|
+
: text;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (title !== undefined && description !== undefined)
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
return { title, description };
|
|
96
|
+
}
|
|
43
97
|
// ─── Custom renderer: add id to heading tags ─────────────────────────────────
|
|
44
98
|
const renderer = new marked.Renderer();
|
|
45
99
|
renderer.heading = function ({ tokens, depth }) {
|
|
@@ -105,6 +159,9 @@ function parseContentFileFromRaw(file, contentDir, raw) {
|
|
|
105
159
|
const excerpt = excerptSource !== null
|
|
106
160
|
? marked.parse(excerptSource, { renderer })
|
|
107
161
|
: undefined;
|
|
162
|
+
// Derive fallback title / description from body tokens when frontmatter
|
|
163
|
+
// does not provide them. Frontmatter always wins — these only fill gaps.
|
|
164
|
+
const fallbacks = extractFallbacks(tokens);
|
|
108
165
|
const item = {
|
|
109
166
|
...frontmatter,
|
|
110
167
|
_path,
|
|
@@ -113,6 +170,12 @@ function parseContentFileFromRaw(file, contentDir, raw) {
|
|
|
113
170
|
body,
|
|
114
171
|
toc,
|
|
115
172
|
};
|
|
173
|
+
if (item.title === undefined && fallbacks.title !== undefined) {
|
|
174
|
+
item.title = fallbacks.title;
|
|
175
|
+
}
|
|
176
|
+
if (item.description === undefined && fallbacks.description !== undefined) {
|
|
177
|
+
item.description = fallbacks.description;
|
|
178
|
+
}
|
|
116
179
|
if (excerpt !== undefined) {
|
|
117
180
|
item.excerpt = excerpt;
|
|
118
181
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parser.js","sourceRoot":"","sources":["../../../src/plugin/content/parser.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,aAAa,CAAA;AAChC,OAAO,EAAE,MAAM,EAAc,MAAM,QAAQ,CAAA;AAC3C,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AACtC,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAA;AAG3C,OAAO,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AACnD,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAEhC,iFAAiF;AAEjF,8DAA8D;AAC9D,SAAS,OAAO,CAAC,IAAY;IAC3B,OAAO,IAAI;SACR,WAAW,EAAE;SACb,OAAO,CAAC,WAAW,EAAE,EAAE,CAAC;SACxB,IAAI,EAAE;SACN,OAAO,CAAC,SAAS,EAAE,GAAG,CAAC;SACvB,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAA;AACxB,CAAC;AAED;;;GAGG;AACH,SAAS,eAAe,CAAC,MAAe;IACtC,MAAM,QAAQ,GAAqB,EAAE,CAAA;IAErC,MAAM,IAAI,GAAG,CAAC,SAAkB,EAAE,EAAE;QAClC,KAAK,MAAM,KAAK,IAAI,SAAS,EAAE,CAAC;YAC9B,IAAI,KAAK,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;gBAC7B,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAA;gBACvB,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;gBACxB,QAAQ,CAAC,IAAI,CAAC;oBACZ,KAAK,EAAE,KAAK,CAAC,KAAgC;oBAC7C,EAAE;oBACF,IAAI;iBACL,CAAC,CAAA;YACJ,CAAC;YACD,mDAAmD;YACnD,IAAI,QAAQ,IAAI,KAAK,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC;gBACrD,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAA;YACpB,CAAC;QACH,CAAC;IACH,CAAC,CAAA;IAED,IAAI,CAAC,MAAM,CAAC,CAAA;IACZ,OAAO,QAAQ,CAAA;AACjB,CAAC;AAED,gFAAgF;AAEhF,MAAM,QAAQ,GAAG,IAAI,MAAM,CAAC,QAAQ,EAAE,CAAA;AAEtC,QAAQ,CAAC,OAAO,GAAG,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE;IAC5C,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,CAAC,CAAE,CAAC,CAAC,IAAe,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IAChF,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACxB,MAAM,KAAK,GAAG,KAAgC,CAAA;IAC9C,MAAM,SAAS,GAAG,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAA;IAC3F,OAAO,KAAK,KAAK,QAAQ,EAAE,KAAK,SAAS,MAAM,KAAK,KAAK,CAAA;AAC3D,CAAC,CAAA;AAED,iFAAiF;AAEjF;;;;;;;;;GASG;AACH,SAAS,uBAAuB,CAC9B,IAAiB,EACjB,UAAkB,EAClB,GAAW;IAEX,MAAM,KAAK,GAAG,iBAAiB,CAAC,IAAI,CAAC,QAAQ,EAAE,UAAU,CAAC,CAAA;IAC1D,MAAM,KAAK,GAAG,QAAQ,CAAC,UAAU,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAA;IAEjD,IAAI,IAAI,CAAC,GAAG,KAAK,MAAM,EAAE,CAAC;QACxB,uEAAuE;QACvE,iEAAiE;QACjE,IAAI,CAAC;YACH,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;QACjB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,MAAM,IAAI,KAAK,CACb,iCAAiC,IAAI,CAAC,QAAQ,MAAO,GAAa,CAAC,OAAO,EAAE,CAC7E,CAAA;QACH,CAAC;QACD,OAAO;YACL,KAAK;YACL,KAAK;YACL,KAAK,EAAE,MAAM;YACb,IAAI,EAAE,GAAG;YACT,GAAG,EAAE,EAAE;SACR,CAAA;IACH,CAAC;IAED,4EAA4E;IAC5E,MAAM,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,CAAA;IAC1B,MAAM,WAAW,GAAG,MAAM,CAAC,IAAmB,CAAA;IAC9C,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAA;IAE9B,sCAAsC;IACtC,0EAA0E;IAC1E,0EAA0E;IAC1E,kBAAkB;IAClB,MAAM,WAAW,GAAG,eAAe,CAAA;IACnC,MAAM,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC,WAAW,CAAC,CAAA;IAC9C,MAAM,OAAO,GAAG,SAAS,KAAK,CAAC,CAAC,CAAA;IAChC,MAAM,UAAU,GAAG,OAAO;QACxB,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,SAAS,GAAG,WAAW,CAAC,MAAM,CAAC;QAC7E,CAAC,CAAC,OAAO,CAAA;IACX,MAAM,aAAa,GAAG,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,CAAA;IAEzE,qCAAqC;IACrC,MAAM,KAAK,GAAG,IAAI,MAAM,CAAC,KAAK,EAAE,CAAA;IAChC,MAAM,MAAM,GAAG,KAAK,CAAC,GAAG,CAAC,UAAU,CAAC,CAAA;IACpC,MAAM,GAAG,GAAG,eAAe,CAAC,MAAM,CAAC,CAAA;IAEnC,+DAA+D;IAC/D,MAAM,IAAI,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,EAAE,QAAQ,EAAE,CAAW,CAAA;IAE1D,4BAA4B;IAC5B,MAAM,OAAO,GAAG,aAAa,KAAK,IAAI;QACpC,CAAC,CAAE,MAAM,CAAC,KAAK,CAAC,aAAa,EAAE,EAAE,QAAQ,EAAE,CAAY;QACvD,CAAC,CAAC,SAAS,CAAA;IAEb,MAAM,IAAI,GAAgB;QACxB,GAAG,WAAW;QACd,KAAK;QACL,KAAK;QACL,KAAK,EAAE,UAAU;QACjB,IAAI;QACJ,GAAG;KACJ,CAAA;IAED,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;QAC1B,IAAI,CAAC,OAAO,GAAG,OAAO,CAAA;IACxB,CAAC;IAED,wEAAwE;IACxE,0EAA0E;IAC1E,0EAA0E;IAC1E,6EAA6E;IAC7E,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QACpC,IAAK,IAAgC,CAAC,GAAG,CAAC,YAAY,IAAI,EAAE,CAAC;YAC3D,CAAC;YAAC,IAAgC,CAAC,GAAG,CAAC,GAAK,IAAgC,CAAC,GAAG,CAAU;iBACvF,WAAW,EAAE;iBACb,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAA;QAClB,CAAC;IACH,CAAC;IAED,OAAO,IAAI,CAAA;AACb,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAC9B,IAAiB,EACjB,UAAkB;IAElB,MAAM,GAAG,GAAG,YAAY,CAAC,IAAI,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAA;IAChD,OAAO,uBAAuB,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,CAAC,CAAA;AACvD,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,qBAAqB,CACzC,IAAiB,EACjB,UAAkB;IAElB,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAA;IAClD,OAAO,uBAAuB,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,CAAC,CAAA;AACvD,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,aAAa,CAAC,IAAiB;IAC7C,6DAA6D;IAC7D,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,IAAI,EAAE,GAAG,IAAI,CAAA;IACnD,OAAO,IAAmB,CAAA;AAC5B,CAAC"}
|
|
1
|
+
{"version":3,"file":"parser.js","sourceRoot":"","sources":["../../../src/plugin/content/parser.ts"],"names":[],"mappings":"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"}
|
package/docs/content.md
CHANGED
|
@@ -131,6 +131,23 @@ Recognized frontmatter keys:
|
|
|
131
131
|
|
|
132
132
|
Any additional frontmatter keys are stored verbatim in the `ContentMeta` / `ContentItem` object.
|
|
133
133
|
|
|
134
|
+
### Automatic title and description
|
|
135
|
+
|
|
136
|
+
When `title` or `description` are absent from frontmatter, the parser derives them from the body:
|
|
137
|
+
|
|
138
|
+
- **`title`** — plain text of the first depth-1 heading (`# …`). Only `h1` is considered; `h2`–`h6` are ignored.
|
|
139
|
+
- **`description`** — plain text of the first paragraph, truncated to 160 characters (with `…` appended). Inline formatting is stripped.
|
|
140
|
+
|
|
141
|
+
Frontmatter values always win — these fallbacks only fill the gaps. JSON files do not receive fallbacks (they have no Markdown body to parse from).
|
|
142
|
+
|
|
143
|
+
```md
|
|
144
|
+
# Hello World
|
|
145
|
+
|
|
146
|
+
This becomes the description because no description key is in frontmatter.
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Results in `title: 'Hello World'` and `description: 'This becomes the description because no description key is in frontmatter.'`.
|
|
150
|
+
|
|
134
151
|
### Date-prefixed filenames
|
|
135
152
|
|
|
136
153
|
Filenames starting with `YYYY-MM-DD-` have the date prefix stripped when computing the content path:
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* Content layer e2e tests — exercises queryContent() and useContentSearch().
|
|
3
3
|
*
|
|
4
4
|
* Pages under test:
|
|
5
|
-
* /content-index
|
|
6
|
-
* /content-blog
|
|
7
|
-
* /content-doc
|
|
8
|
-
* /content-search
|
|
5
|
+
* /content-index — queryContent().find() (all content)
|
|
6
|
+
* /content-blog — queryContent('/blog').find() (blog prefix, draft exclusion)
|
|
7
|
+
* /content-doc — queryContent('/docs/getting-started').first() (body + TOC)
|
|
8
|
+
* /content-search — useContentSearch() (MiniSearch, client-side)
|
|
9
|
+
* /content-fallback — title/description derived from body when frontmatter omits them
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
const mode = Cypress.env('mode') as 'spa' | 'ssr' | 'ssg'
|
|
@@ -226,3 +227,65 @@ describe('Content search — useContentSearch()', () => {
|
|
|
226
227
|
cy.get('[data-cy=content-search-result]').should('not.exist')
|
|
227
228
|
})
|
|
228
229
|
})
|
|
230
|
+
|
|
231
|
+
// ─── /content-fallback ────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
describe('Content fallback — title and description derived from body', () => {
|
|
234
|
+
// blog/no-frontmatter.md has no frontmatter at all.
|
|
235
|
+
// Expected derived values:
|
|
236
|
+
// title → "Derived From Body" (first h1)
|
|
237
|
+
// description → "This description is…" (first paragraph, ≤160 chars)
|
|
238
|
+
|
|
239
|
+
if (mode !== 'spa') {
|
|
240
|
+
it('pre-renders the derived title in initial HTML (SSR/SSG)', () => {
|
|
241
|
+
cy.request('/content-fallback').then((response) => {
|
|
242
|
+
expect(response.body).to.include('Derived From Body')
|
|
243
|
+
})
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('pre-renders the derived description in initial HTML (SSR/SSG)', () => {
|
|
247
|
+
cy.request('/content-fallback').then((response) => {
|
|
248
|
+
expect(response.body).to.include('This description is derived from the first paragraph')
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
it('renders the derived title after hydration', () => {
|
|
254
|
+
cy.visit('/content-fallback')
|
|
255
|
+
cy.get('[data-cy=content-fallback-title]', { timeout: 8000 })
|
|
256
|
+
.should('contain', 'Derived From Body')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('renders the derived description after hydration', () => {
|
|
260
|
+
cy.visit('/content-fallback')
|
|
261
|
+
cy.get('[data-cy=content-fallback-desc]', { timeout: 8000 })
|
|
262
|
+
.should('contain', 'This description is derived from the first paragraph')
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('document is found — fallback-missing element is absent', () => {
|
|
266
|
+
cy.visit('/content-fallback')
|
|
267
|
+
cy.get('[data-cy=content-fallback-heading]', { timeout: 8000 }).should('exist')
|
|
268
|
+
cy.get('[data-cy=content-fallback-missing]').should('not.exist')
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('derived item appears in queryContent find() results (content-index)', () => {
|
|
272
|
+
cy.visit('/content-index')
|
|
273
|
+
cy.get('[data-cy=content-item][data-path="/blog/no-frontmatter"]', { timeout: 8000 })
|
|
274
|
+
.should('exist')
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('derived title is shown in the content-index listing', () => {
|
|
278
|
+
cy.visit('/content-index')
|
|
279
|
+
cy.get('[data-cy=content-item][data-path="/blog/no-frontmatter"] [data-cy=content-item-title]', { timeout: 8000 })
|
|
280
|
+
.should('contain', 'Derived From Body')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('derived item appears in search results when searching by derived title', () => {
|
|
284
|
+
cy.intercept('GET', '/_content/search-index.json').as('searchIndex')
|
|
285
|
+
cy.visit('/content-search')
|
|
286
|
+
cy.wait('@searchIndex')
|
|
287
|
+
setSearchQuery('Derived')
|
|
288
|
+
cy.get('[data-cy=content-search-result]', { timeout: 8000 })
|
|
289
|
+
.should('contain', 'Derived From Body')
|
|
290
|
+
})
|
|
291
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content fallback page — exercises title/description derivation from body
|
|
3
|
+
* when frontmatter does not supply them.
|
|
4
|
+
*
|
|
5
|
+
* Fetches /blog/no-frontmatter (no frontmatter at all) and renders its
|
|
6
|
+
* derived title and description so Cypress can assert both in all modes.
|
|
7
|
+
*
|
|
8
|
+
* Route: /content-fallback
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
component('page-content-fallback', () => {
|
|
12
|
+
useHead({ title: 'Content Fallback — Kitchen Sink' })
|
|
13
|
+
|
|
14
|
+
const ssrData = usePageData<{ doc: ContentItem | null }>()
|
|
15
|
+
const doc = ref<ContentItem | null>(ssrData?.doc ?? null)
|
|
16
|
+
|
|
17
|
+
useOnConnected(async () => {
|
|
18
|
+
if (ssrData) return // already hydrated
|
|
19
|
+
doc.value = await queryContent('/blog/no-frontmatter').first()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
return html`
|
|
23
|
+
<div>
|
|
24
|
+
<h1 data-cy="content-fallback-heading">Content Fallback</h1>
|
|
25
|
+
${doc.value ? html`
|
|
26
|
+
<p data-cy="content-fallback-title">${doc.value.title}</p>
|
|
27
|
+
<p data-cy="content-fallback-desc">${doc.value.description}</p>
|
|
28
|
+
` : html`<p data-cy="content-fallback-missing">Document not found.</p>`}
|
|
29
|
+
</div>
|
|
30
|
+
`
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
export const loader = async () => {
|
|
34
|
+
const doc = await queryContent('/blog/no-frontmatter').first()
|
|
35
|
+
return { doc }
|
|
36
|
+
}
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeAll } from 'vitest'
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
2
2
|
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'pathe'
|
|
@@ -62,6 +62,62 @@ Just content, no more marker.
|
|
|
62
62
|
`)
|
|
63
63
|
|
|
64
64
|
writeFileSync(join(contentDir, 'data', 'products.json'), JSON.stringify([{ id: 1, name: 'Widget' }]))
|
|
65
|
+
|
|
66
|
+
// ── Fallback fixtures ────────────────────────────────────────────────────
|
|
67
|
+
// No frontmatter at all — title and description derived from body
|
|
68
|
+
writeFileSync(join(contentDir, 'no-frontmatter.md'), `# Derived Title
|
|
69
|
+
|
|
70
|
+
First paragraph for description.
|
|
71
|
+
|
|
72
|
+
Second paragraph.
|
|
73
|
+
`)
|
|
74
|
+
|
|
75
|
+
// Frontmatter title only — description derived from body
|
|
76
|
+
writeFileSync(join(contentDir, 'title-only.md'), `---
|
|
77
|
+
title: Explicit Title
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
Description will come from this paragraph.
|
|
81
|
+
`)
|
|
82
|
+
|
|
83
|
+
// Frontmatter description only — title derived from h1
|
|
84
|
+
writeFileSync(join(contentDir, 'desc-only.md'), `---
|
|
85
|
+
description: Explicit description
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
# Derived H1 Title
|
|
89
|
+
|
|
90
|
+
Some paragraph.
|
|
91
|
+
`)
|
|
92
|
+
|
|
93
|
+
// Both set in frontmatter — body values must NOT overwrite them
|
|
94
|
+
writeFileSync(join(contentDir, 'both-frontmatter.md'), `---
|
|
95
|
+
title: FM Title
|
|
96
|
+
description: FM Description
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
# Different H1
|
|
100
|
+
|
|
101
|
+
Different paragraph.
|
|
102
|
+
`)
|
|
103
|
+
|
|
104
|
+
// h1 with inline formatting — plain text only
|
|
105
|
+
writeFileSync(join(contentDir, 'formatted-h1.md'), `# Hello **World**
|
|
106
|
+
|
|
107
|
+
Some intro.
|
|
108
|
+
`)
|
|
109
|
+
|
|
110
|
+
// Long paragraph — description truncated to 160 chars + ellipsis
|
|
111
|
+
writeFileSync(join(contentDir, 'long-para.md'), `# Long
|
|
112
|
+
|
|
113
|
+
${'A'.repeat(200)}
|
|
114
|
+
`)
|
|
115
|
+
|
|
116
|
+
// No h1, only h2 — title fallback must remain undefined
|
|
117
|
+
writeFileSync(join(contentDir, 'no-h1.md'), `## Section Only
|
|
118
|
+
|
|
119
|
+
A paragraph here.
|
|
120
|
+
`)
|
|
65
121
|
})
|
|
66
122
|
|
|
67
123
|
function makeFile(filePath: string, ext: 'md' | 'json'): ContentFile {
|
|
@@ -237,3 +293,89 @@ describe('parseContentFile — date normalisation', () => {
|
|
|
237
293
|
expect(date < '2027-01-01').toBe(true)
|
|
238
294
|
})
|
|
239
295
|
})
|
|
296
|
+
|
|
297
|
+
// ─── Fallback title / description ────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
describe('parseContentFile — fallback title from h1', () => {
|
|
300
|
+
it('derives title from h1 when frontmatter title is absent', () => {
|
|
301
|
+
const item = parseContentFile(makeFile(join(contentDir, 'no-frontmatter.md'), 'md'), contentDir)
|
|
302
|
+
expect(item.title).toBe('Derived Title')
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('frontmatter title is not overwritten when present', () => {
|
|
306
|
+
const item = parseContentFile(makeFile(join(contentDir, 'title-only.md'), 'md'), contentDir)
|
|
307
|
+
expect(item.title).toBe('Explicit Title')
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('derives title from h1 when only description is in frontmatter', () => {
|
|
311
|
+
const item = parseContentFile(makeFile(join(contentDir, 'desc-only.md'), 'md'), contentDir)
|
|
312
|
+
expect(item.title).toBe('Derived H1 Title')
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('frontmatter title wins over h1 when both set', () => {
|
|
316
|
+
const item = parseContentFile(makeFile(join(contentDir, 'both-frontmatter.md'), 'md'), contentDir)
|
|
317
|
+
expect(item.title).toBe('FM Title')
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('strips inline formatting — title is plain text', () => {
|
|
321
|
+
const item = parseContentFile(makeFile(join(contentDir, 'formatted-h1.md'), 'md'), contentDir)
|
|
322
|
+
expect(item.title).toBe('Hello World')
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
it('does not derive title from h2 — must be h1 only', () => {
|
|
326
|
+
const item = parseContentFile(makeFile(join(contentDir, 'no-h1.md'), 'md'), contentDir)
|
|
327
|
+
expect(item.title).toBeUndefined()
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
describe('parseContentFile — fallback description from first paragraph', () => {
|
|
332
|
+
it('derives description from first paragraph when frontmatter description is absent', () => {
|
|
333
|
+
const item = parseContentFile(makeFile(join(contentDir, 'no-frontmatter.md'), 'md'), contentDir)
|
|
334
|
+
expect(item.description).toBe('First paragraph for description.')
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('frontmatter description is not overwritten when present', () => {
|
|
338
|
+
const item = parseContentFile(makeFile(join(contentDir, 'desc-only.md'), 'md'), contentDir)
|
|
339
|
+
expect(item.description).toBe('Explicit description')
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it('derives description from first paragraph when only title is in frontmatter', () => {
|
|
343
|
+
const item = parseContentFile(makeFile(join(contentDir, 'title-only.md'), 'md'), contentDir)
|
|
344
|
+
expect(item.description).toBe('Description will come from this paragraph.')
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('frontmatter description wins when both set', () => {
|
|
348
|
+
const item = parseContentFile(makeFile(join(contentDir, 'both-frontmatter.md'), 'md'), contentDir)
|
|
349
|
+
expect(item.description).toBe('FM Description')
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('truncates long paragraphs to 160 chars with ellipsis', () => {
|
|
353
|
+
const item = parseContentFile(makeFile(join(contentDir, 'long-para.md'), 'md'), contentDir)
|
|
354
|
+
expect(typeof item.description).toBe('string')
|
|
355
|
+
expect((item.description as string).length).toBeLessThanOrEqual(164) // 160 + '…' (3 bytes)
|
|
356
|
+
expect(item.description as string).toMatch(/…$/)
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
it('derived description is on ContentMeta (present in manifest)', async () => {
|
|
360
|
+
const { toContentMeta } = await import('../../../plugin/content/parser.js')
|
|
361
|
+
const item = parseContentFile(makeFile(join(contentDir, 'no-frontmatter.md'), 'md'), contentDir)
|
|
362
|
+
const meta = toContentMeta(item)
|
|
363
|
+
expect(meta.description).toBe('First paragraph for description.')
|
|
364
|
+
})
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
describe('parseContentFile — JSON fallback', () => {
|
|
368
|
+
it('does not apply title/description fallbacks to JSON files', () => {
|
|
369
|
+
// JSON files have no markdown body to extract from
|
|
370
|
+
const item = parseContentFile(
|
|
371
|
+
makeFile(join(contentDir, 'data', 'products.json'), 'json'),
|
|
372
|
+
contentDir,
|
|
373
|
+
)
|
|
374
|
+
expect(item.title).toBeUndefined()
|
|
375
|
+
expect(item.description).toBeUndefined()
|
|
376
|
+
})
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
afterAll(() => {
|
|
380
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
381
|
+
})
|
|
@@ -48,6 +48,62 @@ function extractHeadings(tokens: Token[]): ContentHeading[] {
|
|
|
48
48
|
return headings
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// ─── Fallback title / description extraction ─────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Converts a list of inline marked tokens to plain text by recursing into
|
|
55
|
+
* formatted tokens (strong, em, link, etc.) and collecting their leaf text.
|
|
56
|
+
* Used to derive readable fallback values from body content.
|
|
57
|
+
*/
|
|
58
|
+
function inlineToPlainText(tokens: Token[]): string {
|
|
59
|
+
let result = ''
|
|
60
|
+
for (const t of tokens) {
|
|
61
|
+
const children = (t as { tokens?: Token[] }).tokens
|
|
62
|
+
if (Array.isArray(children) && children.length > 0) {
|
|
63
|
+
result += inlineToPlainText(children)
|
|
64
|
+
} else if (t.type === 'br') {
|
|
65
|
+
result += ' '
|
|
66
|
+
} else if ('text' in t && typeof (t as { text: string }).text === 'string') {
|
|
67
|
+
result += (t as { text: string }).text
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return result
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const DESCRIPTION_MAX_LEN = 160
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Scans the top-level token list for a fallback `title` (first depth-1 heading)
|
|
77
|
+
* and `description` (first paragraph). Both are `undefined` when no matching
|
|
78
|
+
* token is found.
|
|
79
|
+
*
|
|
80
|
+
* These are applied only when the corresponding frontmatter field is absent, so
|
|
81
|
+
* frontmatter always wins.
|
|
82
|
+
*/
|
|
83
|
+
function extractFallbacks(tokens: Token[]): { title?: string; description?: string } {
|
|
84
|
+
let title: string | undefined
|
|
85
|
+
let description: string | undefined
|
|
86
|
+
|
|
87
|
+
for (const token of tokens) {
|
|
88
|
+
if (title === undefined && token.type === 'heading' && token.depth === 1) {
|
|
89
|
+
const text = inlineToPlainText((token as { tokens: Token[] }).tokens ?? []).trim()
|
|
90
|
+
if (text) title = text
|
|
91
|
+
}
|
|
92
|
+
if (description === undefined && token.type === 'paragraph') {
|
|
93
|
+
const text = inlineToPlainText((token as { tokens: Token[] }).tokens ?? []).trim()
|
|
94
|
+
if (text) {
|
|
95
|
+
description =
|
|
96
|
+
text.length > DESCRIPTION_MAX_LEN
|
|
97
|
+
? text.slice(0, DESCRIPTION_MAX_LEN).trimEnd() + '…'
|
|
98
|
+
: text
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (title !== undefined && description !== undefined) break
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { title, description }
|
|
105
|
+
}
|
|
106
|
+
|
|
51
107
|
// ─── Custom renderer: add id to heading tags ─────────────────────────────────
|
|
52
108
|
|
|
53
109
|
const renderer = new marked.Renderer()
|
|
@@ -129,6 +185,10 @@ function parseContentFileFromRaw(
|
|
|
129
185
|
? (marked.parse(excerptSource, { renderer }) as string)
|
|
130
186
|
: undefined
|
|
131
187
|
|
|
188
|
+
// Derive fallback title / description from body tokens when frontmatter
|
|
189
|
+
// does not provide them. Frontmatter always wins — these only fill gaps.
|
|
190
|
+
const fallbacks = extractFallbacks(tokens)
|
|
191
|
+
|
|
132
192
|
const item: ContentItem = {
|
|
133
193
|
...frontmatter,
|
|
134
194
|
_path,
|
|
@@ -138,6 +198,13 @@ function parseContentFileFromRaw(
|
|
|
138
198
|
toc,
|
|
139
199
|
}
|
|
140
200
|
|
|
201
|
+
if (item.title === undefined && fallbacks.title !== undefined) {
|
|
202
|
+
item.title = fallbacks.title
|
|
203
|
+
}
|
|
204
|
+
if (item.description === undefined && fallbacks.description !== undefined) {
|
|
205
|
+
item.description = fallbacks.description
|
|
206
|
+
}
|
|
207
|
+
|
|
141
208
|
if (excerpt !== undefined) {
|
|
142
209
|
item.excerpt = excerpt
|
|
143
210
|
}
|