diff --git a/package.json b/package.json index 69de5635..3bab63a6 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "./cors": "./dist/middleware/cors/index.js", "./etag": "./dist/middleware/etag/index.js", "./graphql-server": "./dist/middleware/graphql-server/index.js", + "./jsx": "./dist/middleware/jsx/index.js", "./jwt": "./dist/middleware/jwt/index.js", "./logger": "./dist/middleware/logger/index.js", "./mustache": "./dist/middleware/mustache/index.js", @@ -60,6 +61,9 @@ "graphql-server": [ "./dist/middleware/graphql-server" ], + "jsx": [ + "./dist/middleware/jsx" + ], "jwt": [ "./dist/middleware/jwt" ], diff --git a/src/context.ts b/src/context.ts index bb948a25..d94e085f 100644 --- a/src/context.ts +++ b/src/context.ts @@ -21,7 +21,7 @@ export class Context { private _res: Response | undefined private notFoundHandler: NotFoundHandler - render: (template: string, params?: object, options?: object) => Promise + render: (content: string, params?: object, options?: object) => Response | Promise constructor( req: Request, diff --git a/src/middleware/jsx/README.md b/src/middleware/jsx/README.md new file mode 100644 index 00000000..e69de29b diff --git a/src/middleware/jsx/index.test.tsx b/src/middleware/jsx/index.test.tsx new file mode 100644 index 00000000..9509589f --- /dev/null +++ b/src/middleware/jsx/index.test.tsx @@ -0,0 +1,17 @@ +import { Hono } from '../../hono' +import { h, jsx } from '.' + +describe('JSX middleware', () => { + const app = new Hono() + app.use('*', jsx()) + + it('Should render HTML strings', async () => { + app.get('/', (c) => { + return c.render(

Hello

) + }) + const res = await app.request('http://localhost/') + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('text/html; charset=UTF-8') + expect(await res.text()).toBe('

Hello

') + }) +}) diff --git a/src/middleware/jsx/index.ts b/src/middleware/jsx/index.ts new file mode 100644 index 00000000..cf3f91b8 --- /dev/null +++ b/src/middleware/jsx/index.ts @@ -0,0 +1,49 @@ +import type { Context } from '../../context' +import type { Next } from '../../hono' +import { escape } from '../../utils/html' + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace h.JSX { + interface IntrinsicElements { + [tagName: string]: Record + } + } +} + +export const jsx = () => { + return async (c: Context, next: Next) => { + c.render = (content: string) => { + const output = `${content.toString()}` + return c.html(output) + } + await next() + } +} + +type EscapedString = string & { isEscaped: true } + +export const h = ( + tag: string | Function, + props: Record, + ...children: (string | EscapedString)[] +): EscapedString => { + if (typeof tag === 'function') { + return tag.call(null, { ...props, children }) + } + let attrs = '' + const propsKeys = Object.keys(props || {}) + for (let i = 0, len = propsKeys.length; i < len; i++) { + attrs += ` ${propsKeys[i]}="${escape(props[propsKeys[i]])}"` + } + + const res: any = new String( + `<${tag}${attrs}>${children + .flat() + .map((c) => ((c as any).isEscaped ? c : escape(c as string))) + .join('')}` + ) + res.isEscaped = true + + return res +} diff --git a/src/middleware/mustache/Context.ts b/src/middleware/mustache/Context.ts new file mode 100644 index 00000000..716f60bc --- /dev/null +++ b/src/middleware/mustache/Context.ts @@ -0,0 +1,3 @@ +export interface Context { + render(template: string, params?: object, options?: object): Promise +} diff --git a/src/middleware/mustache/mustache.ts b/src/middleware/mustache/mustache.ts index 34b8a28b..cb0332e9 100644 --- a/src/middleware/mustache/mustache.ts +++ b/src/middleware/mustache/mustache.ts @@ -1,6 +1,6 @@ import Mustache from 'mustache' import type { Context } from '../../context' -import type { Handler, Next } from '../../hono' +import type { Next } from '../../hono' import { bufferToString } from '../../utils/buffer' import type { KVAssetOptions } from '../../utils/cloudflare' import { getContentFromKVAsset, getKVFilePath } from '../../utils/cloudflare' @@ -20,11 +20,11 @@ export type MustacheOptions = { namespace?: KVNamespace } -export const mustache = (init: MustacheOptions = { root: '' }): Handler => { +export const mustache = (init: MustacheOptions = { root: '' }) => { const { root } = init return async (c: Context, next: Next) => { - c.render = async (filename, params = {}, options?) => { + c.render = async (filename, params = {}, options?): Promise => { const path = getKVFilePath({ filename: `${filename}${EXTENSION}`, root: root, diff --git a/src/utils/html.test.ts b/src/utils/html.test.ts new file mode 100644 index 00000000..696e5974 --- /dev/null +++ b/src/utils/html.test.ts @@ -0,0 +1,8 @@ +import { escape } from './html' + +describe('HTML escape', () => { + it('Should escape special characters', () => { + expect(escape('I think this is good.')).toBe('I <b>think</b> this is good.') + expect(escape('John "Johnny" Smith')).toBe('John "Johnny" Smith') + }) +}) diff --git a/src/utils/html.ts b/src/utils/html.ts new file mode 100644 index 00000000..be005ae4 --- /dev/null +++ b/src/utils/html.ts @@ -0,0 +1,8 @@ +export const escape = (str: string): string => { + return str + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>') +} diff --git a/tsconfig.json b/tsconfig.json index 1b92b21b..58c6b1c5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,8 @@ "node", "@cloudflare/workers-types" ], + "jsx": "react", + "jsxFactory": "h", }, "include": [ "src/**/*.ts",