Skip to content

Commit 9a637a3

Browse files
committed
feat: add expr validator helpers
1 parent a0e5fd9 commit 9a637a3

5 files changed

Lines changed: 183 additions & 2 deletions

File tree

.changeset/nine-cooks-sneeze.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"htmljs-parser": minor
3+
---
4+
5+
Add expression validator helpers.

src/__tests__/validate.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import assert from "node:assert/strict";
2+
import { isValidAttrValue, isValidStatement } from "..";
3+
4+
describe("validation helpers", () => {
5+
describe("isValidStatement", () => {
6+
it("accepts single-line expressions", () => {
7+
assert.equal(isValidStatement("foo + bar"), true);
8+
});
9+
10+
it("accepts indented continuation lines", () => {
11+
assert.equal(isValidStatement("foo\n + bar"), true);
12+
});
13+
14+
it("rejects unindented continuation lines", () => {
15+
assert.equal(isValidStatement("foo\nbar"), false);
16+
});
17+
18+
it("accepts indented ternary continuation", () => {
19+
assert.equal(isValidStatement("foo ?\n bar : baz"), true);
20+
});
21+
22+
it("rejects unterminated groups", () => {
23+
assert.equal(isValidStatement("(foo"), false);
24+
});
25+
26+
it("rejects mismatched closing groups", () => {
27+
assert.equal(isValidStatement(")"), false);
28+
});
29+
});
30+
31+
describe("isValidAttrValue", () => {
32+
it("accepts html attr values with operators", () => {
33+
assert.equal(isValidAttrValue("foo + bar", false), true);
34+
});
35+
36+
it("accepts html attr values containing =>", () => {
37+
assert.equal(isValidAttrValue("foo=>bar", false), true);
38+
});
39+
40+
it("rejects html attr values terminated by >", () => {
41+
assert.equal(isValidAttrValue("foo >", false), false);
42+
});
43+
44+
it("accepts concise attr values with >", () => {
45+
assert.equal(isValidAttrValue("foo > bar", true), true);
46+
});
47+
48+
it("rejects html attr values terminated by commas", () => {
49+
assert.equal(isValidAttrValue("foo, bar", false), false);
50+
});
51+
52+
it("accepts html attr values containing semicolons", () => {
53+
assert.equal(isValidAttrValue("foo;", false), true);
54+
});
55+
56+
it("rejects concise attr values terminated by semicolons", () => {
57+
assert.equal(isValidAttrValue("foo;", true), false);
58+
});
59+
60+
it("accepts html attr values with decrement operator", () => {
61+
assert.equal(isValidAttrValue("foo --", false), true);
62+
});
63+
64+
it("rejects concise attr values with decrement operator", () => {
65+
assert.equal(isValidAttrValue("foo --", true), false);
66+
});
67+
68+
it("rejects attr values separated only by whitespace", () => {
69+
assert.equal(isValidAttrValue("foo bar", false), false);
70+
});
71+
});
72+
});

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export {
1212
type Range,
1313
} from "./internal";
1414

15+
export { isValidStatement, isValidAttrValue } from "./util/validators";
16+
1517
/**
1618
* Creates a new Marko parser.
1719
*/

src/states/ATTRIBUTE.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ function shouldTerminateHtmlAttrName(code: number, data: string, pos: number) {
323323
}
324324
}
325325

326-
function shouldTerminateHtmlAttrValue(
326+
export function shouldTerminateHtmlAttrValue(
327327
this: STATE.ExpressionMeta,
328328
code: number,
329329
data: string,
@@ -368,7 +368,7 @@ function shouldTerminateConciseAttrName(
368368
}
369369
}
370370

371-
function shouldTerminateConciseAttrValue(
371+
export function shouldTerminateConciseAttrValue(
372372
code: number,
373373
data: string,
374374
pos: number,

src/util/validators.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import {
2+
CODE,
3+
STATE,
4+
Parser,
5+
type StateDefinition,
6+
type Meta,
7+
} from "../internal";
8+
import {
9+
shouldTerminateConciseAttrValue,
10+
shouldTerminateHtmlAttrValue,
11+
} from "../states";
12+
13+
const ROOT_STATE: StateDefinition = {
14+
name: "ROOT",
15+
enter() {
16+
return ROOT_RANGE;
17+
},
18+
exit() {},
19+
char() {},
20+
eol() {},
21+
eof() {},
22+
return() {},
23+
};
24+
const ROOT_RANGE = {
25+
state: ROOT_STATE,
26+
parent: undefined as unknown as Meta,
27+
start: 0,
28+
end: 0,
29+
};
30+
31+
export function isValidStatement(code: string): boolean {
32+
return isValid(code, true, prepareStatement);
33+
}
34+
35+
function prepareStatement(expr: STATE.ExpressionMeta) {
36+
expr.operators = true;
37+
expr.terminatedByEOL = true;
38+
expr.consumeIndentedContent = true;
39+
}
40+
41+
export function isValidAttrValue(code: string, concise: boolean): boolean {
42+
return isValid(code, concise, prepareAttrValue);
43+
}
44+
45+
function prepareAttrValue(expr: STATE.ExpressionMeta, concise: boolean) {
46+
expr.operators = true;
47+
expr.terminatedByWhitespace = true;
48+
expr.shouldTerminate = concise
49+
? shouldTerminateConciseAttrValue
50+
: shouldTerminateHtmlAttrValue;
51+
}
52+
53+
function isValid(
54+
data: string,
55+
concise: boolean,
56+
prepare: (expr: STATE.ExpressionMeta, concise: boolean) => void,
57+
) {
58+
const parser = new Parser({});
59+
const maxPos = (parser.maxPos = data.length);
60+
parser.pos = 0;
61+
parser.data = data;
62+
parser.indent = "";
63+
parser.forward = 1;
64+
parser.textPos = -1;
65+
parser.isConcise = concise;
66+
parser.beginMixedMode = parser.endingMixedModeAtEOL = false;
67+
parser.lines = parser.activeTag = parser.activeAttr = undefined;
68+
parser.activeState = ROOT_STATE;
69+
parser.activeRange = ROOT_RANGE;
70+
const expr = parser.enterState(STATE.EXPRESSION);
71+
prepare(expr, concise);
72+
73+
while (parser.pos < maxPos) {
74+
const code = data.charCodeAt(parser.pos);
75+
76+
if (code === CODE.NEWLINE) {
77+
parser.forward = 1;
78+
parser.activeState.eol.call(parser, 1, parser.activeRange);
79+
} else if (
80+
code === CODE.CARRIAGE_RETURN &&
81+
data.charCodeAt(parser.pos + 1) === CODE.NEWLINE
82+
) {
83+
parser.forward = 2;
84+
parser.activeState.eol.call(parser, 2, parser.activeRange);
85+
} else {
86+
parser.forward = 1;
87+
parser.activeState.char.call(parser, code, parser.activeRange);
88+
}
89+
90+
if (parser.activeRange === ROOT_RANGE) {
91+
return false;
92+
}
93+
94+
parser.pos += parser.forward;
95+
}
96+
97+
return (
98+
parser.pos === maxPos &&
99+
parser.activeRange === expr &&
100+
!expr.groupStack.length
101+
);
102+
}

0 commit comments

Comments
 (0)