From 44f4a080014c376e589449f36d854e13fa27358c Mon Sep 17 00:00:00 2001 From: KimlikDAO-bot Date: Sat, 8 Feb 2025 14:30:31 -0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=B1=20Simplify=20kastro=20component=20?= =?UTF-8?q?model:=20stateless/statefull?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kastro/README.md | 98 +++++------ kastro/compiler/page.js | 4 +- kastro/kastro.js | 2 +- kastro/script.js | 2 +- kastro/transpiler/componentProps.js | 20 --- kastro/transpiler/jsx-runtime.js | 15 +- kastro/transpiler/jsx.js | 161 ++++++++++-------- .../{compiler => transpiler}/pageGlobals.js | 0 kastro/transpiler/transpiler.js | 4 +- 9 files changed, 144 insertions(+), 162 deletions(-) delete mode 100644 kastro/transpiler/componentProps.js rename kastro/{compiler => transpiler}/pageGlobals.js (100%) diff --git a/kastro/README.md b/kastro/README.md index a5a71a6..4085540 100644 --- a/kastro/README.md +++ b/kastro/README.md @@ -27,27 +27,29 @@ import { LangCode } from "@kimlikdao/util/i18n"; import ArrowSvg from "./arrow.svg"; import Css from "./LandingPage.css"; -/** @const {!HTMLButtonElement} */ -const Button = dom.button(Css.ButtonId); -/** @const {!HTMLSpanElement} */ -const Text = dom.span(Css.TextId); - /** * @param {{ Lang: LangCode }} Lang */ -const LandingPage = ({ Lang }) => ( - - - - Hello World! - -); +const LandingPage = ({ Lang }) => { + /** @const {!HTMLButtonElement} */ + const Button = dom.button(Css.ButtonId); + /** @const {!HTMLSpanElement} */ + const Text = dom.span(Css.TextId); + + return ( + + + + Hello World! + + ); +}; export default LandingPage; ``` -When you import a `.css` file, you get a StyleSheet component like the `Css` +When you import a .css file, you get a StyleSheet component like the `Css` component in the example above. Each selector in the css file becomes available as a property on this component, with the selector name converted to PascalCase. For example, `.blue-button` becomes `Css.BlueButton`. @@ -77,12 +79,11 @@ and the following html will be generated (after de-minification): - + Hello World! ``` -In particular, there is no runtime, no boilerplate or any other code that -would better be handled at compile time. +In particular, there is no runtime, no framework setup code or boilerplate. ## Not reactive As you may have noticed in the example above, kastro is not a reactive @@ -106,41 +107,29 @@ the component html and the object part is used to manage the DOM interactions. When building for the client, the function part is stripped to essentially a no-op and in particular, the entire jsx expression is removed. -There are 3 types of components: +There are 2 types of components, stateless and stateful. In stateless components +the function part binds the component to the dom; in stateful components, the +function part is used as a constructor of the component's instance. -1. **Singleton**: Only one instance can be present in the page. These bind to -the DOM when the containing module is imported and can keep an arbitrary -internal state (every variable you define in the module is available as a -property of the component object). +1. **Stateless**: A component that does not take an `instance` property is + deemed stateless. Their dom id is fixed at compile time either by an `id` + property passed by their parent component, or by hardcoding it if the + component appears at most once in a page (thus assigning a unique id by the + parent is unnecessary). -If a component does not take an `id` or `instance` property, then it is -determined as a singleton. + These components can keep an internal state, however if there are multiple + copies of the component in a page, they will share this state. This means + that for singleton components, we can freely keep internal state, however + for reusable components, either the entire state must be kept in the DOM + or passed into the methods of the component by the caller. - ```jsx - const State = [1, 2, 3]; - const SingletonComp = () =>
Singleton
; - export default SingletonComp; - ``` - If your component is exposing additional methods, you can add them like so: - ```jsx - const State = [1, 2, 3]; - const SingletonComp = () =>
Singleton
; - SingletonComp.push = (x) => State.push(x); - SingletonComp.pop = () => State.pop(); - - export default SingletonComp; - ``` - -2. **Stateless**: A component that takes an `id` property (but no `instance` - property) is deemed stateless. Their dom `id` is fixed at compile time by - their parents, and in particular there can be any number of instances of - stateless components with unique ids assigned by their parents. - - Kastro compiler will generate the `Component({ id: "idAssignedByparent" })` + Kastro compiler will generate the `Component({ id: "idAssignedByParent" })` invocations from the initialization code of the parent component. ```jsx + /** @param {{ id: string }} props */ const StatelessComp = ({ id }) => { + /** @type {!HTMLDivElement} */ const Root = dom.div(id); return ( { + /** @type {!HTMLDivElement} */ const Root = dom.div(id); Root.onclick = () => Root.innerText = Root.innerText == "On" ? "Off" : "On"; return null; @@ -169,13 +160,19 @@ determined as a singleton. Page(); ``` -3. **Stateful**: A component which takes an `instance` property is deemed a - stateful component. These components have internal state and for each copy - of the component, a class instance is created. +2. **Stateful**: A component which takes an `instance` property is deemed a + stateful component. These components can keep an internal state and for each + copy of the component, a class instance is created. + + Note the `instance` property is used by the client jsx transpiler and never + passed to the component itself. ```jsx + /** @param {{ id: string }} props */ const CheckBox = ({ id }) => { + /** @type {!HTMLDivElement} */ this.root = dom.div(id); + /** @type {boolean} */ this.on = true; return
on
; } @@ -195,8 +192,11 @@ determined as a singleton. When the above jsx file is transpiled for the client by kastro (but before compilation by kdjs), it will become ```javascript + /** @param {{ id: string }} props */ const CheckBox = ({ id }) => { + /** @type {!HTMLDivElement} */ this.root = dom.div(id); + /** @type {boolean} */ this.on = true; return null; } diff --git a/kastro/compiler/page.js b/kastro/compiler/page.js index a22a2d7..634c398 100644 --- a/kastro/compiler/page.js +++ b/kastro/compiler/page.js @@ -3,9 +3,8 @@ import { capitalize, getDir } from "../../util/paths"; import { filterGlobalProps } from "../props"; import { Script } from "../script"; import { makeStyleSheets } from "../stylesheet"; -import { initComponentProps } from "../transpiler/componentProps"; +import { initGlobals } from "../transpiler/pageGlobals"; import HtmlMinifierConfig from "./config/htmlMinifierConfig"; -import { initGlobals } from "./pageGlobals"; /** * @param {string} targetName @@ -22,7 +21,6 @@ const pageTarget = (targetName, props) => { const { BuildMode, Lang } = props; initGlobals(props); - initComponentProps(targetModuleName, { BuildMode, Lang }); const StyleSheets = makeStyleSheets(); return import(targetModulePath) .then((jsx) => jsx.default({ BuildMode, Lang }).render()) diff --git a/kastro/kastro.js b/kastro/kastro.js index 72fb068..851b8ec 100644 --- a/kastro/kastro.js +++ b/kastro/kastro.js @@ -15,10 +15,10 @@ import { webpTarget } from "./compiler/image"; import { pageTarget } from "./compiler/page"; -import { getGlobals } from "./compiler/pageGlobals"; import { scriptTarget } from "./compiler/script"; import { styleSheetTarget } from "./compiler/styleSheet"; import { registerTargetFunction } from "./compiler/targetRegistry"; +import { getGlobals } from "./transpiler/pageGlobals"; import { transpileCss, transpileJsx } from "./transpiler/transpiler"; import { CompressedMimes } from "./workers/mimes"; diff --git a/kastro/script.js b/kastro/script.js index 7bfd260..8b3f83d 100644 --- a/kastro/script.js +++ b/kastro/script.js @@ -1,8 +1,8 @@ import { tagYaz } from "../util/html"; import { splitFullExt } from "../util/paths"; import compiler from "./compiler/compiler"; -import { getGlobals } from "./compiler/pageGlobals"; import { Props } from "./props"; +import { getGlobals } from "./transpiler/pageGlobals"; /** * @param {Props} props diff --git a/kastro/transpiler/componentProps.js b/kastro/transpiler/componentProps.js deleted file mode 100644 index 97b7c41..0000000 --- a/kastro/transpiler/componentProps.js +++ /dev/null @@ -1,20 +0,0 @@ -/** @type {!Object} */ -let ComponentProps = {}; - -const storeComponentProps = (name, { id, instance, children, render, ...props }) => { - // For now we only store props for singleton components. - if (id || instance) return; - ComponentProps[name] = props; -} - -const getComponentProps = () => ComponentProps; - -const initComponentProps = (name, props) => - ComponentProps = { [name]: props }; - - -export { - getComponentProps, - initComponentProps, - storeComponentProps -}; diff --git a/kastro/transpiler/jsx-runtime.js b/kastro/transpiler/jsx-runtime.js index c4cd262..e23fb2a 100644 --- a/kastro/transpiler/jsx-runtime.js +++ b/kastro/transpiler/jsx-runtime.js @@ -1,7 +1,6 @@ import { KapalıTag, tagYaz } from "../../util/html"; import { LangCode } from "../../util/i18n"; -import { getGlobals } from "../compiler/pageGlobals"; -import { storeComponentProps } from "./componentProps"; +import { getGlobals } from "./pageGlobals"; /** @const {string} */ const Fragment = ""; @@ -76,16 +75,8 @@ const jsx = (name, props = {}) => { resolveComponentProps(props, globals.Lang); const nameType = typeof name; - if (nameType == "function") { - const componentProps = name({ ...props, ...globals }); - if (!componentProps) return; - const render = componentProps.render; - componentProps.render = () => { - storeComponentProps(name.name, props); - return render(); - } - return componentProps; - } + if (nameType == "function") + return name({ ...props, ...globals }); let { modifiesChildren, ...prop } = props; resolveElementProps(prop); diff --git a/kastro/transpiler/jsx.js b/kastro/transpiler/jsx.js index df126dc..8ee5ea3 100644 --- a/kastro/transpiler/jsx.js +++ b/kastro/transpiler/jsx.js @@ -38,7 +38,7 @@ const SpecifierState = { * @param {DomIdMapper} domIdMapper * @return {string} The transpiled js file */ -const transpile = (isEntry, file, content, domIdMapper, componentProps) => { +const transpile = (isEntry, file, content, domIdMapper, globals) => { /** @const {!Array} */ const comments = []; /** @const {!Array} */ @@ -53,6 +53,8 @@ const transpile = (isEntry, file, content, domIdMapper, componentProps) => { const assetComponents = new Set(); /** @const {!Set} */ const localComponents = new Set(); + /** @const {!Set} */ + const styleSheetComponents = new Set(); /** * @param {!acorn.ImportDeclaration} node @@ -98,7 +100,7 @@ const transpile = (isEntry, file, content, domIdMapper, componentProps) => { } else props[name] = attr.value; } - if (instance || props.id || localComponents.has(tagName)) { + if ((tagName in specifierInfo || localComponents.has(tagName)) && !styleSheetComponents.has(tagName)) { keepImport = true; const serialize = (v) => { if (!v) return "true"; @@ -192,8 +194,6 @@ const transpile = (isEntry, file, content, domIdMapper, componentProps) => { } } - let defaultComponent = null; - /** * @param {!acorn.Program} ast */ @@ -216,16 +216,9 @@ const transpile = (isEntry, file, content, domIdMapper, componentProps) => { updates.push({ beg: node.start, end: node.end, put: "; // Asset component" }); } else addImport(node); - } else if (node.type == "ExportDefaultDeclaration") { - if (node.declaration.type == "Identifier") - defaultComponent = node.declaration.name; - - if (isEntry) { - updates.push({ - beg: node.start, - end: node.end, - put: "; // Entry component, remove the default export\n" - }); + if (ext == "css") { + for (const specifier of node.specifiers) + styleSheetComponents.add(specifier.local.name); } } else if (node.type == "VariableDeclaration") { for (const decl of node.declarations) { @@ -240,65 +233,6 @@ const transpile = (isEntry, file, content, domIdMapper, componentProps) => { } } - const autoInitializeDefaultSingleton = (ast) => { - // Until our type parser is ready, we have a hardcoded name to type map here - const nameToType = { - "Lang": "LangCode", - "defaultChain": "ChainId", - "chains": "!Array", - "chainNotes": "!Object", - } - if (!defaultComponent) return; - for (const node of ast.body) { - if (node.type == "VariableDeclaration") { - for (const decl of node.declarations) { - const name = decl.id.name; - if (decl.id.type === "Identifier" && name == defaultComponent && - decl.init && - (decl.init.type === "ArrowFunctionExpression" || - decl.init.type === "FunctionExpression")) { - - const propStrings = []; - if (decl.init.params.length) { - if (decl.init.params.length > 1) - throw new Error("Singleton components cannot have more than one parameter"); - - const params = decl.init.params[0]; - if (params.type !== "ObjectPattern") - throw new Error("Singleton components must have an object parameter"); - - for (const prop of params.properties) - if (prop.key.name === "id" || prop.key.name === "instance") - return; // Not a singleton - - const storedProps = componentProps[name] || {}; - const serializeValueOf = (key) => { - const serialized = JSON.stringify(storedProps[key]); - const type = nameToType[key]; - return type ? `/** @type {${type}} */(${serialized})` : serialized; - } - for (const prop of params.properties) { - const propName = prop.key.name; - if (propName in storedProps) - propStrings.push( - `${propName}: ${serializeValueOf(propName)}` - ); - } - } - const initStatement = propStrings.length - ? `\n${name}({\n ${propStrings.join(",\n ")}\n});` - : `\n${name}();`; - updates.push({ - beg: node.end, - end: node.end, - put: initStatement - }); - } - } - } - } - } - const processComments = (comments) => { /** @const {RegExp} */ const TypePattern = /{[^}]+}/g; @@ -345,6 +279,83 @@ const transpile = (isEntry, file, content, domIdMapper, componentProps) => { } } + const initializeEntry = () => { + let rootComponentName = file.slice(file.lastIndexOf("/") + 1).replace(".jsx", ""); + /** @type {acorn.Node} */ + let defaultExport = null; + let exportNode = null; + + for (const node of ast.body) { + if (node.type === "ExportDefaultDeclaration") { + exportNode = node; + defaultExport = node.declaration; // Either Identifier or Function + break; + } + } + + if (!defaultExport) throw new Error("Root component must be exported as default"); + + if (defaultExport.type === "Identifier") { + rootComponentName = defaultExport.name; + updates.push({ + beg: exportNode.start, + end: exportNode.end, + put: "" + }); + } else { + updates.push({ + beg: exportNode.start, + end: exportNode.declaration.start, + put: `const ${rootComponentName} = ` + }); + } + + if (defaultExport.type === "Identifier") { + const name = defaultExport.name; + for (const node of ast.body) { + if (node.type === "VariableDeclaration") { + for (const decl of node.declarations) { + if (decl.id.name === name) { + defaultExport = decl.init; + break; + } + } + } + } + } + const nameToType = { + "Lang": "LangCode", + }; + const serializeValueOf = (key) => { + const serialized = JSON.stringify(globals[key]); + const type = nameToType[key]; + return type ? `/** @type {${type}} */(${serialized})` : serialized; + } + + const propStrings = []; + if (defaultExport.params.length) { + if (defaultExport.params.length > 1) + throw new Error("Root component cannot have more than one parameter"); + + const params = defaultExport.params[0]; + if (params.type !== "ObjectPattern") + throw new Error("Root component must have an object parameter"); + + for (const prop of params.properties) { + const propName = prop.key.name; + if (propName in globals) + propStrings.push( + `${propName}: ${serializeValueOf(propName)}` + ); + } + } + updates.push({ + beg: ast.end, + end: ast.end, + put: `${rootComponentName}(${propStrings.length ? `{\n ${propStrings.join(",\n ")}\n}` : ""});` + }); + }; + /** @const {!acorn.Program} */ const ast = JsxParser.parse(content, { sourceType: "module", @@ -354,9 +365,11 @@ const transpile = (isEntry, file, content, domIdMapper, componentProps) => { collectComponents(ast); processComponents(ast); - autoInitializeDefaultSingleton(ast); processComments(comments) pruneImports(); + if (isEntry) + initializeEntry(); + return update(content, updates); }; diff --git a/kastro/compiler/pageGlobals.js b/kastro/transpiler/pageGlobals.js similarity index 100% rename from kastro/compiler/pageGlobals.js rename to kastro/transpiler/pageGlobals.js diff --git a/kastro/transpiler/transpiler.js b/kastro/transpiler/transpiler.js index 50ba14b..de9f199 100644 --- a/kastro/transpiler/transpiler.js +++ b/kastro/transpiler/transpiler.js @@ -1,7 +1,7 @@ -import { getComponentProps } from "./componentProps"; import css from "./css"; import { DomIdMapper, GlobalMapper } from "./domIdMapper"; import jsx from "./jsx"; +import { getGlobals } from "./pageGlobals"; /** @type {!DomIdMapper} */ const IdMapper = new GlobalMapper(); @@ -16,7 +16,7 @@ const transpileJsx = (content, file, isEntry) => jsx.transpile(isEntry, file, content, IdMapper); const transpile = (content, file, isEntry) => file.endsWith(".jsx") - ? jsx.transpile(isEntry, file, content, IdMapper, getComponentProps()) + ? jsx.transpile(isEntry, file, content, IdMapper, getGlobals()) : css.transpile(file, content, IdMapper) export {