Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/nine-cooks-sneeze.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"htmljs-parser": minor
---

Add expression validator helpers.
72 changes: 72 additions & 0 deletions src/__tests__/validate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import { isValidAttrValue, isValidStatement } from "..";

describe("validation helpers", () => {
describe("isValidStatement", () => {
it("accepts single-line expressions", () => {
assert.equal(isValidStatement("foo + bar"), true);
});

it("accepts indented continuation lines", () => {
assert.equal(isValidStatement("foo\n + bar"), true);
});

it("rejects unindented continuation lines", () => {
assert.equal(isValidStatement("foo\nbar"), false);
});

it("accepts indented ternary continuation", () => {
assert.equal(isValidStatement("foo ?\n bar : baz"), true);
});

it("rejects unterminated groups", () => {
assert.equal(isValidStatement("(foo"), false);
});

it("rejects mismatched closing groups", () => {
assert.equal(isValidStatement(")"), false);
});
});

describe("isValidAttrValue", () => {
it("accepts html attr values with operators", () => {
assert.equal(isValidAttrValue("foo + bar", false), true);
});

it("accepts html attr values containing =>", () => {
assert.equal(isValidAttrValue("foo=>bar", false), true);
});

it("rejects html attr values terminated by >", () => {
assert.equal(isValidAttrValue("foo >", false), false);
});

it("accepts concise attr values with >", () => {
assert.equal(isValidAttrValue("foo > bar", true), true);
});

it("rejects html attr values terminated by commas", () => {
assert.equal(isValidAttrValue("foo, bar", false), false);
});

it("accepts html attr values containing semicolons", () => {
assert.equal(isValidAttrValue("foo;", false), true);
});

it("rejects concise attr values terminated by semicolons", () => {
assert.equal(isValidAttrValue("foo;", true), false);
});

it("accepts html attr values with decrement operator", () => {
assert.equal(isValidAttrValue("foo --", false), true);
});

it("rejects concise attr values with decrement operator", () => {
assert.equal(isValidAttrValue("foo --", true), false);
});

it("rejects attr values separated only by whitespace", () => {
assert.equal(isValidAttrValue("foo bar", false), false);
});
});
});
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export {
type Range,
} from "./internal";

export { isValidStatement, isValidAttrValue } from "./util/validators";

/**
* Creates a new Marko parser.
*/
Expand Down
4 changes: 2 additions & 2 deletions src/states/ATTRIBUTE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ function shouldTerminateHtmlAttrName(code: number, data: string, pos: number) {
}
}

function shouldTerminateHtmlAttrValue(
export function shouldTerminateHtmlAttrValue(
this: STATE.ExpressionMeta,
code: number,
data: string,
Expand Down Expand Up @@ -368,7 +368,7 @@ function shouldTerminateConciseAttrName(
}
}

function shouldTerminateConciseAttrValue(
export function shouldTerminateConciseAttrValue(
code: number,
data: string,
pos: number,
Expand Down
102 changes: 102 additions & 0 deletions src/util/validators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {
CODE,
STATE,
Parser,
type StateDefinition,
type Meta,
} from "../internal";
import {
shouldTerminateConciseAttrValue,
shouldTerminateHtmlAttrValue,
} from "../states";

const ROOT_STATE: StateDefinition = {
name: "ROOT",
enter() {
return ROOT_RANGE;
},
exit() {},
char() {},
eol() {},
eof() {},
return() {},
};
const ROOT_RANGE = {
state: ROOT_STATE,
parent: undefined as unknown as Meta,
start: 0,
end: 0,
};

export function isValidStatement(code: string): boolean {
return isValid(code, true, prepareStatement);
}

function prepareStatement(expr: STATE.ExpressionMeta) {
expr.operators = true;
expr.terminatedByEOL = true;
expr.consumeIndentedContent = true;
}

export function isValidAttrValue(code: string, concise: boolean): boolean {
return isValid(code, concise, prepareAttrValue);
}

function prepareAttrValue(expr: STATE.ExpressionMeta, concise: boolean) {
expr.operators = true;
expr.terminatedByWhitespace = true;
expr.shouldTerminate = concise
? shouldTerminateConciseAttrValue
: shouldTerminateHtmlAttrValue;
}

function isValid(
data: string,
concise: boolean,
prepare: (expr: STATE.ExpressionMeta, concise: boolean) => void,
) {
const parser = new Parser({});
const maxPos = (parser.maxPos = data.length);
parser.pos = 0;
parser.data = data;
parser.indent = "";
parser.forward = 1;
parser.textPos = -1;
parser.isConcise = concise;
parser.beginMixedMode = parser.endingMixedModeAtEOL = false;
parser.lines = parser.activeTag = parser.activeAttr = undefined;
parser.activeState = ROOT_STATE;
parser.activeRange = ROOT_RANGE;
const expr = parser.enterState(STATE.EXPRESSION);
prepare(expr, concise);

while (parser.pos < maxPos) {
const code = data.charCodeAt(parser.pos);

if (code === CODE.NEWLINE) {
parser.forward = 1;
parser.activeState.eol.call(parser, 1, parser.activeRange);
} else if (
code === CODE.CARRIAGE_RETURN &&
data.charCodeAt(parser.pos + 1) === CODE.NEWLINE
) {
parser.forward = 2;
parser.activeState.eol.call(parser, 2, parser.activeRange);
} else {
parser.forward = 1;
parser.activeState.char.call(parser, code, parser.activeRange);
}

if (parser.activeRange === ROOT_RANGE) {
return false;
}

parser.pos += parser.forward;
}

return (
parser.pos === maxPos &&
parser.activeRange === expr &&
!expr.groupStack.length
);
}