Skip to content

Commit c4bd3a5

Browse files
committed
feat: excerpt & metadata schema
1 parent 4fc73d6 commit c4bd3a5

File tree

9 files changed

+197
-91
lines changed

9 files changed

+197
-91
lines changed

example/content/posts/1970-01-01-style-guide/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ Below is just about everything you’ll need to style in the theme. Check the so
1313

1414
# Heading 1
1515

16+
<!-- more -->
17+
1618
Doloremque dolor voluptas est sequi omnis. Pariatur ut aut. Sed enim tempora qui veniam qui cum vel. Voluptas odit at vitae minima. In assumenda ut. Voluptatem totam impedit accusantium reiciendis excepturi aut qui accusamus praesentium.
1719

1820
## Heading 2

example/velite.config.ts

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { defineConfig, s } from 'velite'
22

3-
const slugify = input =>
3+
const slugify = (input: string) =>
44
input
55
.toLowerCase()
66
.replace(/\s+/g, '-')
@@ -9,6 +9,14 @@ const slugify = input =>
99
const icon = s.enum(['github', 'instagram', 'medium', 'twitter', 'youtube'])
1010
const count = s.object({ total: s.number(), posts: s.number() }).default({ total: 0, posts: 0 })
1111

12+
const meta = s
13+
.object({
14+
title: s.string().optional(),
15+
description: s.string().optional(),
16+
keywords: s.array(s.string()).optional()
17+
})
18+
.default({})
19+
1220
export default defineConfig({
1321
root: 'content',
1422
output: {
@@ -24,9 +32,9 @@ export default defineConfig({
2432
pattern: 'options/index.yml',
2533
single: true,
2634
fields: s.object({
27-
name: s.name(),
28-
title: s.title(),
29-
description: s.paragraph().optional(),
35+
name: s.string().max(20),
36+
title: s.string().max(99),
37+
description: s.string().max(999).optional(),
3038
keywords: s.array(s.string()),
3139
author: s.object({ name: s.string(), email: s.string().email(), url: s.string().url() }),
3240
links: s.array(s.object({ text: s.string(), link: s.string(), type: s.enum(['navigation', 'footer', 'copyright']) })),
@@ -38,10 +46,10 @@ export default defineConfig({
3846
pattern: 'categories/*.yml',
3947
fields: s
4048
.object({
41-
name: s.name(),
49+
name: s.string().max(20),
4250
slug: s.slug('global'),
4351
cover: s.image().optional(),
44-
description: s.paragraph().optional(),
52+
description: s.string().max(999).optional(),
4553
count
4654
})
4755
.transform(data => ({ ...data, permalink: `/${data.slug}` }))
@@ -51,44 +59,47 @@ export default defineConfig({
5159
pattern: 'tags/index.yml',
5260
fields: s
5361
.object({
54-
name: s.name(),
62+
name: s.string().max(20),
5563
slug: s.slug('global'),
5664
cover: s.image().optional(),
57-
description: s.paragraph().optional(),
65+
description: s.string().max(999).optional(),
5866
count
5967
})
6068
.transform(data => ({ ...data, permalink: `/${data.slug}` }))
6169
},
62-
// pages: {
63-
// name: 'Page',
64-
// pattern: 'pages/**/*.mdx',
65-
// fields: s
66-
// .object({
67-
// title: s.title(),
68-
// slug: s.slug('post'),
69-
// body: s.mdx()
70-
// })
71-
// .transform(data => ({ ...data, permalink: `/${data.slug}/${data.slug}` }))
72-
// },
70+
pages: {
71+
name: 'Page',
72+
pattern: 'pages/**/*.mdx',
73+
fields: s
74+
.object({
75+
title: s.string().max(99),
76+
slug: s.slug('global'),
77+
body: s.markdown()
78+
})
79+
.transform(data => ({ ...data, permalink: `/${data.slug}` }))
80+
},
7381
posts: {
7482
name: 'Post',
7583
pattern: 'posts/**/*.md',
7684
fields: s
7785
.object({
78-
title: s.title(),
86+
title: s.string().max(99),
7987
slug: s.slug('post'),
8088
date: s.isodate(),
8189
updated: s.isodate().optional(),
8290
cover: s.image().optional(),
83-
description: s.paragraph().optional(),
91+
description: s.string().max(999).optional(),
8492
draft: s.boolean().default(false),
8593
featured: s.boolean().default(false),
8694
categories: s.array(s.string()).default(['Journal']),
8795
tags: s.array(s.string()).default([]),
88-
meta: s.meta(),
89-
body: s.markdown()
96+
meta: meta,
97+
metadata: s.metadata({ age: 20 }),
98+
summary: s.excerpt({ length: 100 }),
99+
excerpt: s.excerpt({ separator: 'more', format: 'html' }),
100+
content: s.markdown()
90101
})
91-
.transform(data => ({ ...data, permalink: `/${data.slug}/${data.slug}` }))
102+
.transform(data => ({ ...data, permalink: `/blog/${data.slug}` }))
92103
}
93104
},
94105
onSuccess: ({ categories, tags, posts }) => {

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@
4747
"cac": "latest",
4848
"esbuild": "latest",
4949
"fast-glob": "latest",
50+
"hast-util-excerpt": "latest",
51+
"hast-util-reading-time": "latest",
52+
"hast-util-truncate": "latest",
5053
"micromatch": "latest",
5154
"rehype-raw": "latest",
5255
"rehype-stringify": "latest",

readme.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ However, I have provided a complete [example](example) for your reference.
1818
- [x] markdown & yaml & json built-in support
1919
- [x] remark plugins & rehype plugins
2020
- [x] watch
21-
- [ ] excerpt & plain
21+
- [x] excerpt ~~& plain~~
22+
- [x] metadata field (reading-time, ~~word-count, etc.~~)
23+
- [ ] types generate
2224
- [ ] example with nextjs
23-
- [ ] metadata field (reading-time, word-count, etc.)
2425
- [ ] mdx
2526
- [ ] reference parent
2627
- [ ] nextjs plugin
27-
- [ ] types generate
2828
- [ ] image command (compress, resize, etc.)
2929
- [ ] docs
3030

src/loaders/markdown.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,21 @@ export default defineLoader({
1010
name: 'markdown',
1111
test: /\.(md|mdx)$/,
1212
load: async vfile => {
13-
const content = vfile.toString()
13+
const raw = vfile.toString()
1414
// https://github.com/vfile/vfile-matter/blob/main/lib/index.js
15-
const match = content.match(/^---(?:\r?\n|\r)(?:([\s\S]*?)(?:\r?\n|\r))?---(?:\r?\n|\r|$)/)
16-
if (match == null) {
17-
return { body: content }
18-
}
19-
20-
// TODO: output file meta data for later use
21-
22-
const data = yaml.parse(match[1])
23-
const raw = content.slice(match[0].length).trim()
24-
// keep original content with multiple keys in vfile.data for later use
25-
return Object.assign(data, { raw, excerpt: raw, plain: raw, html: raw, body: raw, code: raw })
15+
const match = raw.match(/^---(?:\r?\n|\r)(?:([\s\S]*?)(?:\r?\n|\r))?---(?:\r?\n|\r|$)/)
16+
const data = match == null ? {} : yaml.parse(match[1])
17+
// data._file = vfile // output vfile for later use?
18+
const body = match == null ? raw : raw.slice(match[0].length).trim()
19+
// keep raw body with multiple keys (may be used) for later use
20+
data.metadata = body // for extract metadata (reading-time, word-count, etc.)
21+
data.body = body // for extract body content
22+
data.content = body // for extract content
23+
data.summary = body // for extract summary
24+
data.excerpt = body // for extract excerpt
25+
data.plain = body // for extract plain text
26+
data.html = body // for markdown render
27+
data.code = body // for mdx render
28+
return data
2629
}
2730
})

src/plugins/rehype-extract-excerpt.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,32 @@
1+
import { excerpt } from 'hast-util-excerpt'
2+
import { truncate } from 'hast-util-truncate'
13
import { visit } from 'unist-util-visit'
24

35
import type { Root } from 'hast'
46
import type { Plugin } from 'unified'
57

6-
const extractExcerpt: Plugin<[], Root> = () => (tree, file) => {
7-
const lines: string[] = []
8-
visit(tree, 'text', node => {
9-
lines.push(node.value)
10-
})
11-
// extract plain
12-
const plain = lines.join('').trim()
13-
// extract excerpt
14-
const excerpt = plain.slice(0, 100)
15-
Object.assign(file.data, { plain, excerpt })
8+
interface ExtractExcerptOptions {
9+
separator?: string
10+
length?: number
1611
}
1712

13+
const extractExcerpt: Plugin<[ExtractExcerptOptions], Root> =
14+
({ separator, length }) =>
15+
(tree, file) => {
16+
if (separator != null) {
17+
tree = excerpt(tree, { comment: separator }) ?? tree
18+
} else if (length != null) {
19+
tree = truncate(tree, { size: length, ellipsis: '…' })
20+
}
21+
22+
const lines: string[] = []
23+
visit(tree, 'text', node => {
24+
lines.push(node.value)
25+
})
26+
27+
Object.assign(file.data, { plain: lines.join('').trim() })
28+
29+
return tree
30+
}
31+
1832
export default extractExcerpt

src/plugins/rehype-metadata.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { readingTime } from 'hast-util-reading-time'
2+
3+
import type { Root } from 'hast'
4+
import type { Plugin } from 'unified'
5+
6+
interface ReadingTimeOptions {
7+
age: number
8+
}
9+
10+
const metadata: Plugin<[ReadingTimeOptions], Root> =
11+
({ age }) =>
12+
(tree, file) => {
13+
Object.assign(file.data, { readingTime: Math.ceil(readingTime(tree, { age })) })
14+
}
15+
16+
export default metadata

src/shared/markdown.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,41 @@ import z from 'zod'
88

99
import rehypeCopyLinkedFiles from '../plugins/rehype-copy-linked-files'
1010
import rehypeExtractExcerpt from '../plugins/rehype-extract-excerpt'
11+
import rehypeMetadata from '../plugins/rehype-metadata'
1112
import remarkFlattenImage from '../plugins/remark-flatten-image'
1213
import remarkFlattenListItem from '../plugins/remark-flatten-listitem'
1314
import remarkRemoveComments from '../plugins/remark-remove-comments'
1415

1516
import type { PluggableList } from 'unified'
1617

1718
export interface MarkdownOptions {
19+
/**
20+
* Enable GitHub Flavored Markdown (GFM).
21+
* @default true
22+
*/
1823
gfm?: boolean
24+
/**
25+
* Remove html comments.
26+
* @default true
27+
*/
1928
removeComments?: boolean
29+
/**
30+
* Flatten image paragraph.
31+
* @default true
32+
*/
2033
flattenImage?: boolean
34+
/**
35+
* Flatten list item paragraph.
36+
* @default true
37+
*/
2138
flattenListItem?: boolean
39+
/**
40+
* Remark plugins.
41+
*/
2242
remarkPlugins?: PluggableList
43+
/**
44+
* Rehype plugins.
45+
*/
2346
rehypePlugins?: PluggableList
2447
}
2548

@@ -34,7 +57,7 @@ export const markdown = ({ gfm = true, removeComments = true, flattenImage = tru
3457
file.use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw) // turn markdown syntax tree to html syntax tree, with raw html support
3558
if (rehypePlugins != null) file.use(rehypePlugins) // apply rehype plugins
3659
file.use(rehypeCopyLinkedFiles) // copy linked files to public path and replace their urls with public urls
37-
file.use(rehypeExtractExcerpt) // extract excerpt and plain into file.data
60+
// file.use(rehypeExtractExcerpt) // extract excerpt and plain into file.data
3861
// if (process.env.NODE_ENV === 'production') file.use(rehypePresetMinify) // minify html syntax tree
3962
file.use(rehypeStringify) // serialize html syntax tree
4063
try {
@@ -45,3 +68,73 @@ export const markdown = ({ gfm = true, removeComments = true, flattenImage = tru
4568
return value
4669
}
4770
})
71+
72+
export interface MetadataOptions {
73+
/**
74+
* Age of the reader.
75+
* @default 22
76+
*/
77+
age: number
78+
}
79+
80+
export interface Metadata {
81+
/**
82+
* Reading time in minutes.
83+
*/
84+
readingTime: number
85+
}
86+
87+
export const metadata = ({ age = 22 }: MetadataOptions) =>
88+
z.string().transform(async (value, ctx) => {
89+
try {
90+
const file = await unified()
91+
.use(remarkParse)
92+
.use(remarkRehype, { allowDangerousHtml: true })
93+
.use(rehypeRaw)
94+
.use(rehypeMetadata, { age: age })
95+
.use(rehypeStringify)
96+
.process(value)
97+
return file.data as unknown as Metadata
98+
} catch (err: any) {
99+
ctx.addIssue({ code: 'custom', message: err.message })
100+
return value
101+
}
102+
})
103+
104+
export interface ExcerptOptions {
105+
/**
106+
* Excerpt separator.
107+
* @example
108+
* excerpt({ separator: 'more' }) // split excerpt by `<!-- more -->`
109+
*/
110+
separator?: string
111+
/**
112+
* Excerpt length.
113+
* @default 200
114+
*/
115+
length?: number
116+
/**
117+
* Excerpt format.
118+
* @default 'plain'
119+
*/
120+
format?: 'plain' | 'html'
121+
}
122+
123+
export const excerpt = ({ separator, length = 200, format = 'plain' }: ExcerptOptions = {}) =>
124+
z.string().transform(async (value, ctx) => {
125+
try {
126+
const file = await unified()
127+
.use(remarkParse)
128+
.use(remarkRehype, { allowDangerousHtml: true })
129+
.use(rehypeRaw)
130+
.use(rehypeExtractExcerpt, { separator, length })
131+
.use(rehypeStringify)
132+
.process({ value })
133+
134+
if (format === 'plain') return file.data.plain
135+
return file.toString()
136+
} catch (err: any) {
137+
ctx.addIssue({ code: 'custom', message: err.message })
138+
return value
139+
}
140+
})

0 commit comments

Comments
 (0)