diff --git a/example/content/categories/index.yml b/example/content/categories/index.yml deleted file mode 100644 index 3882978..0000000 --- a/example/content/categories/index.yml +++ /dev/null @@ -1,15 +0,0 @@ -- name: Journal - slug: journal - cover: journal.jpg - description: serving as a platform for academic exchange and dissemination of knowledge - meta: - title: Journal - description: with a focus on timely updates and research results. It is a reference tool for scholars and professionals in various fields. - -- name: Travel - slug: travel - description: All travel logs - -- name: Photography - slug: photography - description: All photography logs diff --git a/example/content/categories/journal.yml b/example/content/categories/journal.yml new file mode 100644 index 0000000..e2c9d2e --- /dev/null +++ b/example/content/categories/journal.yml @@ -0,0 +1,7 @@ +name: Journal +slug: journal +cover: journal.jpg +description: serving as a platform for academic exchange and dissemination of knowledge +meta: + title: Journal + description: with a focus on timely updates and research results. It is a reference tool for scholars and professionals in various fields. diff --git a/example/content/categories/photography.yml b/example/content/categories/photography.yml new file mode 100644 index 0000000..9b0b220 --- /dev/null +++ b/example/content/categories/photography.yml @@ -0,0 +1,3 @@ +name: Travel +slug: travel +description: All travel logs diff --git a/example/content/categories/travel.yml b/example/content/categories/travel.yml new file mode 100644 index 0000000..7d33e15 --- /dev/null +++ b/example/content/categories/travel.yml @@ -0,0 +1,3 @@ +name: Photography +slug: photography +description: All photography logs diff --git a/example/velite.config.ts b/example/velite.config.ts index c63f674..74ead57 100644 --- a/example/velite.config.ts +++ b/example/velite.config.ts @@ -35,7 +35,7 @@ export default defineConfig({ }, categories: { name: 'Category', - pattern: 'categories/index.yml', + pattern: 'categories/*.yml', fields: s .object({ name: s.name(), @@ -77,8 +77,8 @@ export default defineConfig({ .object({ title: s.title(), slug: s.slug('post'), - date: s.date(), - updated: s.date().optional(), + date: s.isodate(), + updated: s.isodate().optional(), cover: s.image().optional(), description: s.paragraph().optional(), draft: s.boolean().default(false), diff --git a/readme.md b/readme.md index 324c604..b55218e 100644 --- a/readme.md +++ b/readme.md @@ -8,6 +8,20 @@ [![NPM Version][version-img]][version-url] [![Code Style][style-img]][style-url] +## TODOs + +example +copy-linked-files +--watch +mdx +reference parent + +- [ ] excerpt +- [ ] nextjs plugin +- [ ] types generate +- [ ] image command (compress, resize, etc.) +- [ ] docs + ## Backups ```typescript @@ -84,14 +98,6 @@ export const mdx = ({ gfm = true, removeComments = true, flattenImage = true, fl } ``` -## TODOs - -- [ ] excerpt -- [ ] nextjs plugin -- [ ] types generate -- [ ] image command (compress, resize, etc.) -- [ ] docs - ## Installation ```shell diff --git a/src/builder.ts b/src/build.ts similarity index 77% rename from src/builder.ts rename to src/build.ts index 846e26a..2265626 100644 --- a/src/builder.ts +++ b/src/build.ts @@ -1,7 +1,3 @@ -/** - * @file Builder - */ - import { mkdir, rm, watch, writeFile } from 'node:fs/promises' import { join } from 'node:path' import glob from 'fast-glob' @@ -31,6 +27,51 @@ class Builder { this.result = {} } + /** + * create builder instance + */ + static async create(options: Options) { + // resolve config + const config = await resolveConfig({ filename: options.config, clean: options.clean, verbose: options.verbose }) + + // register user loaders + config.loaders.forEach(addLoader) + + // init static output config + initOutputConfig(config.output) + + // prerequisite + if (config.clean) { + // clean output directories if `--clean` requested + await rm(config.output.data, { recursive: true, force: true }) + await rm(config.output.static, { recursive: true, force: true }) + config.verbose && console.log('cleaned output directories') + } + + return new Builder(config) + } + + /** + * output result to dist + */ + async output() { + const { output } = this.config + + await mkdir(output.data, { recursive: true }) + + await Promise.all( + Object.entries(this.result).map(async ([name, data]) => { + if (data == null) return + const json = JSON.stringify(data, null, 2) + await writeFile(join(output.data, name + '.json'), json) + console.log(`wrote ${data.length ?? 1} ${name} to '${join(output.data, name + '.json')}'`) + }) + ) + } + + /** + * build content with config + */ async build() { if (this.config == null) throw new Error('config not initialized') const { root, verbose, schemas, onSuccess } = this.config @@ -39,26 +80,22 @@ class Builder { cache.clear() // clear cache in case of rebuild + const files: File[] = [] + const tasks = Object.entries(schemas).map(async ([name, schema]) => { const filenames = await glob(schema.pattern, { cwd: root, onlyFiles: true, ignore: ['**/_*'] }) verbose && console.log(`found ${filenames.length} files matching '${schema.pattern}'`) - const files = await Promise.all( - filenames.map(async file => { - const doc = await File.create(join(root, file)) - await doc.load() - await doc.parse(schema.fields) - return doc + const result = await Promise.all( + filenames.map(async filename => { + const file = await File.create(join(root, filename)) + const result = await file.parse(schema.fields) + files.push(file) + return result }) ) - const report = reporter(files, { quiet: true, verbose }) - report.length > 0 && console.log(report) - - const data = files - .map(f => f.data.result ?? []) - .flat() - .filter(Boolean) + const data = result.flat().filter(Boolean) if (schema.single) { if (data.length > 1) { @@ -70,37 +107,25 @@ class Builder { return [name, data] as const }) - const collections = await Promise.all(tasks) - - const result = Object.fromEntries(collections) + const entities = await Promise.all(tasks) - // user callback - onSuccess != null && (await onSuccess(result)) + // report if any error in parsing + const report = reporter(files, { quiet: true, verbose }) + report.length > 0 && console.log(report) - Object.assign(this.result, result) - } + const collections = Object.fromEntries(entities) - /** - * output result to dist - */ - async output() { - const { output } = this.config + // user callback + if (typeof onSuccess === 'function') { + await onSuccess(collections) + } - await mkdir(output.data, { recursive: true }) + Object.assign(this.result, collections) - await Promise.all( - Object.entries(this.result).map(async ([name, data]) => { - if (data == null) return - const json = JSON.stringify(data, null, 2) - await writeFile(join(output.data, name + '.json'), json) - console.log(`wrote ${data.length ?? 1} ${name} to '${join(output.data, name + '.json')}'`) - }) - ) + await this.output() } async watch() { - if (!this.config.watch) return - console.log('watching for changes') const { schemas } = this.config @@ -112,38 +137,15 @@ class Builder { const { filename } = event if (filename == null) continue if (!allPatterns.some(pattern => micromatch.isMatch(filename, pattern))) continue - // TODO: rebuild only changed file // rebuild all - await this.build() - await this.output() + await this.build() // TODO: rebuild only changed file } } - - static async create(options: Options) { - // resolve config - const config = await resolveConfig({ filename: options.config, clean: options.clean, verbose: options.verbose, watch: options.watch }) - - // register user loaders - config.loaders.forEach(addLoader) - - // init static output config - initOutputConfig(config.output) - - // prerequisite - if (config.clean) { - // clean output directories if `--clean` requested - await rm(config.output.data, { recursive: true, force: true }) - await rm(config.output.static, { recursive: true, force: true }) - config.verbose && console.log('cleaned output directories') - } - - return new Builder(config) - } } export const build = async (options: Options) => { const builder = await Builder.create(options) await builder.build() - await builder.output() + if (!options.watch) return await builder.watch() } diff --git a/src/cli.ts b/src/cli.ts index bbfaa79..264a0e1 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,12 +1,8 @@ #!/usr/bin/env node - -/** - * @file CLI entry point - */ import cac from 'cac' import { name, version } from '../package.json' -import { build } from './builder' +import { build } from './build' const cli = cac(name).version(version).help() diff --git a/src/config.ts b/src/config.ts index 6f6f256..b97b136 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,3 @@ -/** - * @file Load config from user's project - */ - import { access, rm } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' @@ -75,7 +71,6 @@ const loadConfig = async (filename: string): Promise => { interface Options { filename?: string clean?: boolean - watch?: boolean verbose?: boolean } @@ -117,7 +112,6 @@ export const resolveConfig = async (options: Options = {}): Promise => { ignoreFileExtensions: userConfig.output?.ignoreFileExtensions ?? [] }, clean: options.clean ?? userConfig.clean ?? false, - watch: options.watch ?? false, verbose, schemas: userConfig.schemas, loaders: userConfig.loaders ?? [], diff --git a/src/file.ts b/src/file.ts index 71f3cd4..87a6118 100644 --- a/src/file.ts +++ b/src/file.ts @@ -1,46 +1,45 @@ -/** - * @file Document - */ - import { readFile } from 'node:fs/promises' import { VFile } from 'vfile' -import { ZodType } from 'zod' import { resolveLoader } from './loaders' -declare module 'vfile' { - interface DataMap { - original: Record | Record[] - result: Record | Record[] - } -} +import type { Collection } from './types' +import type { ZodType } from 'zod' export class File extends VFile { + /** + * create file instance + * @param path file path + * @returns file instance + */ + static async create(path: string) { + const value = await readFile(path, 'utf8') + return new File({ path, value }) + } + /** * load file content into `this.data.original` + * @returns original data from file */ - async load(): Promise { - try { - if (this.extname == null) { - throw new Error('can not parse file without extension') - } - const loader = resolveLoader(this.path) - if (loader == null) { - throw new Error(`no loader found for '${this.path}'`) - } - await loader.load(this) - } catch (err: any) { - this.message(err.message) + private async load(): Promise { + if (this.extname == null) { + throw new Error('can not parse file without extension') + } + const loader = resolveLoader(this.path) + if (loader == null) { + throw new Error(`no loader found for '${this.path}'`) } + return loader.load(this) } /** - * parse `this.data.original` into `this.data.result` with given fields schema + * parse file content with given fields schema * @param fields fields schema + * @returns collection data from file, or undefined if parsing failed */ - async parse(fields: ZodType): Promise { + async parse(fields: ZodType): Promise { try { - const { original } = this.data + const original = await this.load() if (original == null || Object.keys(original).length === 0) { throw new Error('no data parsed from this file') @@ -64,14 +63,9 @@ export class File extends VFile { }) ) - this.data.result = processed.length === 1 ? processed[0] : processed + return processed.length === 1 ? processed[0] : processed } catch (err: any) { this.message(err.message) } } - - static async create(path: string) { - const value = await readFile(path, 'utf8') - return new File({ path, value }) - } } diff --git a/src/index.ts b/src/index.ts index 6fb24fc..d5ee1a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,4 @@ -/** - * @file module entry point - */ - export { z, s } from './shared' export { defineConfig, defineLoader } from './types' export { addLoader, removeLoader } from './loaders' -export { build } from './builder' +export { build } from './build' diff --git a/src/loaders/json.ts b/src/loaders/json.ts index a6e070b..e373e26 100644 --- a/src/loaders/json.ts +++ b/src/loaders/json.ts @@ -8,6 +8,6 @@ export default defineLoader({ name: 'json', test: /\.json$/, load: async vfile => { - vfile.data.original = JSON.parse(vfile.toString()) + return JSON.parse(vfile.toString()) } }) diff --git a/src/loaders/markdown.ts b/src/loaders/markdown.ts index 73dee99..4fce752 100644 --- a/src/loaders/markdown.ts +++ b/src/loaders/markdown.ts @@ -14,9 +14,7 @@ export default defineLoader({ // https://github.com/vfile/vfile-matter/blob/main/lib/index.js const match = content.match(/^---(?:\r?\n|\r)(?:([\s\S]*?)(?:\r?\n|\r))?---(?:\r?\n|\r|$)/) if (match == null) { - // throw new Error('frontmatter is required') - vfile.data.original = { body: content } - return + return { body: content } } // TODO: output file meta data for later use @@ -24,6 +22,6 @@ export default defineLoader({ const data = yaml.parse(match[1]) const raw = content.slice(match[0].length).trim() // keep original content with multiple keys in vfile.data for later use - vfile.data.original = Object.assign(data, { raw, excerpt: raw, plain: raw, html: raw, body: raw, code: raw }) + return Object.assign(data, { raw, excerpt: raw, plain: raw, html: raw, body: raw, code: raw }) } }) diff --git a/src/loaders/yaml.ts b/src/loaders/yaml.ts index b1fe344..99302de 100644 --- a/src/loaders/yaml.ts +++ b/src/loaders/yaml.ts @@ -10,6 +10,6 @@ export default defineLoader({ name: 'yaml', test: /\.(yaml|yml)$/, load: async vfile => { - vfile.data.original = yaml.parse(vfile.toString()) + return yaml.parse(vfile.toString()) } }) diff --git a/src/shared/shared.ts b/src/shared/shared.ts index dc015ab..40d8775 100644 --- a/src/shared/shared.ts +++ b/src/shared/shared.ts @@ -52,7 +52,7 @@ export const name = () => z.string().max(20) export const title = () => z.string().max(99) -export const date = () => +export const isodate = () => z .string() .refine(value => !isNaN(Date.parse(value)), 'Invalid date') diff --git a/src/static.ts b/src/static.ts index 824e4d8..e1b2017 100644 --- a/src/static.ts +++ b/src/static.ts @@ -95,21 +95,23 @@ const outputStatic = async (ref: string, fromPath: string, isImage?: true): Prom }) if (isImage == null) { - const files = cache.get('files') || new Set() + const key = 'static:files' + const files = cache.get(key) || new Set() if (files.has(filename)) return filename files.add(filename) // TODO: not await works, but await not works, becareful if copy failed await copy(from, filename) - cache.set('files', files) + cache.set(key, files) return filename } - const images = cache.get('images') || new Map() + const key = 'static:images' + const images = cache.get(key) || new Map() if (images.has(filename)) return images.get(filename) as Image const img = await getImageMetadata(source) if (img == null) return ref const image = { src: filename, ...img } images.set(filename, image) - cache.set('images', images) + cache.set(key, images) await copy(from, filename) return image } diff --git a/src/types.ts b/src/types.ts index b935c9a..eb3c4bf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,10 +1,11 @@ -/** - * @file shared types - */ - import type { VFile } from 'vfile' import type { ZodType } from 'zod' +/** + * Collection data from file + */ +export type Collection = Record | Record[] + /** * File loader */ @@ -23,10 +24,10 @@ export interface Loader { */ test: RegExp /** - * Load file content into `vfile.data.result` + * Load file content * @param vfile vfile */ - load: (vfile: VFile) => void | Promise + load: (vfile: VFile) => Collection | Promise } export interface Output { @@ -108,11 +109,6 @@ export interface Config = Record