fix(compiler): reject dynamic api access in client functions
Usage-driven emission can only see api.<name> calls. api["name"]() or passing api to a helper is invisible to it, silently drops the block from the browser bundle, and fails at runtime instead of build time. Detect that dynamic/indirect use (masking strings and comments first, reusing the tokenizer's skipLiteralOrComment) and refuse to compile instead, naming the offending function.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
// Generated by scripts/build-editor-compiler.mjs. Do not edit directly.
|
||||||
// WRN editor compiler source hash: 6c7feefb98f5f19d3156a5953ddce47910f3c1572395bf4f39b1a23771b648b1
|
// WRN editor compiler source hash: 3b1bf12fc638343d15eb8502a2b10df4ad909529de8c94b612cb567959324f5d
|
||||||
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
// WRN editor compiler generator hash: a54ca847c758bc98d8e353ad6d70088df31de1820f6cf9d1c3462505f563e6b8
|
||||||
// Generated with TypeScript: 6.0.3
|
// Generated with TypeScript: 6.0.3
|
||||||
const __nodeRequire = require;
|
const __nodeRequire = require;
|
||||||
@@ -712,6 +712,40 @@ function _functionEntry(ast, fn, availableFunctions) {
|
|||||||
}
|
}
|
||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Blank out string/template literals and comments in a raw JS body, preserving
|
||||||
|
* length and newlines, so a scanner walking the result never mistakes text
|
||||||
|
* inside a string or comment for real code. Reuses the tokenizer's
|
||||||
|
* comment/string-skipping rules (`skipLiteralOrComment`) instead of
|
||||||
|
* reimplementing them — a second hand-rolled scanner is how apostrophes in
|
||||||
|
* prose used to swallow braces elsewhere in this codebase.
|
||||||
|
*/
|
||||||
|
function maskStringsAndComments(src) {
|
||||||
|
let out = "";
|
||||||
|
let i = 0;
|
||||||
|
let atLineStart = true;
|
||||||
|
while (i < src.length) {
|
||||||
|
const c = src[i];
|
||||||
|
if (c === "\n") {
|
||||||
|
out += c;
|
||||||
|
atLineStart = true;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const skipped = (0, syntax_1.skipLiteralOrComment)(src, i, atLineStart);
|
||||||
|
if (skipped !== null) {
|
||||||
|
out += src.slice(i, skipped).replace(/[^\n]/g, " ");
|
||||||
|
i = skipped;
|
||||||
|
atLineStart = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c !== " " && c !== "\t" && c !== "\r")
|
||||||
|
atLineStart = false;
|
||||||
|
out += c;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Block names the page's client functions actually call.
|
* Block names the page's client functions actually call.
|
||||||
*
|
*
|
||||||
@@ -721,15 +755,49 @@ function _functionEntry(ast, fn, availableFunctions) {
|
|||||||
*/
|
*/
|
||||||
function clientCalledApiNames(ast) {
|
function clientCalledApiNames(ast) {
|
||||||
const called = new Set();
|
const called = new Set();
|
||||||
const bodies = ast.runtimeFunctions
|
for (const fn of ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime))) {
|
||||||
.filter((fn) => ["client", "shared"].includes(fn.runtime))
|
const masked = maskStringsAndComments(fn.body);
|
||||||
.map((fn) => fn.body)
|
for (const match of masked.matchAll(/\bapi\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/g)) {
|
||||||
.join("\n");
|
called.add(match[1]);
|
||||||
for (const match of bodies.matchAll(/\bapi\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/g)) {
|
}
|
||||||
called.add(match[1]);
|
|
||||||
}
|
}
|
||||||
return called;
|
return called;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Refuse to compile a client/shared function whose body references `api` in
|
||||||
|
* any form other than `api.<identifier>` — e.g. `api["searchUsers"]()`, or
|
||||||
|
* passing `api` to a helper. Usage-driven emission (see `clientCalledApiNames`
|
||||||
|
* above) can only see `api.<identifier>` calls; a dynamic or indirect
|
||||||
|
* reference is invisible to it, so the referenced block would be silently
|
||||||
|
* dropped from the browser bundle and the call would fail at runtime with
|
||||||
|
* "api.<name> is not a function". That failure direction is worse than a
|
||||||
|
* loud compile error, so it is caught here instead.
|
||||||
|
*
|
||||||
|
* Strings and comments are masked out first so `api` appearing in prose or in
|
||||||
|
* a quoted value never trips this check, and every `api.<identifier>` access
|
||||||
|
* is stripped before the standalone-word scan so a real, well-formed call
|
||||||
|
* never does either.
|
||||||
|
*/
|
||||||
|
function assertNoDynamicApiAccess(ast) {
|
||||||
|
// Only pages with a mode "any" block have anything at stake here: those
|
||||||
|
// blocks are emitted solely because usage detection saw `api.<name>`, so a
|
||||||
|
// dynamic reference this scan can't see is the one that silently drops a
|
||||||
|
// block from the bundle. Mode "client" blocks always ship regardless of
|
||||||
|
// usage, and a page with no api blocks at all may still declare an
|
||||||
|
// ordinary `state api` (see the B5 regression test) where a bare "api"
|
||||||
|
// identifier is just that state, not a missed block reference.
|
||||||
|
if (!ast.dataApis.some((block) => block.mode === "any" && block.sections))
|
||||||
|
return;
|
||||||
|
for (const fn of ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime))) {
|
||||||
|
const masked = maskStringsAndComments(fn.body);
|
||||||
|
const withoutCalls = masked.replace(/\bapi\s*\.\s*[A-Za-z_$][A-Za-z0-9_$]*/g, (match) => match.replace(/[^\n]/g, " "));
|
||||||
|
if (/\bapi\b/.test(withoutCalls)) {
|
||||||
|
throw new Error(`.wrn ${fn.runtime} function "${fn.name}" in page "${ast.name}" references "api" in a form other than "api.<name>(...)". ` +
|
||||||
|
`API blocks must be called as api.name(...) so the compiler can tell which ones the browser needs to receive; ` +
|
||||||
|
`dynamic or indirect access (e.g. api["name"](), or passing api to a helper) cannot be detected and would silently drop the block from the browser bundle.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* A block is emitted into the browser module when it is authored as
|
* A block is emitted into the browser module when it is authored as
|
||||||
* client-only, or when it is mode "any" and a client function actually calls
|
* client-only, or when it is mode "any" and a client function actually calls
|
||||||
@@ -764,6 +832,7 @@ function apiBindings(ast) {
|
|||||||
return members.length ? `const api = {\n${members.join(",\n")}\n };` : "";
|
return members.length ? `const api = {\n${members.join(",\n")}\n };` : "";
|
||||||
}
|
}
|
||||||
function generateBrowserModule(ast) {
|
function generateBrowserModule(ast) {
|
||||||
|
assertNoDynamicApiAccess(ast);
|
||||||
const functions = ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime));
|
const functions = ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime));
|
||||||
const functionNames = functions.map((fn) => fn.name);
|
const functionNames = functions.map((fn) => fn.name);
|
||||||
const state = ast.states.filter((entry) => entry.runtime !== "server").map((entry) => entry.name);
|
const state = ast.states.filter((entry) => entry.runtime !== "server").map((entry) => entry.name);
|
||||||
@@ -6217,10 +6286,11 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|||||||
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
||||||
};
|
};
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.stripRuntimeFunctionModifiers = exports.parseStructuredImports = exports.parseStoreLifecycle = exports.parseStateDeclarations = exports.parseRuntimeFunctions = exports.parsePersist = exports.parseOutputs = exports.parseComputedDeclarations = exports.supportsSyntaxFeature = exports.diagnosticSummary = exports.sliceSource = exports.createSourceRange = exports.WRN_SYNTAX_FEATURES = exports.WRN_SYNTAX_VERSION = exports.positionAt = exports.isRuntimeTarget = exports.isHydrationStrategy = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.containsReadonlyPropMutation = exports.classifyParseError = exports.assertValidAst = exports.validateTypedInitializer = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.VOID_ELEMENTS = exports.ParseError = exports.parseHtmlView = exports.parse = exports.formatWrn = exports.LexError = exports.Lexer = void 0;
|
exports.stripRuntimeFunctionModifiers = exports.parseStructuredImports = exports.parseStoreLifecycle = exports.parseStateDeclarations = exports.parseRuntimeFunctions = exports.parsePersist = exports.parseOutputs = exports.parseComputedDeclarations = exports.supportsSyntaxFeature = exports.diagnosticSummary = exports.sliceSource = exports.createSourceRange = exports.WRN_SYNTAX_FEATURES = exports.WRN_SYNTAX_VERSION = exports.positionAt = exports.isRuntimeTarget = exports.isHydrationStrategy = exports.formatDiagnostic = exports.diagnosticFromError = exports.diagnose = exports.containsReadonlyPropMutation = exports.classifyParseError = exports.assertValidAst = exports.validateTypedInitializer = exports.runtimeTypeOf = exports.inferredRuntimeType = exports.eraseFunctionTypes = exports.VOID_ELEMENTS = exports.ParseError = exports.parseHtmlView = exports.parse = exports.formatWrn = exports.skipLiteralOrComment = exports.LexError = exports.Lexer = void 0;
|
||||||
var tokenizer_ts_1 = require("./tokenizer.js");
|
var tokenizer_ts_1 = require("./tokenizer.js");
|
||||||
Object.defineProperty(exports, "Lexer", { enumerable: true, get: function () { return tokenizer_ts_1.Lexer; } });
|
Object.defineProperty(exports, "Lexer", { enumerable: true, get: function () { return tokenizer_ts_1.Lexer; } });
|
||||||
Object.defineProperty(exports, "LexError", { enumerable: true, get: function () { return tokenizer_ts_1.LexError; } });
|
Object.defineProperty(exports, "LexError", { enumerable: true, get: function () { return tokenizer_ts_1.LexError; } });
|
||||||
|
Object.defineProperty(exports, "skipLiteralOrComment", { enumerable: true, get: function () { return tokenizer_ts_1.skipLiteralOrComment; } });
|
||||||
var formatter_ts_1 = require("./formatter.js");
|
var formatter_ts_1 = require("./formatter.js");
|
||||||
Object.defineProperty(exports, "formatWrn", { enumerable: true, get: function () { return formatter_ts_1.formatWrn; } });
|
Object.defineProperty(exports, "formatWrn", { enumerable: true, get: function () { return formatter_ts_1.formatWrn; } });
|
||||||
var parser_ts_1 = require("./parser.js");
|
var parser_ts_1 = require("./parser.js");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// WRN editor extension source hash: 1791876dd8fecb7b56caf3cbb629ecdd96b84a1c0e46d3cfbb5515a8c30e281d
|
// WRN editor extension source hash: 42ceb9645e98cbc79abd3104e8426da535affc52327fb9e42ce658888d1b9941
|
||||||
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
// WRN editor extension generator hash: 456d1d614e44e5fb1f19b784176c09cf2ade9b64ef73a17934c2698150b62728
|
||||||
"use strict";
|
"use strict";
|
||||||
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
// WRN editor language server source hash: 7084da58f35fcb02c387b392d78660fa3b59f2060c765c3e982dd1918edab78a
|
// WRN editor language server source hash: d42dc4f557c0cb990f1d164708ca5d9ed8ddeabca3270f1b99f175b1f342572e
|
||||||
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
|
// WRN editor language server generator hash: f593a44aaf05495b789ce7a3086bee1eebb951b884d41c0e017bbcfe5f547e72
|
||||||
// @bun @bun-cjs
|
// @bun @bun-cjs
|
||||||
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
|
(function(exports, require, module, __filename, __dirname) {var __create = Object.create;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
eraseFunctionTypes,
|
eraseFunctionTypes,
|
||||||
|
skipLiteralOrComment,
|
||||||
type PageAst,
|
type PageAst,
|
||||||
type RuntimeFunctionDecl,
|
type RuntimeFunctionDecl,
|
||||||
type StructuredImportDecl,
|
type StructuredImportDecl,
|
||||||
@@ -307,6 +308,40 @@ function _functionEntry(
|
|||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blank out string/template literals and comments in a raw JS body, preserving
|
||||||
|
* length and newlines, so a scanner walking the result never mistakes text
|
||||||
|
* inside a string or comment for real code. Reuses the tokenizer's
|
||||||
|
* comment/string-skipping rules (`skipLiteralOrComment`) instead of
|
||||||
|
* reimplementing them — a second hand-rolled scanner is how apostrophes in
|
||||||
|
* prose used to swallow braces elsewhere in this codebase.
|
||||||
|
*/
|
||||||
|
function maskStringsAndComments(src: string): string {
|
||||||
|
let out = "";
|
||||||
|
let i = 0;
|
||||||
|
let atLineStart = true;
|
||||||
|
while (i < src.length) {
|
||||||
|
const c = src[i]!;
|
||||||
|
if (c === "\n") {
|
||||||
|
out += c;
|
||||||
|
atLineStart = true;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const skipped = skipLiteralOrComment(src, i, atLineStart);
|
||||||
|
if (skipped !== null) {
|
||||||
|
out += src.slice(i, skipped).replace(/[^\n]/g, " ");
|
||||||
|
i = skipped;
|
||||||
|
atLineStart = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (c !== " " && c !== "\t" && c !== "\r") atLineStart = false;
|
||||||
|
out += c;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block names the page's client functions actually call.
|
* Block names the page's client functions actually call.
|
||||||
*
|
*
|
||||||
@@ -316,18 +351,57 @@ function _functionEntry(
|
|||||||
*/
|
*/
|
||||||
function clientCalledApiNames(ast: PageAst): Set<string> {
|
function clientCalledApiNames(ast: PageAst): Set<string> {
|
||||||
const called = new Set<string>();
|
const called = new Set<string>();
|
||||||
const bodies = ast.runtimeFunctions
|
|
||||||
.filter((fn) => ["client", "shared"].includes(fn.runtime))
|
|
||||||
.map((fn) => fn.body)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
for (const match of bodies.matchAll(/\bapi\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/g)) {
|
for (const fn of ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime))) {
|
||||||
called.add(match[1]!);
|
const masked = maskStringsAndComments(fn.body);
|
||||||
|
for (const match of masked.matchAll(/\bapi\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)/g)) {
|
||||||
|
called.add(match[1]!);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return called;
|
return called;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuse to compile a client/shared function whose body references `api` in
|
||||||
|
* any form other than `api.<identifier>` — e.g. `api["searchUsers"]()`, or
|
||||||
|
* passing `api` to a helper. Usage-driven emission (see `clientCalledApiNames`
|
||||||
|
* above) can only see `api.<identifier>` calls; a dynamic or indirect
|
||||||
|
* reference is invisible to it, so the referenced block would be silently
|
||||||
|
* dropped from the browser bundle and the call would fail at runtime with
|
||||||
|
* "api.<name> is not a function". That failure direction is worse than a
|
||||||
|
* loud compile error, so it is caught here instead.
|
||||||
|
*
|
||||||
|
* Strings and comments are masked out first so `api` appearing in prose or in
|
||||||
|
* a quoted value never trips this check, and every `api.<identifier>` access
|
||||||
|
* is stripped before the standalone-word scan so a real, well-formed call
|
||||||
|
* never does either.
|
||||||
|
*/
|
||||||
|
function assertNoDynamicApiAccess(ast: PageAst): void {
|
||||||
|
// Only pages with a mode "any" block have anything at stake here: those
|
||||||
|
// blocks are emitted solely because usage detection saw `api.<name>`, so a
|
||||||
|
// dynamic reference this scan can't see is the one that silently drops a
|
||||||
|
// block from the bundle. Mode "client" blocks always ship regardless of
|
||||||
|
// usage, and a page with no api blocks at all may still declare an
|
||||||
|
// ordinary `state api` (see the B5 regression test) where a bare "api"
|
||||||
|
// identifier is just that state, not a missed block reference.
|
||||||
|
if (!ast.dataApis.some((block) => block.mode === "any" && block.sections)) return;
|
||||||
|
|
||||||
|
for (const fn of ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime))) {
|
||||||
|
const masked = maskStringsAndComments(fn.body);
|
||||||
|
const withoutCalls = masked.replace(/\bapi\s*\.\s*[A-Za-z_$][A-Za-z0-9_$]*/g, (match) =>
|
||||||
|
match.replace(/[^\n]/g, " "),
|
||||||
|
);
|
||||||
|
if (/\bapi\b/.test(withoutCalls)) {
|
||||||
|
throw new Error(
|
||||||
|
`.wrn ${fn.runtime} function "${fn.name}" in page "${ast.name}" references "api" in a form other than "api.<name>(...)". ` +
|
||||||
|
`API blocks must be called as api.name(...) so the compiler can tell which ones the browser needs to receive; ` +
|
||||||
|
`dynamic or indirect access (e.g. api["name"](), or passing api to a helper) cannot be detected and would silently drop the block from the browser bundle.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A block is emitted into the browser module when it is authored as
|
* A block is emitted into the browser module when it is authored as
|
||||||
* client-only, or when it is mode "any" and a client function actually calls
|
* client-only, or when it is mode "any" and a client function actually calls
|
||||||
@@ -370,6 +444,7 @@ function apiBindings(ast: PageAst): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function generateBrowserModule(ast: PageAst): string {
|
export function generateBrowserModule(ast: PageAst): string {
|
||||||
|
assertNoDynamicApiAccess(ast);
|
||||||
const functions = ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime));
|
const functions = ast.runtimeFunctions.filter((fn) => ["client", "shared"].includes(fn.runtime));
|
||||||
const functionNames = functions.map((fn) => fn.name);
|
const functionNames = functions.map((fn) => fn.name);
|
||||||
const state = ast.states.filter((entry) => entry.runtime !== "server").map((entry) => entry.name);
|
const state = ast.states.filter((entry) => entry.runtime !== "server").map((entry) => entry.name);
|
||||||
|
|||||||
@@ -58,3 +58,43 @@ test("declared field types never reach the browser module", () => {
|
|||||||
|
|
||||||
expect(browser).not.toContain("name?: string");
|
expect(browser).not.toContain("name?: string");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("dynamic bracket access on api is a compile error naming the construct", () => {
|
||||||
|
expect(() => generateTargets(parse(withCalls(` await api["used"]({ name: "a" })`)))).toThrow(
|
||||||
|
/api/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passing api to a helper is a compile error", () => {
|
||||||
|
expect(() => generateTargets(parse(withCalls(` callHelper(api)`)))).toThrow(/api/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a plain api.name(...) call still compiles", () => {
|
||||||
|
expect(() =>
|
||||||
|
generateTargets(parse(withCalls(` await api.used({ name: "a" })`))),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an identifier that merely contains 'api' does not trigger the dynamic-access error", () => {
|
||||||
|
expect(() =>
|
||||||
|
generateTargets(
|
||||||
|
parse(
|
||||||
|
withCalls(
|
||||||
|
` const rapidCheck = 1; this.apiary = rapidCheck; await api.used({ name: "a" })`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the word 'api' inside a comment or string literal does not trigger the dynamic-access error", () => {
|
||||||
|
expect(() =>
|
||||||
|
generateTargets(
|
||||||
|
parse(
|
||||||
|
withCalls(
|
||||||
|
` // this mentions api in a comment\n const note = "the api is great";\n await api.used({ name: "a" })`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { Lexer, LexError } from "./tokenizer.ts";
|
export { Lexer, LexError, skipLiteralOrComment } from "./tokenizer.ts";
|
||||||
export { formatWrn } from "./formatter.ts";
|
export { formatWrn } from "./formatter.ts";
|
||||||
export type { FormatWrnOptions } from "./formatter.ts";
|
export type { FormatWrnOptions } from "./formatter.ts";
|
||||||
export { parse, parseHtmlView, ParseError, VOID_ELEMENTS } from "./parser.ts";
|
export { parse, parseHtmlView, ParseError, VOID_ELEMENTS } from "./parser.ts";
|
||||||
|
|||||||
Reference in New Issue
Block a user