@jasonshimmy/vite-plugin-cer-app 0.20.5 → 0.21.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/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.21.0] - 2026-04-12
5
+
6
+ - feat: add support for numeric ordering prefixes in content paths and update related tests (ffb0bc8)
7
+
4
8
  ## [v0.20.5] - 2026-04-12
5
9
 
6
10
  - fix: enhance hydration handling and data caching in usePageData and routing middleware (305ae7f)
package/README.md CHANGED
@@ -210,6 +210,28 @@ component('page-about', () => {
210
210
 
211
211
  ---
212
212
 
213
+ ## Content Layer
214
+
215
+ Drop Markdown and JSON files into `content/` and query them with `queryContent()`.
216
+
217
+ Numeric ordering prefixes are supported on both directories and files. A leading `NN.` is stripped from the public content path, which lets you keep source-tree ordering without leaking the prefix into URLs:
218
+
219
+ ```text
220
+ content/
221
+ 01.docs/
222
+ 01.getting-started.md -> /docs/getting-started
223
+ 02.routing.md -> /docs/routing
224
+ 02.blog/
225
+ 01.index.md -> /blog
226
+ 02.2026-04-01-hello.md -> /blog/hello
227
+ ```
228
+
229
+ Date-prefixed filenames still work the same way after the numeric prefix is removed.
230
+
231
+ See [docs/content.md](docs/content.md) for the full content-layer API and examples.
232
+
233
+ ---
234
+
213
235
  ## Documentation
214
236
 
215
237
  | Guide | Description |
package/commits.txt CHANGED
@@ -1 +1 @@
1
- - fix: enhance hydration handling and data caching in usePageData and routing middleware (305ae7f)
1
+ - feat: add support for numeric ordering prefixes in content paths and update related tests (ffb0bc8)
@@ -4,12 +4,15 @@
4
4
  * Rules:
5
5
  * - Strip the content dir prefix
6
6
  * - Strip the file extension
7
+ * - Strip `NN.` numeric ordering prefixes from all path segments
7
8
  * - Strip `/index` suffix (so blog/index.md → /blog)
8
9
  * - Strip `YYYY-MM-DD-` date prefix from the final slug segment
9
10
  *
10
11
  * Examples:
11
12
  * index.md → /
13
+ * 01.about.md → /about
12
14
  * about.md → /about
15
+ * 01.blog/02.hello.md → /blog/hello
13
16
  * blog/index.md → /blog
14
17
  * blog/2026-04-03-hello.md → /blog/hello
15
18
  * docs/getting-started.md → /docs/getting-started
@@ -1 +1 @@
1
- {"version":3,"file":"path-utils.d.ts","sourceRoot":"","sources":["../../../src/plugin/content/path-utils.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAyB9E"}
1
+ {"version":3,"file":"path-utils.d.ts","sourceRoot":"","sources":["../../../src/plugin/content/path-utils.ts"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CA0B9E"}
@@ -1,16 +1,22 @@
1
1
  import { relative } from 'pathe';
2
+ function stripNumericPrefix(segment) {
3
+ return segment.replace(/^\d+\./, '');
4
+ }
2
5
  /**
3
6
  * Maps a content file path to a `_path` URL path.
4
7
  *
5
8
  * Rules:
6
9
  * - Strip the content dir prefix
7
10
  * - Strip the file extension
11
+ * - Strip `NN.` numeric ordering prefixes from all path segments
8
12
  * - Strip `/index` suffix (so blog/index.md → /blog)
9
13
  * - Strip `YYYY-MM-DD-` date prefix from the final slug segment
10
14
  *
11
15
  * Examples:
12
16
  * index.md → /
17
+ * 01.about.md → /about
13
18
  * about.md → /about
19
+ * 01.blog/02.hello.md → /blog/hello
14
20
  * blog/index.md → /blog
15
21
  * blog/2026-04-03-hello.md → /blog/hello
16
22
  * docs/getting-started.md → /docs/getting-started
@@ -21,8 +27,9 @@ export function fileToContentPath(filePath, contentDir) {
21
27
  let rel = relative(contentDir, filePath);
22
28
  rel = rel.replace(/\.(md|json)$/, '');
23
29
  // Split into segments
24
- const segments = rel.split('/');
25
- // Strip date prefix (YYYY-MM-DD-) from the last segment
30
+ const segments = rel.split('/').map(stripNumericPrefix);
31
+ // Strip date prefix (YYYY-MM-DD-) from the last segment after removing any
32
+ // numeric ordering prefix (for example 01.2026-04-03-hello.md → /hello).
26
33
  const last = segments[segments.length - 1];
27
34
  const stripped = last.replace(/^\d{4}-\d{2}-\d{2}-/, '');
28
35
  segments[segments.length - 1] = stripped;
@@ -1 +1 @@
1
- {"version":3,"file":"path-utils.js","sourceRoot":"","sources":["../../../src/plugin/content/path-utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAEhC;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,iBAAiB,CAAC,QAAgB,EAAE,UAAkB;IACpE,mDAAmD;IACnD,IAAI,GAAG,GAAG,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAA;IACxC,GAAG,GAAG,GAAG,CAAC,OAAO,CAAC,cAAc,EAAE,EAAE,CAAC,CAAA;IAErC,sBAAsB;IACtB,MAAM,QAAQ,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;IAE/B,wDAAwD;IACxD,MAAM,IAAI,GAAG,QAAQ,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;IAC1C,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,qBAAqB,EAAE,EAAE,CAAC,CAAA;IACxD,QAAQ,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,QAAQ,CAAA;IAExC,+DAA+D;IAC/D,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,IAAI,QAAQ,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,KAAK,OAAO,EAAE,CAAC;QACrE,QAAQ,CAAC,GAAG,EAAE,CAAA;IAChB,CAAC;IAED,qDAAqD;IACrD,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,IAAI,QAAQ,CAAC,CAAC,CAAC,KAAK,OAAO,EAAE,CAAC;QACrD,OAAO,GAAG,CAAA;IACZ,CAAC;IAED,MAAM,IAAI,GAAG,GAAG,GAAG,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;IACrC,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;AAClC,CAAC"}
1
+ {"version":3,"file":"path-utils.js","sourceRoot":"","sources":["../../../src/plugin/content/path-utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAEhC,SAAS,kBAAkB,CAAC,OAAe;IACzC,OAAO,OAAO,CAAC,OAAO,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAA;AACtC,CAAC;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,UAAU,iBAAiB,CAAC,QAAgB,EAAE,UAAkB;IACpE,mDAAmD;IACnD,IAAI,GAAG,GAAG,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAA;IACxC,GAAG,GAAG,GAAG,CAAC,OAAO,CAAC,cAAc,EAAE,EAAE,CAAC,CAAA;IAErC,sBAAsB;IACtB,MAAM,QAAQ,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAA;IAEvD,2EAA2E;IAC3E,yEAAyE;IACzE,MAAM,IAAI,GAAG,QAAQ,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;IAC1C,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,qBAAqB,EAAE,EAAE,CAAC,CAAA;IACxD,QAAQ,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,QAAQ,CAAA;IAExC,+DAA+D;IAC/D,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC,IAAI,QAAQ,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,KAAK,OAAO,EAAE,CAAC;QACrE,QAAQ,CAAC,GAAG,EAAE,CAAA;IAChB,CAAC;IAED,qDAAqD;IACrD,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC,IAAI,QAAQ,CAAC,CAAC,CAAC,KAAK,OAAO,EAAE,CAAC;QACrD,OAAO,GAAG,CAAA;IACZ,CAAC;IAED,MAAM,IAAI,GAAG,GAAG,GAAG,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;IACrC,OAAO,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;AAClC,CAAC"}
package/docs/content.md CHANGED
@@ -28,6 +28,15 @@ content/
28
28
  getting-started.md
29
29
  ```
30
30
 
31
+ Numeric ordering prefixes are also supported on both directories and files. A leading `NN.` is used for ordering in the source tree but stripped from the public content path:
32
+
33
+ ```
34
+ content/
35
+ 01.docs/
36
+ 01.getting-started.md -> /docs/getting-started
37
+ 02.routing.md -> /docs/routing
38
+ ```
39
+
31
40
  ### 2. Query content in a page
32
41
 
33
42
  ```ts
@@ -145,6 +154,17 @@ Filenames starting with `YYYY-MM-DD-` have the date prefix stripped when computi
145
154
  content/blog/2026-04-01-hello.md → _path: '/blog/hello'
146
155
  ```
147
156
 
157
+ ### Numeric ordering prefixes
158
+
159
+ Directories and filenames starting with `NN.` have that numeric prefix stripped from the computed content path. This lets you control source-tree ordering without exposing the prefix in URLs:
160
+
161
+ ```
162
+ content/01.docs/02.getting-started.md → _path: '/docs/getting-started'
163
+ content/02.blog/01.index.md → _path: '/blog'
164
+ ```
165
+
166
+ Numeric prefixes are removed from every path segment before the usual `index` and date-prefix handling runs.
167
+
148
168
  ### Index files
149
169
 
150
170
  Files named `index.md` have `/index` stripped from their path:
@@ -341,6 +361,68 @@ export const loader = async () => {
341
361
  }
342
362
  ```
343
363
 
364
+ ### Common pattern: catch-all content route
365
+
366
+ Content-driven apps often use a catch-all page to resolve the current URL to a content document.
367
+
368
+ ```ts
369
+ // app/pages/[...all].ts
370
+ component('page-all', () => {
371
+ const props = useProps({ all: '' })
372
+ const ssrData = usePageData<{ doc: ContentItem | null }>()
373
+ const doc = ref<ContentItem | null>(ssrData?.doc ?? null)
374
+
375
+ const contentPath = normalizeContentPath(props.all)
376
+
377
+ useHead({
378
+ title: doc.value?.title ?? 'Not found',
379
+ meta: doc.value?.description
380
+ ? [{ name: 'description', content: doc.value.description }]
381
+ : [],
382
+ })
383
+
384
+ useOnConnected(async () => {
385
+ if (ssrData) return
386
+ doc.value = await queryContent(contentPath).first()
387
+ })
388
+
389
+ return html`
390
+ <article class="prose">
391
+ ${
392
+ !doc.value
393
+ ? html`
394
+ <h1>404</h1>
395
+ <p>No content found for <code>${contentPath}</code>.</p>
396
+ `
397
+ : doc.value._type === 'json'
398
+ ? html`
399
+ <h1>${doc.value.title ?? contentPath}</h1>
400
+ <pre>${doc.value.body}</pre>
401
+ `
402
+ : html`
403
+ <h1>${doc.value.title ?? contentPath}</h1>
404
+ ${doc.value.description ? html`<p>${doc.value.description}</p>` : ''}
405
+ ${unsafeHTML(doc.value.body)}
406
+ `
407
+ }
408
+ </article>
409
+ `
410
+ })
411
+
412
+ export const loader = async ({ params }) => {
413
+ const contentPath = normalizeContentPath(params.all)
414
+ const doc = await queryContent(contentPath).first()
415
+ return { doc }
416
+ }
417
+
418
+ function normalizeContentPath(all: string | undefined) {
419
+ const slug = String(all ?? '').replace(/^\/+|\/+$/g, '')
420
+ return slug ? `/${slug}` : '/'
421
+ }
422
+ ```
423
+
424
+ This pattern works well for documentation sites, blogs, and other apps where the route structure mirrors the `content/` directory. `queryContent('/docs/getting-started').first()` returns the full `ContentItem`, including `body`, `excerpt`, and `toc`.
425
+
344
426
  ---
345
427
 
346
428
  ## `useContentSearch()`
@@ -5,6 +5,7 @@
5
5
  * /content-index — queryContent().find() (all content)
6
6
  * /content-blog — queryContent('/blog').find() (blog prefix, draft exclusion)
7
7
  * /content-doc — queryContent('/docs/getting-started').first() (body + TOC)
8
+ * /content-guides — queryContent('/guides').find() (numeric dir/file prefixes)
8
9
  * /content-search — useContentSearch() (MiniSearch, client-side)
9
10
  * /content-fallback — title/description derived from body when frontmatter omits them
10
11
  */
@@ -157,6 +158,42 @@ describe('Content doc — queryContent("/docs/getting-started").first()', () =>
157
158
  })
158
159
  })
159
160
 
161
+ // ─── /content-guides ─────────────────────────────────────────────────────────
162
+
163
+ describe('Content guides — numeric directory and file prefixes', () => {
164
+ if (mode !== 'spa') {
165
+ it('pre-renders stripped guide paths and titles in initial HTML (SSR/SSG)', () => {
166
+ cy.request('/content-guides').then((response) => {
167
+ expect(response.body).to.include('Guides Home')
168
+ expect(response.body).to.include('Intro Guide')
169
+ expect(response.body).to.include('Advanced Guide')
170
+ expect(response.body).to.include('data-path="/guides"')
171
+ expect(response.body).to.include('data-path="/guides/intro"')
172
+ expect(response.body).to.include('data-path="/guides/advanced"')
173
+ expect(response.body).not.to.include('01.guides')
174
+ expect(response.body).not.to.include('/guides/02.intro')
175
+ })
176
+ })
177
+ }
178
+
179
+ it('renders guide items after hydration with stripped public paths', () => {
180
+ cy.visit('/content-guides')
181
+ cy.get('[data-cy=content-guides-item]', { timeout: 8000 }).should('have.length', 3)
182
+ cy.get('[data-cy=content-guides-item]').then(($items) => {
183
+ const paths = [...$items].map((el) => el.getAttribute('data-path'))
184
+ expect(paths).to.deep.equal(['/guides', '/guides/intro', '/guides/advanced'])
185
+ })
186
+ })
187
+
188
+ it('preserves numeric file ordering in queryContent results', () => {
189
+ cy.visit('/content-guides')
190
+ cy.get('[data-cy=content-guides-title]', { timeout: 8000 }).then(($titles) => {
191
+ const titles = [...$titles].map((el) => el.textContent?.trim())
192
+ expect(titles).to.deep.equal(['Guides Home', 'Intro Guide', 'Advanced Guide'])
193
+ })
194
+ })
195
+ })
196
+
160
197
  // ─── /content-search ──────────────────────────────────────────────────────────
161
198
 
162
199
  // Helper: set the search input value and fire the input event.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Content guides listing page — exercises numeric directory/file prefixes in
3
+ * the content layer using `queryContent('/guides').find()`.
4
+ * Route: /content-guides
5
+ */
6
+
7
+ component('page-content-guides', () => {
8
+ useHead({ title: 'Content Guides — Kitchen Sink' })
9
+
10
+ const ssrData = usePageData<{ guides: ContentMeta[] }>()
11
+ const guides = ref<ContentMeta[]>(ssrData?.guides ?? [])
12
+
13
+ useOnConnected(async () => {
14
+ if (ssrData) return
15
+ guides.value = await queryContent('/guides').find()
16
+ })
17
+
18
+ return html`
19
+ <div>
20
+ <h1 data-cy="content-guides-heading">Guides</h1>
21
+ <ul data-cy="content-guides-list">
22
+ ${guides.value.map((guide, index) => html`
23
+ <li data-cy="content-guides-item" data-path="${guide._path}" data-index="${index}">
24
+ <strong data-cy="content-guides-title">${guide.title ?? guide._path}</strong>
25
+ </li>
26
+ `)}
27
+ </ul>
28
+ </div>
29
+ `
30
+ })
31
+
32
+ export const loader = async () => {
33
+ const guides = await queryContent('/guides').find()
34
+ return { guides }
35
+ }
@@ -0,0 +1,8 @@
1
+ ---
2
+ title: Guides Home
3
+ description: Landing page for the guides section.
4
+ ---
5
+
6
+ # Guides Home
7
+
8
+ Welcome to the ordered guides section.
@@ -0,0 +1,8 @@
1
+ ---
2
+ title: Intro Guide
3
+ description: The first ordered guide.
4
+ ---
5
+
6
+ # Intro Guide
7
+
8
+ This guide should appear before the advanced guide.
@@ -0,0 +1,8 @@
1
+ ---
2
+ title: Advanced Guide
3
+ description: The later ordered guide.
4
+ ---
5
+
6
+ # Advanced Guide
7
+
8
+ This guide should appear after the intro guide.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jasonshimmy/vite-plugin-cer-app",
3
- "version": "0.20.5",
3
+ "version": "0.21.0",
4
4
  "description": "Nuxt-style meta-framework for @jasonshimmy/custom-elements-runtime",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -10,18 +10,34 @@ describe('fileToContentPath', () => {
10
10
  expect(fileToContentPath(`${DIR}/index.md`, DIR)).toBe('/')
11
11
  })
12
12
 
13
+ it('strips numeric prefix from a root-level file segment', () => {
14
+ expect(fileToContentPath(`${DIR}/01.about.md`, DIR)).toBe('/about')
15
+ })
16
+
13
17
  it('maps about.md to /about', () => {
14
18
  expect(fileToContentPath(`${DIR}/about.md`, DIR)).toBe('/about')
15
19
  })
16
20
 
21
+ it('strips numeric prefixes from directory segments', () => {
22
+ expect(fileToContentPath(`${DIR}/01.docs/02.getting-started.md`, DIR)).toBe('/docs/getting-started')
23
+ })
24
+
17
25
  it('maps blog/index.md to /blog', () => {
18
26
  expect(fileToContentPath(`${DIR}/blog/index.md`, DIR)).toBe('/blog')
19
27
  })
20
28
 
29
+ it('strips numeric prefix before handling index files', () => {
30
+ expect(fileToContentPath(`${DIR}/01.blog/02.index.md`, DIR)).toBe('/blog')
31
+ })
32
+
21
33
  it('maps blog/2026-04-03-hello.md to /blog/hello (strips date prefix)', () => {
22
34
  expect(fileToContentPath(`${DIR}/blog/2026-04-03-hello.md`, DIR)).toBe('/blog/hello')
23
35
  })
24
36
 
37
+ it('strips numeric prefix before date prefix on the final segment', () => {
38
+ expect(fileToContentPath(`${DIR}/blog/01.2026-04-03-hello.md`, DIR)).toBe('/blog/hello')
39
+ })
40
+
25
41
  it('maps docs/getting-started.md to /docs/getting-started', () => {
26
42
  expect(fileToContentPath(`${DIR}/docs/getting-started.md`, DIR)).toBe('/docs/getting-started')
27
43
  })
@@ -1,17 +1,24 @@
1
1
  import { relative } from 'pathe'
2
2
 
3
+ function stripNumericPrefix(segment: string): string {
4
+ return segment.replace(/^\d+\./, '')
5
+ }
6
+
3
7
  /**
4
8
  * Maps a content file path to a `_path` URL path.
5
9
  *
6
10
  * Rules:
7
11
  * - Strip the content dir prefix
8
12
  * - Strip the file extension
13
+ * - Strip `NN.` numeric ordering prefixes from all path segments
9
14
  * - Strip `/index` suffix (so blog/index.md → /blog)
10
15
  * - Strip `YYYY-MM-DD-` date prefix from the final slug segment
11
16
  *
12
17
  * Examples:
13
18
  * index.md → /
19
+ * 01.about.md → /about
14
20
  * about.md → /about
21
+ * 01.blog/02.hello.md → /blog/hello
15
22
  * blog/index.md → /blog
16
23
  * blog/2026-04-03-hello.md → /blog/hello
17
24
  * docs/getting-started.md → /docs/getting-started
@@ -23,9 +30,10 @@ export function fileToContentPath(filePath: string, contentDir: string): string
23
30
  rel = rel.replace(/\.(md|json)$/, '')
24
31
 
25
32
  // Split into segments
26
- const segments = rel.split('/')
33
+ const segments = rel.split('/').map(stripNumericPrefix)
27
34
 
28
- // Strip date prefix (YYYY-MM-DD-) from the last segment
35
+ // Strip date prefix (YYYY-MM-DD-) from the last segment after removing any
36
+ // numeric ordering prefix (for example 01.2026-04-03-hello.md → /hello).
29
37
  const last = segments[segments.length - 1]
30
38
  const stripped = last.replace(/^\d{4}-\d{2}-\d{2}-/, '')
31
39
  segments[segments.length - 1] = stripped