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

feat: HTML generator #5

Merged
merged 26 commits into from
Apr 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 61 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,22 @@ npm i ghtml

## API Reference

The main export of the package is the `html` function that can be used to tag template literals and escape their expressions. To bypass escaping an expression, prefix it with `!`.
### `html`

Node.js users also have access to the `includeFile` function that reads and outputs the content of a file while caching it in memory for future use.
The `html` function is used to tag template literals and escape their expressions. To bypass escaping an expression, prefix it with `!`.

### `htmlGenerator`

The `htmlGenerator` function is the generator version of the `html` function. It allows for the generation of HTML fragments in a streaming manner, which can be particularly useful for large templates or when generating HTML on-the-fly.

### `includeFile`

Available for Node.js users, the `includeFile` function is a wrapper around `readFileSync`. It reads and outputs the content of a file while also caching it in memory for faster future reuse.

## Usage

### `html`

```js
import { html } from "ghtml";

Expand All @@ -24,15 +34,63 @@ const greeting = html`<h1>Hello, ${username}!</h1>`;

console.log(greeting);
// Output: <h1>Hello, &lt;img src=&quot;https://example.com/hacker.png&quot;&gt;</h1>
```

To bypass escaping:

```js
const img = '<img src="https://example.com/safe.png">';
const container = html`<div>!${img}</div>`;

console.log(container);
// Output: <div><img src="https://example.com/safe.png"></div>
```

The `includeFile` function returns the content of a file. Again, remember that it also caches the result, so any subsequent modifications to the same file won't be reflected until the app is restarted:
When nesting multiple `html` expressions, always use `!` as they will do their own escaping:

```js
const someCondition = Math.random() >= 0.5;
const data = {
username: "John",
age: 21,
};

const htmlString = html`
<div>
!${someCondition
? html`
<p>Data:</p>
<ul>
${Object.values(data).map(
([key, val]) => `
${key}: ${val}
`,
)}
</ul>
`
: "<p>No data...</p>"}
</div>
`;
```

### `htmlGenerator`

```js
import { htmlGenerator as html } from "ghtml";
import { Readable } from "node:stream";

const htmlContent = html`<html>
<p>${"...your HTML content..."}</p>
</html>`;
const readableStream = Readable.from(htmlContent);

http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/html;charset=utf-8" });
readableStream.pipe(res);
});
```

### `includeFile`

```js
import { includeFile } from "ghtml/includeFile.js";
Expand Down
78 changes: 76 additions & 2 deletions src/html.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ const html = (literals, ...expressions) => {
}

accumulator += literal + expression;

++index;
}

Expand All @@ -51,4 +50,79 @@ const html = (literals, ...expressions) => {
return accumulator;
};

export { html };
/**
* @param {{ raw: string[] }} literals Tagged template literals.
* @param {...any} expressions Expressions to interpolate.
* @yields {string} The HTML strings.
*/
const htmlGenerator = function* (literals, ...expressions) {
let index = 0;

while (index < expressions.length) {
let literal = literals.raw[index];
let expression;

if (typeof expressions[index] === "string") {
expression = expressions[index];
} else if (expressions[index] == null) {
expression = "";
} else if (Array.isArray(expressions[index])) {
expression = expressions[index].join("");
} else {
if (typeof expressions[index][Symbol.iterator] === "function") {
const isRaw =
literal.length > 0 && literal.charCodeAt(literal.length - 1) === 33;

if (isRaw) {
literal = literal.slice(0, -1);
}

if (literal.length) {
yield literal;
}

for (const value of expressions[index]) {
expression =
typeof value === "string"
? value
: value == null
? ""
: Array.isArray(value)
? value.join("")
: `${value}`;

if (expression.length) {
if (!isRaw) {
expression = expression.replace(escapeRegExp, escapeFunction);
}

yield expression;
}
}

++index;
continue;
}

expression = `${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);
}

if (literal.length || expression.length) {
yield literal + expression;
}

++index;
}

if (literals.raw[index].length) {
yield literals.raw[index];
}
};

export { html, htmlGenerator };
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { html } from "./html.js";
export { html, htmlGenerator } from "./html.js";
57 changes: 55 additions & 2 deletions test/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert";
import { html } from "../src/index.js";
import { html, htmlGenerator } from "../src/index.js";

const username = "Paul";
const descriptionSafe = "This is a safe description.";
Expand All @@ -11,6 +11,16 @@ const conditionTrue = true;
const conditionFalse = false;
const emptyString = "";

const generatorExample = function* () {
yield "<p>";
yield descriptionSafe;
yield descriptionUnsafe;
yield array1;
yield null;
yield 255;
yield "</p>";
};

test("renders empty input", () => {
assert.strictEqual(html({ raw: [""] }), "");
});
Expand All @@ -30,7 +40,7 @@ test("renders safe content", () => {
);
});

test("escapes unsafe output", () => {
test("escapes unsafe content", () => {
assert.strictEqual(
html`<p>${descriptionUnsafe}</p>`,
`<p>&lt;script&gt;alert(&apos;This is an unsafe description.&apos;)&lt;/script&gt;</p>`,
Expand Down Expand Up @@ -105,3 +115,46 @@ test("renders multiple html calls with different expression types", () => {
`,
);
});

test("htmlGenerator renders safe content", () => {
const generator = htmlGenerator`<p>${descriptionSafe}!${descriptionSafe}G!${htmlGenerator`${array1}`}!${null}${255}</p>`;
assert.strictEqual(generator.next().value, "<p>This is a safe description.");
assert.strictEqual(generator.next().value, "This is a safe description.");
assert.strictEqual(generator.next().value, "G");
assert.strictEqual(generator.next().value, "12345");
assert.strictEqual(generator.next().value, "255");
assert.strictEqual(generator.next().value, "</p>");
assert.strictEqual(generator.next().done, true);
});

test("htmlGenerator escapes unsafe content", () => {
const generator = htmlGenerator`<p>${descriptionUnsafe}${descriptionUnsafe}${htmlGenerator`${array1}`}${null}${255}</p>`;
assert.strictEqual(
generator.next().value,
"<p>&lt;script&gt;alert(&apos;This is an unsafe description.&apos;)&lt;/script&gt;",
);
assert.strictEqual(
generator.next().value,
"&lt;script&gt;alert(&apos;This is an unsafe description.&apos;)&lt;/script&gt;",
);
assert.strictEqual(generator.next().value, "12345");
assert.strictEqual(generator.next().value, "255");
assert.strictEqual(generator.next().value, "</p>");
assert.strictEqual(generator.next().done, true);
});

test("htmlGenerator works with other generators", () => {
const generator = htmlGenerator`<div>!${generatorExample()}</div>`;
assert.strictEqual(generator.next().value, "<div>");
assert.strictEqual(generator.next().value, "<p>");
assert.strictEqual(generator.next().value, "This is a safe description.");
assert.strictEqual(
generator.next().value,
"<script>alert('This is an unsafe description.')</script>",
);
assert.strictEqual(generator.next().value, "12345");
assert.strictEqual(generator.next().value, "255");
assert.strictEqual(generator.next().value, "</p>");
assert.strictEqual(generator.next().value, "</div>");
assert.strictEqual(generator.next().done, true);
});
Loading