Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor #19

Merged
merged 20 commits into from
Dec 6, 2023
Merged
2 changes: 1 addition & 1 deletion docs/guide/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Check out our detailed [Why Velite](#why-velite) to learn more about what makes

## Try Velite Online

You can try Velite directly in your browser on StackBlitz:
You can try Velite directly in your browser on StackBlitz, It runs Velite directly in the browser, and it is almost identical to the local setup but doesn't require installing anything on your machine.

- https://stackblitz.com/edit/velite-basic
- https://stackblitz.com/edit/velite-nextjs
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ $ bun add velite -D

:::

::: tip Velite is an ESM-only package
::: tip Velite is an [ESM-only](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c) package

Don't use `require()` to import it, and make sure your nearest `package.json` contains `"type": "module"`, or change the file extension of your relevant files like `velite.config.js` to `.mjs`/`.mts`. Also, inside async CJS contexts, you can use `await import('velite')` instead.

Expand Down
13 changes: 13 additions & 0 deletions docs/guide/using-markdown.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
# Markdown

This documentation is still being written. Please check back later.

Markdown is a lightweight markup language with plain text formatting syntax. It is designed so that it can be converted to HTML and many other formats using a tool by the same name. Markdown is often used to format readme files, for writing messages in online discussion forums, and to create rich text using a plain text editor.

::: tip Markdown or MDX

Markdown is top-level supported in Velite, Although we also support MDX, I think that MDX is not the best choice for content creators. Although it is powerful and has stronger programmability, it is easy to lose the essence of writing and recording, and become addicted to technology.

- Portable: Markdown is portable, you can use it anywhere, even in the terminal.
- Simple: Markdown is simple, you can learn it in 10 minutes, don't need to spend a lot of time to learn React.
- Easy to use: Markdown is easy to use, you can use it to write documents, write blogs, and even write books.
- Stronger by extension: Markdown is extensible, you can use it to write code, write math formulas, and even write music.

:::
22 changes: 22 additions & 0 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,25 @@ interface Result {
[name: string]: Entry | Entry[]
}
```

## `outputFile`

### Signature

```ts
const outputFile: async <T extends string | undefined>(ref: T, fromPath: string) => Promise<T>
```

## `outputImage`

### Signature

```ts
const outputImage: async <T extends string | undefined>(ref: T, fromPath: string) => Promise<Image | T>
```

## `cache`

- `loaded:${path}`: VFile of loaded file.

...
2 changes: 2 additions & 0 deletions examples/basic/velite.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// @ts-check

import { defineConfig, s } from 'velite'

const slugify = input =>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,6 @@
},
"packageManager": "[email protected]",
"engines": {
"node": ">=18"
"node": "^18.17.0 || >=20.3.0"
}
}
162 changes: 62 additions & 100 deletions src/assets.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { createHash } from 'node:crypto'
import { copyFile, readFile } from 'node:fs/promises'
import { basename, extname, join, resolve } from 'node:path'
import { readFile } from 'node:fs/promises'
import { basename, extname, resolve } from 'node:path'
import sharp from 'sharp'
import { visit } from 'unist-util-visit'

import { getConfig } from './config'

import type { Element, Root as Hast } from 'hast'
import type { Element, Root as Hast, Nodes as HNodes } from 'hast'
import type { Root as Mdast, Node } from 'mdast'
import type { Plugin } from 'unified'
import type { VFile } from 'vfile'

/**
* Image object with metadata & blur image
Expand Down Expand Up @@ -40,36 +40,24 @@ export interface Image {
blurHeight: number
}

export const assets = new Map<string, string>()

// https://github.com/sindresorhus/is-absolute-url/blob/main/index.js
const absoluteUrlRegex = /^[a-zA-Z][a-zA-Z\d+\-.]*?:/
const absolutePathRegex = /^(\/[^/\\]|[a-zA-Z]:\\)/
const ABS_URL_RE = /^[a-zA-Z][a-zA-Z\d+\-.]*?:/
const ABS_PATH_RE = /^(\/[^/\\]|[a-zA-Z]:\\)/

/**
* validate if a url is a relative path
* @param url url to validate
* @returns true if the url is a relative path
*/
export const isValidatedStaticPath = (url: string): boolean => {
const isStaticPath = (url: string): boolean => {
if (url.startsWith('#')) return false // ignore hash anchor
if (url.startsWith('?')) return false // ignore query
if (url.startsWith('//')) return false // ignore protocol relative urlet name
if (absoluteUrlRegex.test(url)) return false // ignore absolute url
if (absolutePathRegex.test(url)) return false // ignore absolute path
const { output } = getConfig()
const ext = extname(url).slice(1)
return !output.ignore.includes(ext) // ignore file extensions
}

/**
* get md5 hash of data
* @param data source data
* @returns md5 hash of data
*/
const md5 = (data: string | Buffer): string => {
// https://github.com/joshwiens/hash-perf
// https://stackoverflow.com/q/2722943
// https://stackoverflow.com/q/14139727
return createHash('md5').update(data).digest('hex')
if (ABS_URL_RE.test(url)) return false // ignore absolute url
if (ABS_PATH_RE.test(url)) return false // ignore absolute path
return !getConfig().output.ignore.includes(extname(url).slice(1)) // ignore file extensions
}

/**
Expand All @@ -84,96 +72,76 @@ const getImageMetadata = async (buffer: Buffer): Promise<Omit<Image, 'src'> | un
const aspectRatio = width / height
const blurWidth = 8
const blurHeight = Math.round(blurWidth / aspectRatio)
// prettier-ignore
const blurDataURL = await img.resize(blurWidth, blurHeight).webp({ quality: 1 }).toBuffer().then(b => `data:image/webp;base64,${b.toString('base64')}`)
const blurImage = await img.resize(blurWidth, blurHeight).webp({ quality: 1 }).toBuffer()
const blurDataURL = `data:image/webp;base64,${blurImage.toString('base64')}`
return { height, width, blurDataURL, blurWidth, blurHeight }
}

/**
* output assets file reference of a file
* process assets reference of a file
* @param ref relative path of the referenced file
* @param path source file path
* @param fromPath source file path
* @param isImage process as image and return image object with blurDataURL
* @returns reference public url or image object
*/
const output = async (ref: string, fromPath: string, isImage?: true): Promise<Image | string> => {
if (!isValidatedStaticPath(ref)) return ref

const { output } = getConfig()
export const processAsset = async <T extends string | undefined, U extends true | undefined = undefined>(
ref: T,
fromPath: string,
isImage?: U
): Promise<T extends undefined ? undefined : U extends true ? Image | T : T> => {
if (ref == null) return ref as any // return undefined or null for zod optional type
if (!isStaticPath(ref)) return ref as any // return original url for non-static path

const {
output: { filename, base }
} = getConfig()

const from = resolve(fromPath, '..', ref)
const source = await readFile(from)

const filename = output.filename.replace(/\[(name|hash|ext)(:(\d+))?\]/g, (substring, ...groups) => {
const ext = extname(from)
const name = filename.replace(/\[(name|hash|ext)(:(\d+))?\]/g, (substring, ...groups) => {
const key = groups[0]
const length = groups[2] == null ? undefined : parseInt(groups[2])
switch (key) {
case 'name':
return basename(ref, extname(ref)).slice(0, length)
return basename(ref, ext).slice(0, length)
case 'hash':
return md5(source).slice(0, length)
// TODO: md5 is slow and not-FIPS compliant, consider using sha256
// https://github.com/joshwiens/hash-perf
// https://stackoverflow.com/q/2722943
// https://stackoverflow.com/q/14139727
return createHash('md5').update(source).digest('hex').slice(0, length)
case 'ext':
return extname(ref).slice(1).slice(0, length)
return ext.slice(1, length)
}
return substring
})
const src = base + name
assets.set(name, from)

const dest = join(output.assets, filename)

if (isImage == null) {
await copyFile(from, dest)
return output.base + filename
}

const img = await getImageMetadata(source)
if (img == null) return ref
await copyFile(from, dest)
return { src: output.base + filename, ...img }
}
if (isImage !== true) return src as any

/**
* output assets file reference of a file
* @param ref relative path of the referenced file
* @param path source file path
* @returns reference public url
*/
export const outputFile = async <T extends string | undefined>(ref: T, fromPath: string): Promise<T> => {
if (ref == null) return ref
return output(ref, fromPath) as Promise<T>
}

/**
* output assets file reference of a file
* @param ref relative path of the referenced file
* @param path source file path
* @returns reference public url or image object
*/
export const outputImage = async <T extends string | undefined>(ref: T, fromPath: string): Promise<Image | T> => {
if (ref == null) return ref
return output(ref, fromPath, true) as Promise<Image | T>
const metadata = await getImageMetadata(source)
if (metadata == null) throw new Error(`invalid image: ${from}`)
return { src, ...metadata } as any
}

/**
* rehype plugin to copy linked files to public path and replace their urls with public urls
*/
export const rehypeCopyLinkedFiles: Plugin<[], Hast> = () => async (tree, file) => {
export const extractHastLinkedFiles = async (tree: HNodes, from: string) => {
const links = new Map<string, Element[]>()
const linkedPropertyNames = ['href', 'src', 'poster']

visit(tree, 'element', node => {
linkedPropertyNames.forEach(name => {
const value = node.properties[name]
if (typeof value === 'string' && isValidatedStaticPath(value)) {
if (typeof value === 'string' && isStaticPath(value)) {
const elements = links.get(value) ?? []
elements.push(node)
links.set(value, elements)
}
})
})

await Promise.all(
Array.from(links.entries()).map(async ([url, elements]) => {
const publicUrl = await outputFile(url, file.path)
const publicUrl = await processAsset(url, from)
if (publicUrl == null || publicUrl === url) return
elements.forEach(node => {
linkedPropertyNames.forEach(name => {
Expand All @@ -187,53 +155,47 @@ export const rehypeCopyLinkedFiles: Plugin<[], Hast> = () => async (tree, file)
}

/**
* remark plugin to copy linked files to public path and replace their urls with public urls
* rehype (markdown) plugin to copy linked files to public path and replace their urls with public urls
*/
export const remarkCopyLinkedFiles: Plugin<[], Mdast> = () => async (tree, file) => {
const links = new Map<string, Node[]>()
export const rehypeCopyLinkedFiles = () => async (tree: Hast, file: VFile) => extractHastLinkedFiles(tree, file.path)

/**
* remark (mdx) plugin to copy linked files to public path and replace their urls with public urls
*/
export const remarkCopyLinkedFiles = () => async (tree: Mdast, file: VFile) => {
const links = new Map<string, Node[]>()
const linkedPropertyNames = ['href', 'src', 'poster']
visit(tree, ['link', 'image', 'definition'], (node: any) => {
if (isValidatedStaticPath(node.url)) {
if (isStaticPath(node.url)) {
const nodes = links.get(node.url) || []
nodes.push(node)
links.set(node.url, nodes)
}
})

visit(tree, 'mdxJsxFlowElement', node => {
node.attributes.forEach((attr: any) => {
if (['href', 'src', 'poster'].includes(attr.name) && typeof attr.value === 'string' && isValidatedStaticPath(attr.value)) {
if (linkedPropertyNames.includes(attr.name) && typeof attr.value === 'string' && isStaticPath(attr.value)) {
const nodes = links.get(attr.value) || []
nodes.push(node)
links.set(attr.value, nodes)
}
})
})

await Promise.all(
Array.from(links.entries()).map(async ([url, nodes]) => {
const publicUrl = await outputFile(url, file.path)
const publicUrl = await processAsset(url, file.path)
if (publicUrl == null || publicUrl === url) return
nodes.forEach((node: any) => {
if ('url' in node && node.url === url) {
if (node.url === url) {
node.url = publicUrl
return
}
if ('href' in node && node.href === url) {
node.href = publicUrl
return
}

node.attributes.forEach((attr: any) => {
if (attr.name === 'src' && attr.value === url) {
attr.value = publicUrl
}
if (attr.name === 'href' && attr.value === url) {
attr.value = publicUrl
}
if (attr.name === 'poster' && attr.value === url) {
attr.value = publicUrl
}
linkedPropertyNames.forEach(name => {
if (attr.name === name && attr.value === url) {
attr.value = publicUrl
}
})
})
})
})
Expand Down
Loading