From 2c6eca95d90fe6db48b8ee73b649cdb6c5e544a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=BCrg=C3=BCn=20Day=C4=B1o=C4=9Flu?= Date: Thu, 18 Jan 2024 16:49:31 +0100 Subject: [PATCH] ghtml --- .eslintrc.json | 5 ++ .github/workflows/ci.yml | 17 +++++ .github/workflows/npm-publish.yml | 23 ++++++ .gitignore | 2 + package.json | 28 ++++++++ src/html.js | 56 +++++++++++++++ src/includeFile.js | 22 ++++++ src/index.js | 1 + test/index.js | 115 ++++++++++++++++++++++++++++++ 9 files changed, 269 insertions(+) create mode 100644 .eslintrc.json create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/npm-publish.yml create mode 100644 package.json create mode 100644 src/html.js create mode 100644 src/includeFile.js create mode 100644 src/index.js create mode 100644 test/index.js diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..ddb9ead --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "root": true, + "env": { "node": true }, + "extends": ["plugin:grules/all"] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..314bbd8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,17 @@ +name: CI + +on: + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ^18 + - run: npm run test diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..02d01ac --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,23 @@ +name: npm-publish + +on: + release: + types: [published] + +jobs: + npm-publish: + runs-on: ubuntu-latest + + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ^20 + registry-url: https://registry.npmjs.org + - run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index c6bba59..5ad237c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +package-lock.json + # Logs logs *.log diff --git a/package.json b/package.json new file mode 100644 index 0000000..2418a3b --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "ghtml", + "description": "Replace your template engine with fast JavaScript by leveraging the power of tagged templates.", + "author": "Gürgün Dayıoğlu", + "license": "MIT", + "version": "0.0.0", + "type": "module", + "main": "./src/index.js", + "exports": { + ".": "./src/index.js", + "./*.js": "./src/*.js" + }, + "engines": { + "node": ">=18" + }, + "scripts": { + "test": "npm run lint && node --experimental-test-coverage test/index.js", + "lint": "eslint . && prettier --check .", + "lint:fix": "eslint --fix . && prettier --write ." + }, + "devDependencies": { + "grules": "^0.5.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/gurgunday/ghtml.git" + } +} diff --git a/src/html.js b/src/html.js new file mode 100644 index 0000000..17901d7 --- /dev/null +++ b/src/html.js @@ -0,0 +1,56 @@ +const escapeDictionary = { + '"': """, + "'": "'", + "&": "&", + "<": "<", + ">": ">", +}; + +const escapeRegExp = new RegExp( + `[${Object.keys(escapeDictionary).join("")}]`, + "gu", +); + +const escapeFunction = (key) => { + return escapeDictionary[key]; +}; + +/** + * @param {{ raw: string[] }} literals + * @param {...any} expressions + * @returns {string} + */ +const html = (literals, ...expressions) => { + const lastLiteralIndex = literals.raw.length - 1; + let accumulator = ""; + + if (lastLiteralIndex === -1) { + return accumulator; + } + + for (let index = 0; index < lastLiteralIndex; ++index) { + let literal = literals.raw[index]; + let expression = + typeof expressions[index] === "string" + ? expressions[index] + : expressions[index] == null + ? "" + : Array.isArray(expressions[index]) + ? expressions[index].join("") + : `${expressions[index]}`; + + if (literal.length && literal.charCodeAt(literal.length - 1) === 33) { + literal = literal.slice(0, -1); + } else if (expression.length) { + expression = expression.replace(escapeRegExp, escapeFunction); + } + + accumulator += literal + expression; + } + + accumulator += literals.raw[lastLiteralIndex]; + + return accumulator; +}; + +export { html }; diff --git a/src/includeFile.js b/src/includeFile.js new file mode 100644 index 0000000..3d6f57f --- /dev/null +++ b/src/includeFile.js @@ -0,0 +1,22 @@ +import { readFileSync } from "node:fs"; + +const readFileSyncOptions = { encoding: "utf8" }; + +const fileCache = new Map(); + +/** + * @param {string} path + * @returns {string} + */ +const includeFile = (path) => { + let file = fileCache.get(path); + + if (file === undefined) { + file = readFileSync(path, readFileSyncOptions); + fileCache.set(path, file); + } + + return file; +}; + +export { includeFile }; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..9e7ac88 --- /dev/null +++ b/src/index.js @@ -0,0 +1 @@ +export { html } from "./html.js"; diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..4334812 --- /dev/null +++ b/test/index.js @@ -0,0 +1,115 @@ +import test from "node:test"; +import assert from "node:assert"; +import { html } from "../src/index.js"; + +const username = "G"; +const descriptionSafe = "This is a safe description."; +const descriptionUnsafe = + ""; +const array1 = [1, 2, 3, 4, 5]; +const conditionTrue = true; +const conditionFalse = false; +const empty = ""; + +test("renders correctly", () => { + assert.strictEqual(html({ raw: [] }, []), ""); +}); + +test("renders correctly", () => { + assert.strictEqual(html`${empty}`, ""); +}); + +test("renders correctly", () => { + assert.strictEqual(html`Hey, ${username}!`, `Hey, ${username}!`); +}); + +test("renders safe content", () => { + assert.strictEqual( + html`

${descriptionSafe}

`, + "

This is a safe description.

", + ); +}); + +test("escapes unsafe output", () => { + assert.strictEqual( + html`

${descriptionUnsafe}

`, + `

<script>alert('This is an unsafe description.')</script>

`, + ); +}); + +test("escapes unsafe output", () => { + assert.strictEqual( + html`

${descriptionUnsafe}

`, + `

<script>alert('This is an unsafe description.')</script>

`, + ); +}); + +test("renders arrays", () => { + assert.strictEqual( + html`

${[descriptionSafe, descriptionUnsafe]}

`, + "

This is a safe description.<script>alert('This is an unsafe description.')</script>

", + ); +}); + +test("bypass escaping", () => { + assert.strictEqual( + html`

!${[descriptionSafe, descriptionUnsafe]}

`, + "

This is a safe description.

", + ); +}); + +test("renders wrapped html calls", () => { + assert.strictEqual( + // prettier-ignore + html`

!${conditionTrue ? html`${descriptionUnsafe}` : ""}

`, + "

<script>alert('This is an unsafe description.')</script>

", + ); +}); + +test("renders multiple html calls", () => { + assert.strictEqual( + html` +

+ !${conditionFalse ? "" : html` ${descriptionSafe} `} + ${array1} + !${conditionFalse ? html` ${array1} ` : ""} +

+ `, + // it should be formatted + ` +

+ This is a safe description. + 12345 + +

+ `, + ); +}); + +test("renders multiple html calls with different expression types", () => { + const obj = {}; + obj.toString = () => { + return "Description of the object."; + }; + + assert.strictEqual( + html` +

+ !${conditionTrue ? html` ${descriptionSafe} ` : ""} + !${conditionFalse + ? "" + : // prettier-ignore + html` ${array1.map((i) => {return i + 1;})} `}
+ And also, ${false} ${null}${undefined}${obj} +

+ `, + // it should be formatted + ` +

+ This is a safe description. + 23456
+ And also, false Description of the object. +

+ `, + ); +});