diff --git a/package-lock.json b/package-lock.json index 51436d712..56c444098 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5985,6 +5985,12 @@ "integrity": "sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==", "dev": true }, + "esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", + "dev": true + }, "espree": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz", diff --git a/package.json b/package.json index a5eb85c67..f098f3d50 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "cross-env": "^7.0.2", "eslint": "^7.14.0", "eslint-config-prettier": "^6.15.0", + "esm": "^3.2.25", "fixpack": "^3.0.6", "husky": "^4.3.0", "jsdom": "^16.4.0", @@ -56,7 +57,7 @@ "size": "cross-env SIZE=1 rollup -c ./rollup.config.js && node ./utilities/sizes.js", "size:check": "cross-env CHECK=1 npm run size", "size:write": "cross-env WRITE=1 npm run size && git add .sizes.json", - "test": "cross-env NODE_ENV=test MARKO_SOURCE_RUNTIME=1 mocha -r ts-node/register -r source-map-support/register packages/*/test/{*.test.ts,*/*.test.ts}", + "test": "cross-env NODE_ENV=test MARKO_SOURCE_RUNTIME=1 TS_NODE_IGNORE='/node_modules/(?!@marko/)/' mocha -r esm -r ts-node/register -r source-map-support/register packages/*/test/{*.test.ts,*/*.test.ts}", "test:coverage": "nyc --reporter=text-summary npm run test", "test:watch": "npm run test -- --watch --watch-files '**/*.ts'" } diff --git a/packages/translator/src/core/translate-html-comment.ts b/packages/translator/src/core/translate-html-comment.ts index baf2771f3..3e66344c3 100644 --- a/packages/translator/src/core/translate-html-comment.ts +++ b/packages/translator/src/core/translate-html-comment.ts @@ -6,12 +6,13 @@ import { assertNoVar } from "@marko/babel-utils"; import { writeHTML } from "../util/html-write"; +import { writeTemplate } from "../util/dom-writer"; import { isOutputHTML } from "../util/marko-config"; export function enter(tag: NodePath) { if (isOutputHTML(tag)) { writeHTML(tag)``; - } + } else writeTemplate(tag, `-->`); tag.remove(); } diff --git a/packages/translator/src/placeholder/dom.ts b/packages/translator/src/placeholder/dom.ts index 0be620c83..108dca3b3 100644 --- a/packages/translator/src/placeholder/dom.ts +++ b/packages/translator/src/placeholder/dom.ts @@ -1,6 +1,41 @@ import { types as t, NodePath } from "@marko/babel-types"; +import { callRuntime } from "../util/runtime"; +import { + needsPlaceholderMarker, + isOnlyChild, + Walks, + writeHydrate, + writeTemplate, + writeWalks, + checkNextMarker +} from "../util/dom-writer"; -// eslint-disable-next-line @typescript-eslint/no-unused-vars export default function (placeholder: NodePath) { - // TODO. + if (needsPlaceholderMarker(placeholder)) { + console.log("REPLACE"); + writeWalks(placeholder, Walks.REPLACE); + writeTemplate(placeholder, ""); + } else if (isOnlyChild(placeholder)) { + console.log("GET"); + writeWalks(placeholder, Walks.GET); + writeTemplate(placeholder, " "); + } else if (!checkNextMarker(placeholder)) { + console.log("AFTER"); + writeWalks(placeholder, Walks.AFTER); + } else { + console.log("BEFORE"); + writeWalks(placeholder, Walks.BEFORE); + } + + // writeHydrate( + // placeholder, + // t.expressionStatement(callRuntime(placeholder, "walk")) + // ); + writeHydrate( + placeholder, + t.expressionStatement( + callRuntime(placeholder, "text", placeholder.get("value").node) + ) + ); + placeholder.remove(); } diff --git a/packages/translator/src/program/dom.ts b/packages/translator/src/program/dom.ts index e784ed5c2..88c2a1fcc 100644 --- a/packages/translator/src/program/dom.ts +++ b/packages/translator/src/program/dom.ts @@ -1,7 +1,14 @@ import { types as t, NodePath } from "@marko/babel-types"; +import { writeExports } from "../util/dom-export"; // eslint-disable-next-line @typescript-eslint/no-unused-vars -export function enter(program: NodePath) {} +export function enter(program: NodePath) { + program.state.template = ""; + program.state.walks = []; + program.state.hydrate = []; +} // eslint-disable-next-line @typescript-eslint/no-unused-vars -export function exit(program: NodePath) {} +export function exit(program: NodePath) { + writeExports(program); +} diff --git a/packages/translator/src/tag/native-tag/dom.ts b/packages/translator/src/tag/native-tag/dom.ts index c83f59c91..fb4782fbf 100644 --- a/packages/translator/src/tag/native-tag/dom.ts +++ b/packages/translator/src/tag/native-tag/dom.ts @@ -1,10 +1,79 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { types as t, NodePath } from "@marko/babel-types"; +import { getTagDef } from "@marko/babel-utils"; +import { + writeHydrate, + writeTemplate, + writeWalks, + Walks, + setOnlyChild, + clearOnlyChild +} from "../../util/dom-writer"; +import { callRuntime, getHTMLRuntime } from "../../util/runtime"; export function enter(tag: NodePath) { - // TODO + const attrs = tag.get("attributes"); + const tagDef = getTagDef(tag); + const hasSpread = attrs.some(attr => attr.isMarkoSpreadAttribute()); + let ofInterest = false; + if (tagDef) { + writeTemplate(tag, `<${tagDef.name}`); + for (const attr of attrs as NodePath[]) { + const name = attr.node.name; + + const value = attr.get("value"); + const { confident, value: computed } = value.evaluate(); + + // special handling of class/style?? + if (confident) { + writeTemplate(tag, getHTMLRuntime(tag).attr(name, computed)); + } else { + if (!ofInterest) { + ofInterest = true; + writeWalks(tag, Walks.GET); + writeHydrate(tag, t.expressionStatement(callRuntime(tag, "walk"))); + } + writeHydrate( + tag, + t.expressionStatement( + callRuntime(tag, "attr", t.stringLiteral(name), attr.node.value!) + ) + ); + } + } + + let emptyBody = false; + + if (tagDef && tagDef.parseOptions?.openTagOnly) { + switch (tagDef.htmlType) { + case "svg": + case "math": + writeTemplate(tag, `/>`); + break; + default: + writeTemplate(tag, `>`); + break; + } + emptyBody = true; + } else if (tag.node.body.body.length) { + writeTemplate(tag, `>`); + if (tag.node.body.body.length === 1) setOnlyChild(tag); + } else { + writeTemplate(tag, `>`); + emptyBody = true; + } + + if (emptyBody) { + writeWalks(tag, Walks.NEXT); + tag.remove(); + } else writeWalks(tag, Walks.ENTER); + } } export function exit(tag: NodePath) { - // TODO + const tagDef = getTagDef(tag); + if (tagDef && tagDef.name) writeTemplate(tag, ``); + writeWalks(tag, Walks.EXIT); + clearOnlyChild(tag); + tag.remove(); } diff --git a/packages/translator/src/text.ts b/packages/translator/src/text.ts index 3a4569ce1..c2d4b1408 100644 --- a/packages/translator/src/text.ts +++ b/packages/translator/src/text.ts @@ -1,12 +1,23 @@ import { types as t, NodePath } from "@marko/babel-types"; import { writeHTML } from "./util/html-write"; +import { + Walks, + writeTemplate, + writeWalks, + markTextSiblings, + checkLastStatic +} from "./util/dom-writer"; import { isOutputHTML } from "./util/marko-config"; export default function (text: NodePath) { if (isOutputHTML(text)) { writeHTML(text)`${text.node.value}`; } else { - // TODO + writeTemplate(text, text.node.value); + if (checkLastStatic(text)) { + writeWalks(text, Walks.NEXT); + } + markTextSiblings(text); } text.remove(); diff --git a/packages/translator/src/util/dom-export.ts b/packages/translator/src/util/dom-export.ts new file mode 100644 index 000000000..de12c3303 --- /dev/null +++ b/packages/translator/src/util/dom-export.ts @@ -0,0 +1,54 @@ +import { NodePath, Program, types as t } from "@marko/babel-types"; +import { callRuntime } from "./runtime"; +import { encodeWalks } from "./walks"; + +export function writeExports(path: NodePath) { + const template = t.identifier("template"); + const walks = t.identifier("walks"); + const hydrate = t.identifier("hydrate"); + // template + path.node.body.push( + t.exportNamedDeclaration( + t.variableDeclaration("const", [ + t.variableDeclarator( + template, + t.stringLiteral(path.state.template || "") + ) + ]) + ), + t.exportNamedDeclaration( + t.variableDeclaration("const", [ + t.variableDeclarator( + walks, + t.stringLiteral(encodeWalks(path.state.walks)) + ) + ]) + ), + t.exportNamedDeclaration( + t.variableDeclaration("const", [ + t.variableDeclarator( + hydrate, + callRuntime( + path, + "register", + t.stringLiteral(path.hub.file.metadata.marko.id), + t.arrowFunctionExpression( + [t.identifier("input")], + t.blockStatement(path.state.hydrate) + ) + ) + ) + ]) + ), + t.exportDefaultDeclaration( + callRuntime( + path, + "createRenderFn", + template, + walks, + t.arrayExpression(), + hydrate + ) + ) + ); +} diff --git a/packages/translator/src/util/dom-writer.ts b/packages/translator/src/util/dom-writer.ts new file mode 100644 index 000000000..2f7c3b718 --- /dev/null +++ b/packages/translator/src/util/dom-writer.ts @@ -0,0 +1,67 @@ +import { + NodePath, + Node, + MarkoTag, + MarkoText, + types as t, + MarkoPlaceholder +} from "@marko/babel-types"; + +import { Walks } from "./walks"; +export { Walks } from "./walks"; + +export function writeTemplate(path: NodePath, s: string) { + path.state.template += s; +} + +export function writeHydrate(path: NodePath, code: Node) { + path.state.hydrate.push(code); +} + +export function writeWalks(path: NodePath, code: Walks) { + path.state.walks.push(code); +} + +export function checkLastStatic(path: NodePath) { + let i = +path.key; + let temp: NodePath; + while ((temp = path.getSibling(++i)).node) { + if (t.isMarkoPlaceholder(temp)) return false; + } + return true; +} + +export function checkNextMarker(path: NodePath) { + let i = +path.key; + let temp: NodePath; + while ((temp = path.getSibling(++i)).node) { + if (!t.isMarkoPlaceholder(temp)) return true; + } + return false; +} + +export function markTextSiblings(path: NodePath) { + const sibling = path.getSibling(+path.key + 1); + if (sibling && t.isMarkoPlaceholder(sibling.node)) + path.state.precedingText = true; +} + +export function needsPlaceholderMarker(path: NodePath) { + if (!path.state.precedingText) return false; + const sibling = path.getSibling(+path.key + 1); + if (sibling && t.isMarkoText(sibling.node)) return true; + path.state.precedingText = false; + return false; +} + +export function setOnlyChild(path: NodePath) { + path.state.onlyChild = true; +} + +export function clearOnlyChild(path: NodePath) { + path.state.onlyChild = false; +} + +export function isOnlyChild(path: NodePath) { + return path.state.onlyChild; +} diff --git a/packages/translator/src/util/runtime.ts b/packages/translator/src/util/runtime.ts index 2da75d7c2..e7f74ca70 100644 --- a/packages/translator/src/util/runtime.ts +++ b/packages/translator/src/util/runtime.ts @@ -8,7 +8,8 @@ export function importRuntime( path: NodePath, name: string ) { - return importNamed(path.hub.file, getRuntimePath(path), name); + const { output } = getMarkoOpts(path); + return importNamed(path.hub.file, getRuntimePath(path, output), name); } export function callRuntime< @@ -25,19 +26,28 @@ export function callRuntime< } export function getHTMLRuntime(path: NodePath) { - return getRuntime(path) as typeof import("@marko/runtime-fluurt/src/html"); + return getRuntime( + path, + "html" + ) as typeof import("@marko/runtime-fluurt/src/html"); } export function getDOMRuntime(path: NodePath) { - return getRuntime(path) as typeof import("@marko/runtime-fluurt/src/dom"); + return getRuntime( + path, + "dom" + ) as typeof import("@marko/runtime-fluurt/src/dom"); } -function getRuntime(path: NodePath): unknown { - return require(getRuntimePath(path)); +function getRuntime( + path: NodePath, + output: string +): unknown { + return require(getRuntimePath(path, output)); } -function getRuntimePath(path: NodePath) { - const { output, optimize } = getMarkoOpts(path); +function getRuntimePath(path: NodePath, output: string) { + const { optimize } = getMarkoOpts(path); return `@marko/runtime-fluurt/${ USE_SOURCE_RUNTIME ? "src" : optimize ? "dist" : "debug" }/${output}`; diff --git a/packages/translator/src/util/walks.ts b/packages/translator/src/util/walks.ts new file mode 100644 index 000000000..1b1818a6e --- /dev/null +++ b/packages/translator/src/util/walks.ts @@ -0,0 +1,140 @@ +const enum WalkCodes { + Get = 33, // ! + Before = 35, // # + After = 36, // $ + Inside = 37, // % + Replace = 38, // & + Out = 39, + OutEnd = 49, + Over = 58, + OverEnd = 91, + Next = 93, + NextEnd = 126 +} + +const get = String.fromCharCode(WalkCodes.Get); +const before = String.fromCharCode(WalkCodes.Before); +const after = String.fromCharCode(WalkCodes.After); +const replace = String.fromCharCode(WalkCodes.Replace); +const inside = String.fromCharCode(WalkCodes.Inside); + +function next(number: number) { + return toCharString(number, WalkCodes.Next, WalkCodes.NextEnd); +} +function over(number: number) { + return toCharString(number, WalkCodes.Over, WalkCodes.OverEnd); +} +function out(number: number) { + return toCharString(number, WalkCodes.Out, WalkCodes.OutEnd); +} + +function toCharString( + number: number, + startCharCode: number, + endCharCode: number +) { + const total = endCharCode - startCharCode + 1; + let value = ""; + while (number > total) { + value += String.fromCharCode(endCharCode); + number -= total; + } + return value + String.fromCharCode(startCharCode + number - 1); +} + +export const enum Walks { + ENTER, + EXIT, + NEXT, + OVER, + GET, + BEFORE, + AFTER, + INSIDE, + REPLACE +} + +type WalkInfo = { + hasAction: boolean; + sequence: Walks[]; + earlyExits: number; +}; + +const lookup = { + [Walks.EXIT]: out, + [Walks.NEXT]: next, + [Walks.OVER]: over, + [Walks.GET]: get, + [Walks.BEFORE]: before, + [Walks.AFTER]: after, + [Walks.INSIDE]: inside, + [Walks.REPLACE]: replace +}; + +function resolveSequence(walks: Walks[]) { + let current: Walks; + let count = 0; + let results = ""; + for (let i = 0, len = walks.length; i < len; i++) { + const w = walks[i]; + if (w !== current!) { + if (current!) { + results += (lookup[current] as any)(count); + } + current = w; + count = 0; + } + count++; + } + if (current!) results += (lookup[current] as any)(count); + return results; +} + +export function encodeWalks(walks: Walks[]): string { + let results = ""; + let inner: WalkInfo; + const current: WalkInfo[] = [ + { + hasAction: false, + sequence: [], + earlyExits: 0 + } + ]; + + for (let i = 0, len = walks.length; i < len; i++) { + switch (walks[i]) { + case Walks.NEXT: + current[0].sequence.push(Walks.NEXT); + break; + case Walks.ENTER: + current.unshift({ + hasAction: false, + sequence: [...current[0].sequence, Walks.NEXT], + earlyExits: 0 + }); + break; + case Walks.EXIT: + inner = current.shift()!; + if (!inner.hasAction) { + current[0].sequence.push(Walks.OVER); + } else { + current[0].hasAction = true; + current[0].earlyExits = inner.earlyExits; + for (let j = 0, len = ++current[0].earlyExits; j < len; j++) + current[0].sequence.push(Walks.EXIT); + } + break; + default: + current[0].hasAction = true; + if (current[0].sequence.length) { + results += resolveSequence(current[0].sequence); + current[0].sequence = []; + current[0].earlyExits = 0; + } + results += lookup[walks[i]]; + } + } + results += resolveSequence(current[0].sequence); + console.log(results, next(1) + after + out(1)); + return results; +} diff --git a/packages/translator/test/fixtures/at-tag-inside-if-tag/config.ts b/packages/translator/test/fixtures/at-tag-inside-if-tag/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/at-tag-inside-if-tag/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/at-tags-dynamic-and-static/config.ts b/packages/translator/test/fixtures/at-tags-dynamic-and-static/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/at-tags-dynamic-and-static/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/at-tags-dynamic-tag-parent/config.ts b/packages/translator/test/fixtures/at-tags-dynamic-tag-parent/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/at-tags-dynamic-tag-parent/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/at-tags-dynamic-with-params/config.ts b/packages/translator/test/fixtures/at-tags-dynamic-with-params/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/at-tags-dynamic-with-params/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/at-tags-dynamic/config.ts b/packages/translator/test/fixtures/at-tags-dynamic/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/at-tags-dynamic/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/at-tags/config.ts b/packages/translator/test/fixtures/at-tags/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/at-tags/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/attr-boolean/config.ts b/packages/translator/test/fixtures/attr-boolean/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/attr-boolean/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/attr-class/config.ts b/packages/translator/test/fixtures/attr-class/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/attr-class/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/attr-escape/config.ts b/packages/translator/test/fixtures/attr-escape/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/attr-escape/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/attr-falsey/config.ts b/packages/translator/test/fixtures/attr-falsey/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/attr-falsey/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/attr-scoped/config.ts b/packages/translator/test/fixtures/attr-scoped/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/attr-scoped/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/attr-style/config.ts b/packages/translator/test/fixtures/attr-style/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/attr-style/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/attr-template-literal-escape/config.ts b/packages/translator/test/fixtures/attr-template-literal-escape/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/attr-template-literal-escape/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/cdata/config.ts b/packages/translator/test/fixtures/cdata/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/cdata/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/comments/config.ts b/packages/translator/test/fixtures/comments/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/comments/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/const-tag/config.ts b/packages/translator/test/fixtures/const-tag/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/const-tag/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/context-tag-from-relative-path/config.ts b/packages/translator/test/fixtures/context-tag-from-relative-path/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/context-tag-from-relative-path/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/context-tag-from-self/config.ts b/packages/translator/test/fixtures/context-tag-from-self/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/context-tag-from-self/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/context-tag-from-tag-name/config.ts b/packages/translator/test/fixtures/context-tag-from-tag-name/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/context-tag-from-tag-name/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/custom-tag-child-analyze/config.ts b/packages/translator/test/fixtures/custom-tag-child-analyze/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/custom-tag-child-analyze/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/custom-tag-parameters/config.ts b/packages/translator/test/fixtures/custom-tag-parameters/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/custom-tag-parameters/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/custom-tag-render-body/config.ts b/packages/translator/test/fixtures/custom-tag-render-body/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/custom-tag-render-body/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/custom-tag-separate-assets/config.ts b/packages/translator/test/fixtures/custom-tag-separate-assets/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/custom-tag-separate-assets/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/custom-tag-template/config.ts b/packages/translator/test/fixtures/custom-tag-template/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/custom-tag-template/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/custom-tag-var/config.ts b/packages/translator/test/fixtures/custom-tag-var/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/custom-tag-var/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/declaration/config.ts b/packages/translator/test/fixtures/declaration/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/declaration/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/doctype/config.ts b/packages/translator/test/fixtures/doctype/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/doctype/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/dynamic-tag-name/config.ts b/packages/translator/test/fixtures/dynamic-tag-name/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/dynamic-tag-name/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/dynamic-tag-name/snapshots/html-rendered-error-expected.txt b/packages/translator/test/fixtures/dynamic-tag-name/snapshots/html-rendered-error-expected.txt new file mode 100644 index 000000000..d80dbde4c --- /dev/null +++ b/packages/translator/test/fixtures/dynamic-tag-name/snapshots/html-rendered-error-expected.txt @@ -0,0 +1 @@ +(intermediate value)(intermediate value)(intermediate value) is not a function \ No newline at end of file diff --git a/packages/translator/test/fixtures/dynamic-tag-var/config.ts b/packages/translator/test/fixtures/dynamic-tag-var/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/dynamic-tag-var/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/dynamic-tag-var/snapshots/html-rendered-error-expected.txt b/packages/translator/test/fixtures/dynamic-tag-var/snapshots/html-rendered-error-expected.txt new file mode 100644 index 000000000..07aabc596 --- /dev/null +++ b/packages/translator/test/fixtures/dynamic-tag-var/snapshots/html-rendered-error-expected.txt @@ -0,0 +1 @@ +child is not a function \ No newline at end of file diff --git a/packages/translator/test/fixtures/entities/config.ts b/packages/translator/test/fixtures/entities/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/entities/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/event-handlers/config.ts b/packages/translator/test/fixtures/event-handlers/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/event-handlers/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/event-handlers/snapshots/html-rendered-error-expected.txt b/packages/translator/test/fixtures/event-handlers/snapshots/html-rendered-error-expected.txt new file mode 100644 index 000000000..27a6958bd --- /dev/null +++ b/packages/translator/test/fixtures/event-handlers/snapshots/html-rendered-error-expected.txt @@ -0,0 +1 @@ +_child is not a function \ No newline at end of file diff --git a/packages/translator/test/fixtures/for-tag/config.ts b/packages/translator/test/fixtures/for-tag/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/for-tag/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/hello-dynamic/config.ts b/packages/translator/test/fixtures/hello-dynamic/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/hello-dynamic/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/html-entity/config.ts b/packages/translator/test/fixtures/html-entity/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/html-entity/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/if-tag/config.ts b/packages/translator/test/fixtures/if-tag/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/if-tag/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/import-tag-conflict/config.ts b/packages/translator/test/fixtures/import-tag-conflict/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/import-tag-conflict/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/import-tag/config.ts b/packages/translator/test/fixtures/import-tag/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/import-tag/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/let-tag/config.ts b/packages/translator/test/fixtures/let-tag/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/let-tag/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/placeholders/config.ts b/packages/translator/test/fixtures/placeholders/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/placeholders/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/style-tag/config.ts b/packages/translator/test/fixtures/style-tag/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/style-tag/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/tag-tag/config.ts b/packages/translator/test/fixtures/tag-tag/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/tag-tag/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/fixtures/yield-tag/config.ts b/packages/translator/test/fixtures/yield-tag/config.ts new file mode 100644 index 000000000..1c5770891 --- /dev/null +++ b/packages/translator/test/fixtures/yield-tag/config.ts @@ -0,0 +1 @@ +export const skip = ["dom-compiled", "dom-rendered"]; diff --git a/packages/translator/test/main.test.ts b/packages/translator/test/main.test.ts index 4e1f7d113..94585b24b 100644 --- a/packages/translator/test/main.test.ts +++ b/packages/translator/test/main.test.ts @@ -6,6 +6,8 @@ import stripAnsi from "strip-ansi"; import { compileFile } from "@marko/compiler"; import { install } from "marko/node-require"; import * as translator from "../src"; +import snapshot from "./utils/snapshot"; +import renderAndTrackMutations from "./utils/render-and-track-mutations"; const baseConfig = { translator, @@ -29,10 +31,10 @@ install({ describe("translator", () => { autotest("fixtures", { - "html-compiled": runCompileTest({ output: "html" }), - "html-rendered": runHTMLRenderTest, - "dom-compiled": () => {}, - "dom-rendered": () => {} + "html-compiled": runTestWithConfig(runCompileTest({ output: "html" })), + "html-rendered": runTestWithConfig(runHTMLRenderTest), + "dom-compiled": runTestWithConfig(runCompileTest({ output: "dom" })), + "dom-rendered": runTestWithConfig(runDOMRenderTest) }); }); @@ -53,6 +55,7 @@ function runCompileTest(config: { output: string }) { try { output = (await compileFile(templateFile, compilerConfig)).code; + // .replace(/"\.\//g, '"../') } catch (compileSnapshotErr) { try { snapshot(stripCwd(stripAnsi(compileSnapshotErr.message)), { @@ -78,9 +81,9 @@ function runCompileTest(config: { output: string }) { }; } -function runHTMLRenderTest({ mode, test, resolve, snapshot }) { +function runHTMLRenderTest({ mode, test, resolve, snapshot }, { inputHTML }) { + // const templateFile = resolve("./snapshots/html-compiled-expected.js"); const templateFile = resolve("template.marko"); - const inputFile = resolve("input.ts"); const snapshotsDir = resolve("snapshots"); const name = `snapshots${path.sep + mode}`; @@ -88,18 +91,11 @@ function runHTMLRenderTest({ mode, test, resolve, snapshot }) { await ensureDir(snapshotsDir); const { render } = await import(templateFile); - let input: Record; let html = ""; - try { - input = await import(inputFile); - } catch { - input = {}; - } - try { await render( - input, + inputHTML || {}, new Writable({ write(chunk: string) { html += chunk; @@ -121,6 +117,39 @@ function runHTMLRenderTest({ mode, test, resolve, snapshot }) { }); } +function runDOMRenderTest({ mode, test, resolve }, { inputDOM }) { + const templateFile = resolve("snapshots/dom-compiled-expected.js"); + const snapshotsDir = resolve("snapshots"); + + test(async () => { + await ensureDir(snapshotsDir); + + snapshot( + snapshotsDir, + `${mode}.md`, + await renderAndTrackMutations(templateFile, inputDOM) + ); + }); +} + +function runTestWithConfig(fn) { + return opts => { + let config; + try { + config = require(opts.resolve("config.ts")); + } catch { + config = {}; + } + + if (config.skip && config.skip.includes(opts.mode)) { + opts.skip("Not Implemented"); + return; + } + + return fn(opts, config); + }; +} + async function ensureDir(dir: string) { try { await fs.promises.access(dir); diff --git a/packages/translator/test/utils/create-browser.ts b/packages/translator/test/utils/create-browser.ts new file mode 100644 index 000000000..8657a92e8 --- /dev/null +++ b/packages/translator/test/utils/create-browser.ts @@ -0,0 +1,24 @@ +import { DOMWindow } from "jsdom"; +import createBrowser from "jsdom-context-require"; + +export default function (options: Parameters[0]) { + // something up with extensions + const browser = createBrowser({ ...options, extensions: require.extensions }); + const window = browser.window as DOMWindow & { MessageChannel: any }; + window.queueMicrotask = queueMicrotask; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + window.MessageChannel = (window as any).MessageChannel = class MessageChannel { + port1: any; + port2: any; + constructor() { + this.port1 = { onmessage() {} }; + this.port2 = { + postMessage: () => { + setImmediate(this.port1.onmessage); + } + }; + } + }; + window.requestAnimationFrame = fn => setTimeout(fn); + return browser; +} diff --git a/packages/translator/test/utils/get-node-info.ts b/packages/translator/test/utils/get-node-info.ts new file mode 100644 index 000000000..ad26ef09c --- /dev/null +++ b/packages/translator/test/utils/get-node-info.ts @@ -0,0 +1,29 @@ +export function getNodePath(node: Node) { + const parts: string[] = []; + let cur: Node | null = node; + while (cur) { + const { parentNode } = cur; + + if (!parentNode || (cur as any).TEST_ROOT) { + break; + } + + let name = getTypeName(cur); + const index = parentNode + ? (Array.from(parentNode.childNodes) as Node[]).indexOf(cur) + : -1; + + if (index !== -1) { + name += `${index}`; + } + + parts.unshift(name); + cur = parentNode; + } + + return parts.join("/"); +} + +export function getTypeName(node: Node) { + return node.nodeName.toLowerCase(); +} diff --git a/packages/translator/test/utils/render-and-track-mutations.ts b/packages/translator/test/utils/render-and-track-mutations.ts new file mode 100644 index 000000000..3c2df25f6 --- /dev/null +++ b/packages/translator/test/utils/render-and-track-mutations.ts @@ -0,0 +1,114 @@ +import createBrowser from "./create-browser"; +import createMutationTracker from "./track-mutations"; +import { wait, isWait } from "./resolve"; + +const browser = createBrowser({ + dir: __dirname, + html: "" +}); + +const window = browser.window; +const document = window.document; + +const { createRenderFn, runInBatch } = browser.require( + "@marko/runtime-fluurt/src/dom/index" +) as typeof import("@marko/runtime-fluurt/src/dom/index"); + +interface Test { + wait?: number; + inputs: [ + Record, + ...Array< + | Record + | ((container: Element) => void) + | ReturnType + > + ]; + default: ReturnType; + html: string; + FAILS_HYDRATE?: boolean; +} + +export default async function renderAndGetMutations( + test: string, + inputs = [] +): Promise { + if (!Array.isArray(inputs)) inputs = [inputs]; + const { default: render } = browser.require(test) as Test; + const [firstInput] = inputs; + const container = Object.assign(document.createElement("div"), { + TEST_ROOT: true + }); + const tracker = createMutationTracker(window, container); + + document.body.appendChild(container); + + try { + tracker.beginUpdate(); + + const instance = render(firstInput); + container.appendChild(instance); + + // const initialHTML = container.innerHTML; + tracker.logUpdate(firstInput); + + for (const update of inputs.slice(1)) { + if (isWait(update)) { + await update(); + } else { + tracker.beginUpdate(); + if (typeof update === "function") { + runInBatch(() => update(container)); + } else { + instance.rerender(update); + } + tracker.logUpdate(update); + } + } + + // if (!FAILS_HYDRATE) { + // const inputSignal = source(firstInput); + // (window as any).M$c = [[0, id, dynamicKeys(inputSignal, renderer.input)]]; + // container.innerHTML = `${initialHTML}`; + // container.insertBefore(document.createTextNode(""), container.firstChild); + // tracker.dropUpdate(); + // init(); + + // tracker.logUpdate(firstInput); + // const logs = tracker.getRawLogs(); + // logs[logs.length - 1] = "--- Hydrate ---\n" + logs[logs.length - 1]; + + // // Hydrate should end up with the same html as client side render. + // assert.equal(container.innerHTML, initialHTML); + + // // Run the same updates after hydrate and ensure the same mutations. + // let resultIndex = 0; + // for (const update of inputs.slice(1)) { + // if (wait) { + // await resolveAfter(null, wait); + // } + // if (typeof update === "function") { + // update(container); + // } else { + // const batch = beginBatch(); + // set(inputSignal, update); + // endBatch(batch); + // } + + // assert.equal( + // tracker.getUpdate(update), + // tracker.getRawLogs()[++resultIndex] + // ); + // } + + // if (wait) { + // await resolveAfter(null, wait); + // } + // } + + return tracker.getLogs(); + } finally { + tracker.cleanup(); + document.body.removeChild(container); + } +} diff --git a/packages/translator/test/utils/resolve.ts b/packages/translator/test/utils/resolve.ts new file mode 100644 index 000000000..c4cfca0e5 --- /dev/null +++ b/packages/translator/test/utils/resolve.ts @@ -0,0 +1,27 @@ +const TIMEOUT_MULTIPLIER = 16; + +export function wait(timeout: number) { + return Object.assign(() => resolveAfter(`wait:${timeout}`, timeout), { + wait: true + }); +} + +export function isWait(value: any): value is ReturnType { + return value.wait; +} + +export function resolveAfter(value: T, timeout: number) { + const p = new Promise(resolve => + setTimeout(() => resolve(value), timeout * TIMEOUT_MULTIPLIER) + ) as Promise; + + return Object.assign(p, { value }); +} + +export function rejectAfter(value: T, timeout: number) { + const p = new Promise((_, reject) => + setTimeout(() => reject(value), timeout * TIMEOUT_MULTIPLIER) + ) as Promise; + + return Object.assign(p, { value }); +} diff --git a/packages/translator/test/utils/snapshot.ts b/packages/translator/test/utils/snapshot.ts new file mode 100644 index 000000000..52049c7aa --- /dev/null +++ b/packages/translator/test/utils/snapshot.ts @@ -0,0 +1,48 @@ +import fs from "fs"; +import path from "path"; +import assert from "assert"; + +export default function snapshot( + dir: string, + file: string, + data: string, + originalError?: Error +) { + const parsed = path.parse(file); + const ext = parsed.ext; + let name = parsed.name; + + if (name) { + name += "."; + } + + try { + fs.accessSync(dir); + } catch { + fs.mkdirSync(dir); + } + + const expectedFile = path.join(dir, `${name}expected${ext}`); + const actualFile = path.join(dir, `${name}actual${ext}`); + + fs.writeFileSync(actualFile, data, "utf-8"); + + if (process.env.UPDATE_EXPECTATIONS) { + fs.writeFileSync(expectedFile, data, "utf-8"); + } else { + const expected = fs.existsSync(expectedFile) + ? fs.readFileSync(expectedFile, "utf-8") + : ""; + + try { + assert.equal(data, expected); + } catch (err) { + err.snapshot = true; + err.name = err.name.replace(" [ERR_ASSERTION]", ""); + err.stack = ""; + err.message = path.relative(process.cwd(), actualFile); + + throw originalError || err; + } + } +} diff --git a/packages/translator/test/utils/track-mutations.ts b/packages/translator/test/utils/track-mutations.ts new file mode 100644 index 000000000..01dbc2cd4 --- /dev/null +++ b/packages/translator/test/utils/track-mutations.ts @@ -0,0 +1,131 @@ +import format from "pretty-format"; +import { getNodePath, getTypeName } from "./get-node-info"; + +const { DOMElement, DOMCollection } = format.plugins; + +export default function createMutationTracker(window, container) { + const result: string[] = []; + let currentRecords: unknown[] | null = null; + const observer = new window.MutationObserver(records => { + if (currentRecords) { + currentRecords = currentRecords.concat(records); + } else { + result.push(getStatusString(container, records, "ASYNC")); + } + }); + observer.observe(container, { + attributes: true, + attributeOldValue: true, + characterData: true, + characterDataOldValue: true, + childList: true, + subtree: true + }); + return { + beginUpdate() { + currentRecords = []; + }, + dropUpdate() { + observer.takeRecords(); + currentRecords = null; + }, + getUpdate(update) { + if (currentRecords) { + currentRecords = currentRecords.concat(observer.takeRecords()); + } else { + currentRecords = observer.takeRecords(); + } + const updateString = getStatusString(container, currentRecords, update); + currentRecords = null; + return updateString; + }, + log(message) { + result.push(message); + }, + logUpdate(update) { + result.push(this.getUpdate(update)); + }, + getRawLogs() { + return result; + }, + getLogs() { + return result.join("\n\n\n"); + }, + cleanup() { + observer.disconnect(); + } + }; +} + +function getStatusString(container: HTMLDivElement, changes, update) { + const clone = container.cloneNode(true); + clone.normalize(); + + return `# Render ${ + typeof update === "function" + ? `\n${update + .toString() + .replace(/^.*?{\s*([\s\S]*?)\s*}.*?$/, "$1") + .replace(/^ {4}/gm, "")}\n` + : JSON.stringify(update) + }\n\`\`\`html\n${Array.from(clone.childNodes) + .map(child => + format(child, { + plugins: [DOMElement, DOMCollection] + }).trim() + ) + .filter(Boolean) + .join("\n") + .trim()}\n\`\`\`\n\n# Mutations\n\`\`\`\n${changes + .map(formatMutationRecord) + .join("\n")}\n\`\`\``; +} + +function formatMutationRecord(record: MutationRecord) { + const { target, oldValue } = record; + + switch (record.type) { + case "attributes": { + const { attributeName } = record; + const newValue = (target as HTMLElement).getAttribute( + attributeName as string + ); + return `${getNodePath(target)}: attr(${attributeName}) ${JSON.stringify( + oldValue + )} => ${JSON.stringify(newValue)}`; + } + + case "characterData": { + return `${getNodePath(target)}: ${JSON.stringify( + oldValue + )} => ${JSON.stringify(target.nodeValue)}`; + } + + case "childList": { + const { removedNodes, addedNodes, previousSibling, nextSibling } = record; + const details: string[] = []; + if (removedNodes.length) { + const relativeNode = previousSibling || nextSibling || target; + const position = + relativeNode === previousSibling + ? "after" + : relativeNode === nextSibling + ? "before" + : "in"; + details.push( + `removed ${Array.from(removedNodes) + .map(getTypeName) + .join(", ")} ${position} ${getNodePath(relativeNode)}` + ); + } + + if (addedNodes.length) { + details.push( + `inserted ${Array.from(addedNodes).map(getNodePath).join(", ")}` + ); + } + + return details.join("\n"); + } + } +}