diff --git a/.changeset/scriptlet-and-spaced-type-statements.md b/.changeset/scriptlet-and-spaced-type-statements.md new file mode 100644 index 0000000..86b3b08 --- /dev/null +++ b/.changeset/scriptlet-and-spaced-type-statements.md @@ -0,0 +1,5 @@ +--- +"htmljs-parser": minor +--- + +Read a `type`, `interface` or `declare` scriptlet as a type, as statements already are, so `$ type H = () => void` or `$ type A = B` no longer swallows the next line; `$ type = x` stays JavaScript. A statement also finds its type keyword after extra whitespace (`static type F = () => void`), and `isValidScriptlet` matches the parser. diff --git a/src/__tests__/fixtures/scriptlet-type-name-guard/__snapshots__/scriptlet-type-name-guard.expected.txt b/src/__tests__/fixtures/scriptlet-type-name-guard/__snapshots__/scriptlet-type-name-guard.expected.txt new file mode 100644 index 0000000..a4e5a48 --- /dev/null +++ b/src/__tests__/fixtures/scriptlet-type-name-guard/__snapshots__/scriptlet-type-name-guard.expected.txt @@ -0,0 +1,14 @@ +1╭─ $ type = a < b + │ │ ╰─ scriptlet.value "type = a < b" + ╰─ ╰─ scriptlet "$ type = a < b" +2╭─ $ type in obj && go() + │ │ ╰─ scriptlet.value "type in obj && go()" + ╰─ ╰─ scriptlet "$ type in obj && go()" +3╭─ $ type (x) + │ │ ╰─ scriptlet.value "type (x)" + ╰─ ╰─ scriptlet "$ type (x)" +4╭─
+ │ ││ ╰─ openTagEnd:selfClosed "/>" + │ │╰─ tagName "div" + ╰─ ╰─ openTagStart +5╰─ \ No newline at end of file diff --git a/src/__tests__/fixtures/scriptlet-type-name-guard/input.marko b/src/__tests__/fixtures/scriptlet-type-name-guard/input.marko new file mode 100644 index 0000000..87ad1aa --- /dev/null +++ b/src/__tests__/fixtures/scriptlet-type-name-guard/input.marko @@ -0,0 +1,4 @@ +$ type = a < b +$ type in obj && go() +$ type (x) +
diff --git a/src/__tests__/fixtures/scriptlet-type-void/__snapshots__/scriptlet-type-void.expected.txt b/src/__tests__/fixtures/scriptlet-type-void/__snapshots__/scriptlet-type-void.expected.txt new file mode 100644 index 0000000..0224667 --- /dev/null +++ b/src/__tests__/fixtures/scriptlet-type-void/__snapshots__/scriptlet-type-void.expected.txt @@ -0,0 +1,27 @@ +1╭─ $ type H = () => void + │ │ ╰─ scriptlet.value "type H = () => void" + ╰─ ╰─ scriptlet "$ type H = () => void" +2╭─
+ │ ││ ╰─ openTagEnd + │ │╰─ tagName "div" + ╰─ ╰─ openTagStart +3╭─ $ type A = B + │ │ │ ╰─ scriptlet.value "type A = B" + │ │ ╰─ scriptlet "$ type A = B" + ╰─ ╰─ text "\n " +4╭─
+ │ │ │ ╰─ closeTagEnd(div) + │ │ ╰─ closeTagName "div" + │ ├─ text "\n" + ╰─ ╰─ closeTagStart " + │ ││ ╰─ openTagEnd:selfClosed "/>" + │ │╰─ tagName "span" + ╰─ ╰─ openTagStart +8╰─ \ No newline at end of file diff --git a/src/__tests__/fixtures/scriptlet-type-void/input.marko b/src/__tests__/fixtures/scriptlet-type-void/input.marko new file mode 100644 index 0000000..f1bf62a --- /dev/null +++ b/src/__tests__/fixtures/scriptlet-type-void/input.marko @@ -0,0 +1,7 @@ +$ type H = () => void +
+ $ type A = B +
+$ interface I { a(): void } +$ declare const d: void + diff --git a/src/__tests__/fixtures/ts-type-statement-extra-whitespace/__snapshots__/ts-type-statement-extra-whitespace.expected.txt b/src/__tests__/fixtures/ts-type-statement-extra-whitespace/__snapshots__/ts-type-statement-extra-whitespace.expected.txt new file mode 100644 index 0000000..de183f2 --- /dev/null +++ b/src/__tests__/fixtures/ts-type-statement-extra-whitespace/__snapshots__/ts-type-statement-extra-whitespace.expected.txt @@ -0,0 +1,7 @@ +1╭─ static type F = () => void + ╰─ ╰─ tagName "static" +2╭─ static const x = 1 + │ ├─ openTagEnd + ╰─ ╰─ tagName "static" +3╭─ + ╰─ ╰─ openTagEnd \ No newline at end of file diff --git a/src/__tests__/fixtures/ts-type-statement-extra-whitespace/input.marko b/src/__tests__/fixtures/ts-type-statement-extra-whitespace/input.marko new file mode 100644 index 0000000..0c08080 --- /dev/null +++ b/src/__tests__/fixtures/ts-type-statement-extra-whitespace/input.marko @@ -0,0 +1,2 @@ +static type F = () => void +static const x = 1 diff --git a/src/__tests__/validate.test.ts b/src/__tests__/validate.test.ts index 90fc0ed..fc8cb41 100644 --- a/src/__tests__/validate.test.ts +++ b/src/__tests__/validate.test.ts @@ -51,6 +51,10 @@ describe("validation helpers", () => { it("ends a type statement at an unindented newline", () => { assert.equal(isValidStatement("type A = B\nfoo"), 0); }); + + it("reads a type statement after extra whitespace", () => { + assert.equal(isValidStatement(" type A = B\nfoo"), 0); + }); }); describe("isValidScriptlet", () => { @@ -58,6 +62,14 @@ describe("validation helpers", () => { assert.equal(isValidScriptlet("foo + bar"), 2); }); + it("ends a type scriptlet at a line-final void", () => { + assert.equal(isValidScriptlet("type H = () => void\nfoo"), 0); + }); + + it("reads an assignment to a variable named type as JavaScript", () => { + assert.equal(isValidScriptlet("type = a < b"), 2); + }); + it("rejects indented continuation lines", () => { assert.equal(isValidScriptlet("foo\n + bar"), 0); }); diff --git a/src/states/EXPRESSION.ts b/src/states/EXPRESSION.ts index c3fd96f..52ef489 100644 --- a/src/states/EXPRESSION.ts +++ b/src/states/EXPRESSION.ts @@ -56,7 +56,7 @@ const tsUnaryKeywords = [ "unique", ] as const; -const binaryKeywords = [ +export const binaryKeywords = [ "as", "extends", "instanceof", // Note: instanceof must be checked before `in` diff --git a/src/states/INLINE_SCRIPT.ts b/src/states/INLINE_SCRIPT.ts index e003a03..7681061 100644 --- a/src/states/INLINE_SCRIPT.ts +++ b/src/states/INLINE_SCRIPT.ts @@ -1,11 +1,15 @@ import { + isIndentCode, + isWordCode, matchesCloseCurlyBrace, type Meta, + Parser, type Range, STATE, type StateDefinition, } from "../internal.ts"; import * as CODE from "../util/codes.ts"; +import { binaryKeywords } from "./EXPRESSION.ts"; interface ScriptletMeta extends Meta { block: boolean; @@ -49,9 +53,7 @@ export const INLINE_SCRIPT: StateDefinition = { this.enterState(STATE.EXPRESSION).shouldTerminate = matchesCloseCurlyBrace; } else { - const expr = this.enterState(STATE.EXPRESSION); - expr.operators = true; - expr.terminatedByEOL = true; + prepareScriptlet(this.enterState(STATE.EXPRESSION), this); } }, @@ -70,3 +72,59 @@ export const INLINE_SCRIPT: StateDefinition = { this.exitState(); }, }; + +const typeKeywords = ["declare", "interface", "type"] as const; + +// Sets up an unenclosed scriptlet's expression, whose code starts at `pos`. +// `declare`, `interface` or `type` before a name (or `type` before `{`/`*`, +// as in `import type { A }`) starts a type, which ends differently than +// JavaScript: a trailing `void` or `>` does not continue it. `type = 1` and +// `type in x` stay JavaScript. +export function prepareScriptlet( + expr: STATE.ExpressionMeta, + parser: Parser, + pos = parser.pos, +) { + expr.operators = true; + expr.terminatedByEOL = true; + + const { data } = parser; + while (isIndentCode(data.charCodeAt(pos))) pos++; + + for (const keyword of typeKeywords) { + if (!parser.lookAheadFor(keyword, pos)) continue; + + let namePos = pos + keyword.length; + if (!isIndentCode(data.charCodeAt(namePos))) return; + while (isIndentCode(data.charCodeAt(namePos))) namePos++; + + if (startsTypeName(parser, namePos, keyword === "type")) { + expr.inType = true; + expr.forceType = true; + parser.pos = namePos; + } + return; + } +} + +function startsTypeName(parser: Parser, pos: number, allowGroup: boolean) { + const code = parser.data.charCodeAt(pos); + if (code === CODE.OPEN_CURLY_BRACE || code === CODE.ASTERISK) { + return allowGroup; + } + + if (!isWordCode(code) || (code >= CODE.NUMBER_0 && code <= CODE.NUMBER_9)) { + return false; + } + + for (const keyword of binaryKeywords) { + if ( + parser.lookAheadFor(keyword, pos) && + !isWordCode(parser.data.charCodeAt(pos + keyword.length)) + ) { + return false; + } + } + + return true; +} diff --git a/src/states/TAG_NAME.ts b/src/states/TAG_NAME.ts index 13a768c..2cca818 100644 --- a/src/states/TAG_NAME.ts +++ b/src/states/TAG_NAME.ts @@ -10,6 +10,7 @@ import { import * as CODE from "../util/codes.ts"; import * as ErrorCode from "../util/error-code.ts"; import * as TagType from "../util/tag-type.ts"; +import { prepareScriptlet } from "./INLINE_SCRIPT.ts"; export interface TagNameMeta extends Meta, Ranges.Template { shorthandCode: -1 | typeof CODE.NUMBER_SIGN | typeof CODE.PERIOD; @@ -195,18 +196,6 @@ export function prepareStatement( parser: Parser, pos = parser.pos, ) { - expr.operators = true; - expr.terminatedByEOL = true; expr.consumeIndentedContent = true; - - const typeStatementMatch = - parser.lookAheadFor("declare ", pos) || - parser.lookAheadFor("interface ", pos) || - parser.lookAheadFor("type ", pos); - if (typeStatementMatch) { - expr.inType = true; - expr.forceType = true; - parser.pos = pos + typeStatementMatch.length; - parser.consumeWhitespace(); - } + prepareScriptlet(expr, parser, pos); } diff --git a/src/util/validators.ts b/src/util/validators.ts index 40dcab0..60a34de 100644 --- a/src/util/validators.ts +++ b/src/util/validators.ts @@ -1,5 +1,6 @@ import { type Meta, Parser, STATE, type StateDefinition } from "../internal.ts"; import { + prepareScriptlet, prepareStatement, shouldTerminateConciseAttrValue, shouldTerminateHtmlAttrValue, @@ -38,11 +39,6 @@ export function isValidScriptlet(code: string): Validity { return isValid(code, true, prepareScriptlet); } -function prepareScriptlet(expr: STATE.ExpressionMeta) { - expr.operators = true; - expr.terminatedByEOL = true; -} - export function isValidAttrValue(code: string, concise: boolean): Validity { return isValid(code, concise, prepareAttrValue); }