├── .gitignore ├── LICENSE ├── README.md ├── audit.sh ├── build-system ├── babel │ ├── plugin-debug-workaround.mjs │ ├── plugin-iife.mjs │ ├── plugin-magical-page-api.mjs │ ├── plugin-scoped-css.mjs │ └── plugin-template-engine-renderer.mjs ├── cache.mjs ├── cli-bin.mjs ├── cli.mjs ├── css.mjs ├── dev-client-injection.js ├── dev-server.mjs ├── nakedjsx.mjs ├── rollup │ └── plugin-map-cache.mjs └── util.mjs ├── dev.sh ├── package.json └── runtime ├── client └── jsx.mjs └── page ├── document.mjs └── page.mjs /.gitignore: -------------------------------------------------------------------------------- 1 | .pnp.cjs 2 | .pnp.loader.mjs 3 | .yarn/ 4 | .yarnrc.yml 5 | yarn.lock -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022-2023, David Hogan 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @nakedjsx/core 2 | The NakedJSX development server and build tool. 3 | 4 | Many users will not use this package directly, and will use `npx nakedjsx` instead. 5 | 6 | Please see the [documentation](https://nakedjsx.org/documentation/) for more information. 7 | -------------------------------------------------------------------------------- /audit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | rm -rf node_modules .yarn* .pnp.* yarn.lock 3 | npm install --package-lock-only 4 | npm audit 5 | -------------------------------------------------------------------------------- /build-system/babel/plugin-debug-workaround.mjs: -------------------------------------------------------------------------------- 1 | // 2 | // NakedJSX pages are rendered via a dynamic import(). 3 | // 4 | // Unfortunately, when debugging under vscode (and likely other debuggers) 5 | // execution of the import()ed script does not block until sourcemaps 6 | // have been processed by vscode, which results in early breakpoints 7 | // not functioning. 8 | // 9 | // Until this problem is solved generically, attempt to work around 10 | // by introducing a small delay to the beginning of every generated 11 | // script. 12 | // 13 | // This doesn't fix the race condition, just shifts the race. 14 | // On an otherwise idle M1 CPU a setTimeout of 2ms appears to work. 15 | // A much larger value is chosen to try and make this work no matter 16 | // the CPU (or load). Ultimately this sucks but is better than nothing. 17 | // 18 | // https://github.com/microsoft/vscode-js-debug/issues/1510 19 | // 20 | 21 | export default function(babel) 22 | { 23 | const t = babel.types; 24 | 25 | return { 26 | visitor: 27 | { 28 | Program(nodePath) 29 | { 30 | // 31 | // Place a delay at the start of the program (should only be used if debugger attached). 32 | // This is to work around issues with breakpoints in dynamically import()ed files. 33 | // 34 | // See: https://github.com/microsoft/vscode-js-debug/issues/1510#issuecomment-1560510140 35 | // 36 | 37 | const delayCode = 38 | t.arrowFunctionExpression( 39 | [t.identifier('resolve')], 40 | t.callExpression( 41 | t.identifier('setTimeout'), 42 | [t.identifier('resolve'), t.numericLiteral(25)] 43 | ) 44 | ); 45 | 46 | const awaitExpression = 47 | t.expressionStatement( 48 | t.awaitExpression( 49 | t.newExpression( 50 | t.identifier('Promise'), 51 | [delayCode] 52 | ) 53 | ) 54 | ); 55 | 56 | t.addComments( 57 | awaitExpression, 58 | 'leading', 59 | [ 60 | { type: "CommentLine", value: ' HACK: Attached debugger detected at build time, give vscode time to connect breakpoints for dynamic import ...' }, 61 | { type: "CommentLine", value: ' see: https://github.com/microsoft/vscode-js-debug/issues/1510#issuecomment-1560510140' } 62 | ]); 63 | 64 | nodePath.unshiftContainer('body', awaitExpression); 65 | }, 66 | } 67 | }; 68 | }; 69 | -------------------------------------------------------------------------------- /build-system/babel/plugin-iife.mjs: -------------------------------------------------------------------------------- 1 | // 2 | // Wraps an entire program in an Immediately Invoked Function Expression (IIFE) 3 | // 4 | 5 | export default function(babel) 6 | { 7 | const t = babel.types; 8 | 9 | return { 10 | visitor: 11 | { 12 | Program: 13 | { 14 | exit(nodePath) 15 | { 16 | // Backup the existing statements 17 | const statements = [...nodePath.node.body]; 18 | 19 | // Then remove them from the lop level scope 20 | for (let statementPath of nodePath.get('body')) 21 | statementPath.remove(); 22 | 23 | // Now create an iife, add the statements to it, then add the iife to the program body 24 | nodePath.pushContainer( 25 | 'body', 26 | t.expressionStatement( 27 | t.callExpression( 28 | t.functionExpression( 29 | null, 30 | [], 31 | t.blockStatement(statements)), 32 | []) 33 | ) 34 | ); 35 | } 36 | } 37 | } 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /build-system/babel/plugin-magical-page-api.mjs: -------------------------------------------------------------------------------- 1 | import { default as generator } from '@babel/generator' 2 | const generate = generator.default; 3 | 4 | import { uniqueGenerator } from '../util.mjs'; 5 | 6 | // 7 | // 1. Ensure calls to async Page functions are awaited. 8 | // 9 | // Without this, it's too easy for users to forget to await Page.Render(). 10 | // This creates instability which would be blamed on NakedJSX. 11 | // 12 | // It also keeps the API nice and clean, and the approach allows us to make 13 | // other calls transparently async later without breaking existing projects. 14 | // 15 | // 2. Convert JavaScript code passed directly to Page.AppendJs() to a string. 16 | // 17 | // Get syntax highlighting for snippets of page JavaScript code passed to 18 | // Page.AppendJs() (vs using a string). The resulting string of code is later 19 | // compiled for inclusion in the final page for the browser to run. 20 | // 21 | // 3. Inject a caching later around JSX passed to Page.Memo() 22 | // 23 | // 24 | 25 | export default function(babel) 26 | { 27 | const t = babel.types; 28 | 29 | return { 30 | visitor: 31 | { 32 | Program(pageProgramNodePath) 33 | { 34 | // 35 | // We wamt to make sure that any JSX code passed to 36 | // client JS is not first compliled for page JS. 37 | // 38 | // Since babel plugin order is complex, 39 | // and we want to guarantee that this plugin 40 | // has completed before the page JS JSX transform, 41 | // we immediately traverse the enture program. 42 | // 43 | 44 | let internalApiImported = false; 45 | 46 | let pageApiImportDeclaration; 47 | let importedPageIdentifier; 48 | 49 | const memoKeyGenerator = uniqueGenerator('__nakedjsx_memo_', '__'); 50 | 51 | pageProgramNodePath.traverse( 52 | { 53 | JSXAttribute(nodePath) 54 | { 55 | // Catch attempts to set magic props 56 | if (nodePath.node.name.name === 'context') 57 | throw nodePath.buildCodeFrameError(`Manually setting reserved 'context' prop is not allowed`); 58 | 59 | if (nodePath.node.name.name === 'children') 60 | throw nodePath.buildCodeFrameError(`Manually setting reserved 'children' prop is not allowed`); 61 | }, 62 | 63 | ImportDeclaration(nodePath) 64 | { 65 | if (nodePath.node.source.value !== '@nakedjsx/core/page') 66 | return; 67 | 68 | for (const specifer of nodePath.node.specifiers) 69 | { 70 | if (specifer.type !== 'ImportSpecifier') 71 | continue; 72 | 73 | if (specifer.imported.name !== 'Page') 74 | continue; 75 | 76 | // 77 | // This program has imported { Page }, or maybe { Page as Something }, from '@nakedjsx/core/page' 78 | // 79 | // We are making the assumption that Render() will be called directy on this object, 80 | // but people doing fancy things can call await themselves. 81 | // 82 | 83 | importedPageIdentifier = specifer.local.name; 84 | 85 | // 86 | // We also automatically import the __nakedjsx_page_internal__ api if needed. To do that, 87 | // we'll need a reference to this node later. 88 | // 89 | 90 | pageApiImportDeclaration = nodePath; 91 | 92 | break; 93 | } 94 | }, 95 | 96 | CallExpression(nodePath, pluginPass) 97 | { 98 | if (!importedPageIdentifier) 99 | return; 100 | 101 | const callee = nodePath.node.callee; 102 | if (callee.type !== 'MemberExpression') 103 | return; 104 | 105 | if (callee.object.name !== importedPageIdentifier) 106 | return; 107 | 108 | // 109 | // It's Page.() 110 | // 111 | 112 | if (callee.property.name === 'Render') 113 | { 114 | if (nodePath.parentPath.type !== 'AwaitExpression') 115 | { 116 | // 117 | // Wrap the non-awaited Page.Render(...) with an AwaitExpression 118 | // 119 | 120 | nodePath.replaceWith(t.awaitExpression(nodePath.node)); 121 | } 122 | 123 | return; 124 | } 125 | 126 | if (callee.property.name === 'AppendJs' || callee.property.name === 'AppendJsIfNew') 127 | { 128 | handleAppendJs(nodePath); 129 | return; 130 | } 131 | 132 | // if (callee.property.name === 'Memo') 133 | // { 134 | // if (!internalApiImported) 135 | // { 136 | // pageApiImportDeclaration.node.specifiers.push(t.importSpecifier(t.identifier('__nakedjsx_page_internal__'), t.identifier('__nakedjsx_page_internal__'))); 137 | // internalApiImported = true; 138 | // } 139 | 140 | // handleMemo(nodePath); 141 | // return; 142 | // } 143 | } 144 | }) 145 | 146 | /** 147 | * All arguments passed to Page.AppendJs() / Page.AppendJsIfNew() are converted 148 | * to strings containing JavaScript source code. 149 | */ 150 | function handleAppendJs(nodePath) 151 | { 152 | const resultingJs = nodePath.get('arguments').map(handleAppendJsArgument.bind(null, nodePath.scope)); 153 | 154 | nodePath.node.arguments = resultingJs; 155 | } 156 | 157 | function handleAppendJsArgument(scope, path) 158 | { 159 | if (path.isStringLiteral()) 160 | return path.node; 161 | 162 | if (path.isTemplateLiteral()) 163 | return path.node; 164 | 165 | if (path.isFunctionExpression() && path.node.id) 166 | { 167 | // 168 | // Page.AppendJs() has been passed a named function in full: 169 | // 170 | // Page.AppendJs(function namedFunction() { ... }); 171 | // 172 | // We replace this with a string containing the source code of the function. 173 | // 174 | // Any more FunctionExpressions we assume to be anon 175 | // 176 | 177 | return t.stringLiteral(path.toString()); 178 | } 179 | 180 | if (path.isFunctionExpression() || path.isArrowFunctionExpression()) 181 | { 182 | // 183 | // Page.AppendJs() has been passed an anon or arrow function: 184 | // 185 | // Page.AppendJs(function() { ... }); 186 | // Page.AppendJs(() => { ... }); 187 | // 188 | // In either case it doesn't make sense to add either of these 189 | // to the top level scope as-is as they'd never be invoked. 190 | // 191 | // However, we can make use of this syntax to add the whole body 192 | // of the function to the top level scope. 193 | // 194 | 195 | const body = path.get('body'); 196 | 197 | if (body.isBlockStatement()) 198 | { 199 | const conciseBodyJs = 200 | body.get('body') 201 | .map(statement => statement.toString()) 202 | .join(''); 203 | 204 | return t.stringLiteral(conciseBodyJs); 205 | } 206 | else 207 | // probably something like () => someFunc() 208 | return t.stringLiteral(body.toString()); 209 | } 210 | 211 | if (path.isIdentifier()) 212 | { 213 | // 214 | // Page.AppendJs() has been passed a function or variable identifier. 215 | // 216 | // If we can unambigiously find its value, and that 217 | // value is a function, replace it with a string 218 | // containing the source code of that function. 219 | // 220 | 221 | const binding = scope.getBinding(path.node.name); 222 | 223 | if (!binding) 224 | throw path.buildCodeFrameError(`Something isn't right with this, please let the NakedJSX team know what you're doing`); 225 | 226 | if (binding.path.isFunctionDeclaration()) 227 | { 228 | // 229 | // A named function has been passed by its identifier. 230 | // Replace with a string containing the source code for that function. 231 | // 232 | 233 | return t.stringLiteral(binding.path.toString()); 234 | } 235 | 236 | // 237 | // Check for a const that points to an anon or arrow function. 238 | // 239 | // This allows code like: 240 | // 241 | // const Tag = 242 | // () => 243 | //

244 | // some tag 245 | //

246 | // 247 | // or: 248 | // 249 | // const Tag = 250 | // function() 251 | // { 252 | // return

some tag

253 | // } 254 | // 255 | // To be passed to client JS using: 256 | // 257 | // Page.AppendJs(Tag); 258 | // 259 | 260 | if (binding.path.isVariableDeclarator()) 261 | { 262 | if (!path.isConstantExpression()) 263 | throw path.buildCodeFrameError(`Identifiers passed to Page.AppendJs must be const.`); 264 | 265 | const value = binding.path.get('init'); 266 | 267 | if (value.isArrowFunctionExpression() || value.isFunctionExpression()) 268 | return t.stringLiteral(generate(t.variableDeclaration('const', [binding.path.node])).code); 269 | else 270 | throw path.buildCodeFrameError(`Identifiers passed to Page.AppendJsx must be initialised with a function`); 271 | } 272 | 273 | if (binding.path.isImportSpecifier()) 274 | { 275 | // 276 | // We need to inject the same (single) import into the client JS 277 | // 278 | 279 | const importNode = t.cloneNode(binding.path.parentPath.node) 280 | importNode.specifiers = [t.cloneNode(binding.path.node)]; 281 | 282 | return t.stringLiteral(generate(importNode).code); 283 | } 284 | 285 | if (binding.path.isImportDefaultSpecifier()) 286 | { 287 | // 288 | // We need to inject the same import into the client JS 289 | // 290 | 291 | const importNode = t.cloneNode(binding.path.parentPath.node); 292 | 293 | return t.stringLiteral(generate(importNode).code); 294 | } 295 | } 296 | 297 | // By default, just convert to the code to a string representation and return it as a StringLiteral node 298 | return t.stringLiteral(path.toString()); 299 | } 300 | 301 | /** 302 | * 303 | */ 304 | function handleMemo(memoNodePath) 305 | { 306 | // 307 | // If a cache key has not been directly provided, 308 | // we attempt to generate one from dynamic expressions 309 | // within the JSX. 310 | // 311 | 312 | const cacheKeyNode = 313 | memoNodePath.node.arguments.length > 1 314 | ? memoNodePath.node.arguments[1] 315 | : createMemoKeyNode(memoNodePath); 316 | 317 | // 318 | // Replace the Page.Memo() call with code that interacts with 319 | // the memo cache for this build of this page. Crucially, the 320 | // JSX passed to Page.Memo() is not evaluated unless the cache 321 | // does not contain a value for the key. 322 | // 323 | 324 | const cacheId = memoKeyGenerator.next().value; 325 | 326 | memoNodePath.replaceWith( 327 | t.callExpression( 328 | t.arrowFunctionExpression( 329 | [t.identifier('key')], 330 | t.logicalExpression( 331 | '??', 332 | t.callExpression( 333 | t.memberExpression( 334 | t.identifier('__nakedjsx_page_internal__'), 335 | t.identifier('memoCacheGet')), 336 | [t.stringLiteral(cacheId), t.identifier('key')]), 337 | t.callExpression( 338 | t.memberExpression( 339 | t.identifier('__nakedjsx_page_internal__'), 340 | t.identifier('memoCacheSet')), 341 | [t.stringLiteral(cacheId), t.identifier('key'), memoNodePath.node.arguments[0]]) 342 | ) 343 | ), 344 | [cacheKeyNode] 345 | ) 346 | ); 347 | } 348 | 349 | function createMemoKeyNode(memoNodePath) 350 | { 351 | // 352 | // Try to generate a key from any dynamic elements used within the JSX. 353 | // Only do this for very simple cases for now, otherwise generate a 354 | // compiler error. 355 | // 356 | 357 | const uniqueMemberExpressions = new Set(); 358 | const uniqueIdentifiers = new Set(); 359 | const automaticKeyNodes = []; 360 | 361 | memoNodePath.get('arguments.0').traverse( 362 | { 363 | JSXExpressionContainer(jsxExpressionNodePath) 364 | { 365 | // 366 | // The JSX contains {/* something */} 367 | // 368 | // We're interested in any expressions that might vary at runtime, 369 | // so that the runtime value can form part of the cache key. 370 | // 371 | // Have to be very careful with this so for now just support 372 | // simple things like 'obj.member' or 'identifier'. 373 | // 374 | // There are likely other things that can safely be ignored 375 | // or added to the cache key. CallExpression would be interesting 376 | // to support but very complex given that it might be conditionally 377 | // called, and that the result would have to be passed to the original 378 | // expression to avoid calling it twice. 379 | // 380 | 381 | function makeStringify(nodeToStringify) 382 | { 383 | return t.callExpression( 384 | t.memberExpression(t.identifier('JSON'),t.identifier('stringify')), 385 | [nodeToStringify] 386 | ); 387 | } 388 | 389 | jsxExpressionNodePath.traverse( 390 | { 391 | enter(nodePath) 392 | { 393 | if ( t.isTemplateLiteral(nodePath) 394 | || t.isTemplateElement(nodePath) 395 | || t.isMemberExpression(nodePath) 396 | || t.isIdentifier(nodePath) 397 | || t.isLogicalExpression(nodePath) 398 | || t.isUnaryExpression(nodePath) 399 | || t.isConditional(nodePath) 400 | || t.isJSX(nodePath) 401 | || t.isSequenceExpression(nodePath) 402 | || t.isNumericLiteral(nodePath) 403 | || t.isStringLiteral(nodePath) 404 | ) 405 | { 406 | return; 407 | } 408 | 409 | throw nodePath.buildCodeFrameError(`Cannot currently build an automatic Page.Memo() cache key from this expression`); 410 | }, 411 | 412 | MemberExpression(nodePath) 413 | { 414 | const code = nodePath.toString(); 415 | 416 | if (!uniqueMemberExpressions.has(code)) 417 | { 418 | uniqueMemberExpressions.add(code); 419 | automaticKeyNodes.push(makeStringify(t.cloneDeep(nodePath.node))); 420 | } 421 | 422 | nodePath.skip(); 423 | }, 424 | 425 | Identifier(nodePath) 426 | { 427 | const code = nodePath.toString(); 428 | 429 | if (!uniqueIdentifiers.has(code)) 430 | { 431 | uniqueIdentifiers.add(code); 432 | automaticKeyNodes.push(makeStringify(t.cloneDeep(nodePath.node))); 433 | } 434 | } 435 | }); 436 | } 437 | }); 438 | 439 | // The generated key is of the form [].join() 440 | return t.callExpression( 441 | t.memberExpression( 442 | t.arrayExpression(automaticKeyNodes), 443 | t.identifier('join') 444 | ), 445 | []); 446 | } 447 | } 448 | } 449 | }; 450 | } -------------------------------------------------------------------------------- /build-system/babel/plugin-scoped-css.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | 3 | import { err } from '../util.mjs'; 4 | 5 | // babel plugin implementation 6 | export default function(babel, options) 7 | { 8 | const t = babel.types; 9 | 10 | const { scopedCssSet } = options; 11 | 12 | function getNodeStringValue(node, errorPath) 13 | { 14 | if (t.isStringLiteral(node)) 15 | return node.value; 16 | 17 | if (t.isTemplateLiteral(node)) 18 | { 19 | // 20 | // JavaScript template literals are used for multiline strings. 21 | // 22 | // The embedded variable syntax is not supported as this would 23 | // require eval() at compile time and would be of limited benefit. 24 | // 25 | // We could check if all the components are const but that would 26 | // assume that the compile time init value is the correct value. 27 | // 28 | 29 | if (node.expressions.length > 0 || node.quasis.length != 1) 30 | throw errorPath.buildCodeFrameError('Javascript variables within scoped css attributes are not currently supported in client Javascript. If you need this, consider using a style={`...`} attribute instead.'); 31 | 32 | return node.quasis[0].value.cooked; 33 | } 34 | 35 | if (t.isJSXExpressionContainer(node)) 36 | return getNodeStringValue(node.expression, errorPath); 37 | 38 | return undefined; 39 | } 40 | 41 | return { 42 | visitor: 43 | { 44 | CallExpression(nodePath, state) 45 | { 46 | const callee = nodePath.node.callee; 47 | 48 | if (!t.isIdentifier(callee) || callee.name !== '__nakedjsx__createElement') 49 | return; 50 | 51 | // 52 | // It's a call to __nakedjsx__createElement(tagName, props, ...children). 53 | // 54 | 55 | // 56 | // If it's a call to __nakedjsx__createElement(__nakedjsx__createFragment, null, ...children) 57 | // we can optimise this to just 'children' for a smaller build 58 | // 59 | 60 | const firstArg = nodePath.node.arguments[0]; 61 | 62 | if (t.isIdentifier(firstArg) && firstArg.name === '__nakedjsx__createFragment') 63 | { 64 | if (!t.isNullLiteral(nodePath.node.arguments[1])) 65 | fatal('Unexpected use of __nakedjsx__createFragment: ' + nodePath.toString()); 66 | 67 | nodePath.replaceWith(t.arrayExpression(nodePath.node.arguments.slice(2))); 68 | return; 69 | } 70 | 71 | // 72 | // Do the props contain "css": ... ? 73 | // 74 | 75 | if (!t.isObjectExpression(nodePath.node.arguments[1])) 76 | return; 77 | 78 | const objectPath = nodePath.get('arguments.1'); 79 | const propsPath = objectPath.get('properties'); 80 | const cssPropPath = propsPath.find(prop => prop.node.key.name === 'css'); 81 | const classNamePropPath = propsPath.find(prop => prop.node.key.name === 'className'); 82 | const contextPropPath = propsPath.find(prop => prop.node.key.name === 'context'); 83 | 84 | // rename existing className to class for a smaller runtime 85 | if (classNamePropPath) 86 | classNamePropPath.node.key.name = 'class'; 87 | 88 | // remove magic context prop path for now, effectively reserving it 89 | if (contextPropPath) 90 | contextPropPath.remove(); 91 | 92 | if (!cssPropPath) 93 | return; 94 | 95 | // 96 | // This call to JSX.CreateElement() has a prop named 'css'. 97 | // It's time to convert this scoped CSS into a class prop. 98 | // 99 | 100 | const scopedCss = getNodeStringValue(cssPropPath.node.value, cssPropPath); 101 | 102 | // Remove the css prop no matter what 103 | cssPropPath.remove(); 104 | 105 | if (!scopedCss) 106 | return; 107 | 108 | const cssClassName = scopedCssSet.getClassName(scopedCss); 109 | if (!cssClassName) 110 | return; 111 | 112 | // 113 | // And now append / add the className prop 114 | // 115 | 116 | if (classNamePropPath) 117 | { 118 | const classNames = `${cssClassName} ${getNodeStringValue(classNamePropPath.node.value, classNamePropPath)}`; 119 | classNamePropPath.node.value = t.stringLiteral(classNames); 120 | } 121 | else 122 | { 123 | const classNameProp = t.objectProperty(t.identifier('class'), t.stringLiteral(cssClassName)); 124 | objectPath.pushContainer('properties', classNameProp); 125 | } 126 | } 127 | } 128 | }; 129 | }; 130 | -------------------------------------------------------------------------------- /build-system/babel/plugin-template-engine-renderer.mjs: -------------------------------------------------------------------------------- 1 | // 2 | // NakedJSX pages are normally generated as an import() side effect. 3 | // In template engine mode, we need to wrap page generation in an 4 | // exported and resuable function. 5 | // 6 | 7 | export default function(babel) 8 | { 9 | const t = babel.types; 10 | 11 | return { 12 | visitor: 13 | { 14 | Program(nodePath) 15 | { 16 | const body = nodePath.get('body'); 17 | const statements = body.filter(statement => !statement.isImportDeclaration()); 18 | const nodes = []; 19 | 20 | for (const statement of statements) 21 | { 22 | const node = statement.node; 23 | const { leadingComments, trailingComments } = node; 24 | 25 | // Bizarely, if we don't detach comments like this the comments remain in program after statement.remove() 26 | node.leadingComments = null; 27 | node.trailingComments = null; 28 | 29 | statement.remove(); 30 | 31 | // Restore the comment links so they are added when the node is 32 | node.leadingComments = leadingComments; 33 | node.trailingComments = trailingComments; 34 | 35 | nodes.push(node); 36 | } 37 | 38 | const res = nodePath.pushContainer( 39 | 'body', 40 | t.exportNamedDeclaration( 41 | t.functionDeclaration( 42 | t.identifier('render'), 43 | [t.identifier('__context')], 44 | t.blockStatement(nodes), 45 | false, // generator 46 | true // async, needed for Page.Render() 47 | ) 48 | ) 49 | ); 50 | } 51 | } 52 | }; 53 | }; 54 | -------------------------------------------------------------------------------- /build-system/cache.mjs: -------------------------------------------------------------------------------- 1 | const defaultCacheLimit = 128; 2 | 3 | /** 4 | * A key-value cache that limits the cache size by evicting the oldest entries. 5 | */ 6 | export class FifoCache 7 | { 8 | #name; 9 | #limit; 10 | #map; 11 | #shouldWarn; 12 | 13 | constructor(name, limit, disableWarning) 14 | { 15 | this.name = name; 16 | this.limit = limit ?? defaultCacheLimit; 17 | this.#map = new Map(); 18 | this.#shouldWarn = !disableWarning; 19 | } 20 | 21 | /** Return whether cache has value for key */ 22 | has(key) 23 | { 24 | return this.#map.has(key); 25 | } 26 | 27 | /** Set value for key, and return value */ 28 | set(key, value) 29 | { 30 | this.#map.set(key, value) 31 | 32 | if (this.#map.size > this.#limit) 33 | { 34 | if (this.#shouldWarn) 35 | { 36 | this.#shouldWarn = true; 37 | console.warn(`NakedJSX FifoCache cache size limit (${this.#limit}) exceeded for cache: ${this.$name}`); 38 | } 39 | 40 | // Remove the oldest entry to keep the cache size down 41 | this.#map.delete(this.#map.entries().next().value[0]); 42 | } 43 | 44 | return value; 45 | } 46 | 47 | /** Return cached value for key, or undefined */ 48 | get(key) 49 | { 50 | return this.#map.get(key); 51 | } 52 | } 53 | 54 | /** 55 | * A key-value cache that limits the cache size by evicting in (least retrieved, oldest) order. 56 | */ 57 | export class LruCache 58 | { 59 | #name; 60 | #limit; 61 | #map; 62 | #shouldWarn; 63 | 64 | constructor(name, limit, disableWarning) 65 | { 66 | this.name = name; 67 | this.limit = limit ?? defaultCacheLimit; 68 | this.#map = new Map(); 69 | this.#shouldWarn = !disableWarning; 70 | } 71 | 72 | /** Return whether cache has value for key */ 73 | has(key) 74 | { 75 | return this.#map.has(key); 76 | } 77 | 78 | /** Set value for key, and return value */ 79 | set(key, value) 80 | { 81 | this.#map.set(key, value) 82 | 83 | if (this.#map.size > this.#limit) 84 | { 85 | if (this.#shouldWarn) 86 | { 87 | this.#shouldWarn = false; 88 | console.warn(`NakedJSX LruCache cache size limit (${this.#limit}) exceeded for cache: ${this.#name}`); 89 | } 90 | 91 | // Remove the least-used entry to keep the cache size down 92 | this.#map.delete(this.#map.entries().next().value[0]); 93 | } 94 | 95 | return value; 96 | } 97 | 98 | /** Return cached value for key, or undefined */ 99 | get(key) 100 | { 101 | const value = this.#map.get(key); 102 | 103 | if (value !== undefined) 104 | { 105 | // delete and re-add will update the insertion order 106 | this.#map.delete(key); 107 | this.#map.set(key, value); 108 | } 109 | 110 | return value; 111 | } 112 | } -------------------------------------------------------------------------------- /build-system/cli-bin.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // 4 | // If the destination root folder is part of a package 5 | // that depends on another @nakedjsx/core, then invoke that 6 | // version's CLI directly. If not, then use this version. 7 | // 8 | 9 | import fs from 'node:fs'; 10 | import path from 'node:path'; 11 | import child_process from 'node:child_process'; 12 | 13 | import { main, usage } from './cli.mjs'; 14 | import { log, warn, fatal, absolutePath } from './util.mjs'; 15 | 16 | function findPackageJson(searchDir) 17 | { 18 | searchDir = absolutePath(searchDir); 19 | 20 | while (searchDir) 21 | { 22 | const testFile = path.join(searchDir, 'package.json'); 23 | if (fs.existsSync(testFile)) 24 | return testFile; 25 | 26 | const nextSearchDir = path.normalize(path.join(searchDir, '..')); 27 | if (nextSearchDir === searchDir) 28 | return null; 29 | 30 | searchDir = nextSearchDir; 31 | } 32 | } 33 | 34 | function isDependencyOrDevDependency(packageFilePath, packageName) 35 | { 36 | try 37 | { 38 | const pkg = JSON.parse(fs.readFileSync(packageFilePath)); 39 | 40 | if (pkg.dependencies && pkg.dependencies[packageName]) 41 | return true; 42 | 43 | if (pkg.devDependencies && pkg.devDependencies[packageName]) 44 | return true; 45 | } 46 | catch(error) 47 | { 48 | warn(`Could not parse ${packageFilePath}`); 49 | } 50 | 51 | return false; 52 | } 53 | 54 | async function forwardToTargetNakedJSX(rootDir, packageFilePath) 55 | { 56 | log(`Forwarding to NakedJSX from ${packageFilePath} to build ${rootDir}`); 57 | 58 | const packageFileDir = path.dirname(packageFilePath); 59 | 60 | // 61 | // Note, we use '.' intead of the original source dir because we are changing cwd. 62 | // The cwd change is necessary to correctly invoke the @nakedjsx/core installed 63 | // around rootDir. 64 | // 65 | // For this reason we also override the --cli-path-base to cwd, which allows paths 66 | // passed on CLI to be relative to where the command was executed, not to the 67 | // changed cwd after forwarding the invocation. 68 | // 69 | // We also want to defend against infinite useTargetNakedJSX recursion bugs so we 70 | // pass --do-not-forward, which forces the next @nakedjsx/core to build it. 71 | // 72 | 73 | const nakedJsxArguments = 74 | ['.', '--do-not-forward', '--cli-path-base', process.cwd()] 75 | .concat(process.argv.slice(3)); 76 | 77 | let command; 78 | let commandArguments; 79 | 80 | if (fs.existsSync(path.join(packageFileDir, 'yarn.lock'))) 81 | { 82 | log('yarn.lock detected, assuming yarn'); 83 | 84 | command = 'yarn'; 85 | commandArguments = ['nakedjsx'].concat(nakedJsxArguments); 86 | } 87 | else if (fs.existsSync(path.join(packageFileDir, 'pnpm-lock.yaml'))) 88 | { 89 | log('pnpm-lock.yaml detected, assuming pnpm'); 90 | 91 | command = 'pnpm'; 92 | commandArguments = ['exec', 'nakedjsx'].concat(nakedJsxArguments); 93 | } 94 | else if (fs.existsSync(path.join(packageFileDir, 'package-lock.json'))) 95 | { 96 | log('package-lock.json detected, assuming npm'); 97 | 98 | command = 'npx'; 99 | commandArguments = ['nakedjsx'].concat(nakedJsxArguments); 100 | } 101 | else 102 | { 103 | fatal('Target package not installed or dep mananger not detected (looked for yarn, pnpm, and npm).'); 104 | } 105 | 106 | log(`Launching child process within ${rootDir}: ${command} ${commandArguments.join(' ')}`); 107 | 108 | child_process.spawnSync( 109 | command, 110 | commandArguments, 111 | { 112 | stdio: 'inherit', 113 | cwd: rootDir 114 | }); 115 | } 116 | 117 | export async function earlyMain() 118 | { 119 | // 120 | // Depending on cwd, 'npx nakedjsx ' will either invoke a globally 121 | // installed @nakedjsx/core, or the 'nakedjsx' binary exposed by an 122 | // installation of @nakedjsx/core that cwd resides in. 123 | // 124 | // A decision to be made - allow the currently executing instalation of 125 | // NakedJSX to handle the build, or invoke the version of NakedJSX 126 | // installed in a package that contains the folder to be built. 127 | // 128 | // Getting this right means you can always use 'npx nakedjsx' 129 | // and the build result will be the same, regardless of the cwd. 130 | // 131 | 132 | if (process.argv.length < 3) 133 | fatal(' is required.', usage); 134 | 135 | // [0] == node, [1] == this script, [2] == root dir 136 | const [rootDir, ...args] = process.argv.slice(2); 137 | 138 | if (rootDir === '--help') 139 | { 140 | usage(); 141 | process.exit(0); 142 | } 143 | 144 | if (!fs.existsSync(rootDir)) 145 | fatal(`Pages directory (${rootDir}) does not exist`); 146 | 147 | if (!fs.statSync(rootDir).isDirectory()) 148 | fatal(`Pages directory (${rootDir}) exists but is not a directory`); 149 | 150 | // Have we been directly told to use the currently running @nakedjsx/core, rather than consider forwarding? 151 | if (args.length && args[0] === '--do-not-forward') 152 | return main(); 153 | 154 | const targetPackageFilePath = findPackageJson(rootDir); 155 | 156 | // If the target folder isn't part of a package, use the bundled @nakedjsx/core 157 | if (!targetPackageFilePath) 158 | return main(); 159 | 160 | // If the target package doesn't directly depend on @nakedjsx/core, use the bundled @nakedjsx/core 161 | if (!isDependencyOrDevDependency(targetPackageFilePath, '@nakedjsx/core')) 162 | return main(); 163 | 164 | // 165 | // The target does directly depend on @nakedjsx/core. 166 | // 167 | // If the currently running nakedjsx is somewhere under the dir that 168 | // holds the target package file, then we can keep running. 169 | // 170 | // Otherwise, we forward this invocation to the @nakedjsx/core 171 | // installation within the target package. 172 | // 173 | 174 | if (process.argv[1].startsWith(path.dirname(targetPackageFilePath))) 175 | return main(); 176 | 177 | // 178 | // Finally, it appears that the target is in a package 179 | // unrelated to the one that the running version of @nakedjsx/core 180 | // is running from. Defer to the target installation. 181 | // 182 | 183 | return forwardToTargetNakedJSX(rootDir, targetPackageFilePath); 184 | } 185 | 186 | await earlyMain(); -------------------------------------------------------------------------------- /build-system/cli.mjs: -------------------------------------------------------------------------------- 1 | 2 | // 3 | // This tool is just a simple config building and invocation wrapper. 4 | // 5 | 6 | import fs from 'node:fs'; 7 | import path from 'node:path'; 8 | 9 | import { packageInfo, configFilename, emptyConfig, NakedJSX } from './nakedjsx.mjs'; 10 | import { log, warn, fatal, jsonClone, camelToKebabCase, absolutePath, merge } from './util.mjs'; 11 | 12 | let developmentMode = false; // --dev 13 | let configSave = false; // --config-save 14 | let configPathBase; 15 | let rootDir; 16 | 17 | function configPath(filepath) 18 | { 19 | // 20 | // All paths passed on CLI are interpretted relative to configPathBase. 21 | // If the @nakedjsx/code invocation wasn't forwarded, then it likely 22 | // won't be set yet and we use an intuitive default of cwd. 23 | // 24 | // Because invocation forwarding changes cwd, it also passes the original 25 | // cwd to use as a config path base via --cli-path-base. 26 | // 27 | 28 | if (!configPathBase) 29 | configPathBase = process.cwd(); 30 | 31 | // 32 | // Convert an absolute or relative to configPathBase path 33 | // to one relative to the root dir config file. 34 | // 35 | 36 | return path.relative(rootDir, absolutePath(filepath, configPathBase)); 37 | } 38 | 39 | const options = 40 | { 41 | '--import-resolve-override': 42 | { 43 | advanced: true, 44 | desc: 'Resolve imports of as .', 45 | args: ['pkg', 'location'], 46 | impl(config, { pkg, location}) 47 | { 48 | config.importResolveOverrides[pkg] = location; 49 | } 50 | }, 51 | 52 | '--do-not-forward': 53 | { 54 | advanced: true, 55 | desc: 'Use the running @nakedksx/core, do not consider forwarding to another installation.', 56 | impl() 57 | { 58 | // no-op - this is handled by the cli-bin wrapper 59 | } 60 | }, 61 | 62 | '--cli-path-base': 63 | { 64 | advanced: true, 65 | desc: 'Interpret CLI relative paths relative to ', 66 | args: ['cliPathBase'], 67 | impl(config, { cliPathBase }) 68 | { 69 | if (configPathBase) 70 | fatal('--cli-path-base must be before other path CLI options'); 71 | 72 | configPathBase = absolutePath(cliPathBase); 73 | } 74 | }, 75 | 76 | '--dev': 77 | { 78 | desc: 'Launch a hot-refresh development server', 79 | impl() 80 | { 81 | developmentMode = true; 82 | } 83 | }, 84 | 85 | '--config-save': 86 | { 87 | desc: 'Save the effective config to /.nakedjsx.json', 88 | impl() 89 | { 90 | configSave = true; 91 | } 92 | }, 93 | 94 | '--out': 95 | { 96 | desc: 'The build output will be placed here', 97 | args: ['path'], 98 | deprecatedAlias: ['--output-dir'], 99 | impl(config, { path }) 100 | { 101 | if (config.output) 102 | config.output.dir = configPath(path); 103 | else 104 | config.output = { dir: configPath(path) }; 105 | 106 | if (config.outputDir) 107 | delete config.outputDir; 108 | } 109 | }, 110 | 111 | '--css-common': 112 | { 113 | desc: 'CSS to compile and compress along with extracted scoped css="..." JSX attributes', 114 | args: ['pathToCssFile'], 115 | impl(config, { pathToCssFile }) 116 | { 117 | config.commonCssFile = configPath(pathToCssFile) 118 | } 119 | }, 120 | 121 | '--plugin': 122 | { 123 | desc: 'Enable plugin and set its unique alias, for example: --plugin image @nakedjsx/plugin-asset-image', 124 | args: ['alias', 'pluginPackageNameOrPath'], 125 | async impl(config, { alias, pluginPackageNameOrPath }) 126 | { 127 | let finalPath; 128 | const testPath = absolutePath(pluginPackageNameOrPath); 129 | 130 | if (fs.existsSync(testPath)) 131 | finalPath = configPath(testPath); 132 | else 133 | { 134 | // hopefully a package name like @nakedjsx/plugin-asset-image 135 | finalPath = pluginPackageNameOrPath; 136 | } 137 | 138 | if (config.plugins[alias] && config.plugins[alias] !== finalPath) 139 | fatal(`Plugin alias '${alias}' already used by ${config.plugins[alias]}`); 140 | 141 | config.plugins[alias] = finalPath; 142 | } 143 | }, 144 | 145 | '--path-alias': 146 | { 147 | desc: 'Import path alias, eg. import something from \'$SRC/something.mjs\'', 148 | args: ['alias', 'path'], 149 | impl(config, { alias, path }) 150 | { 151 | config.pathAliases[alias] = configPath(path); 152 | } 153 | }, 154 | 155 | '--define': 156 | { 157 | desc: 'Make string data available to code, eg. import VALUE from \'KEY\'', 158 | args: ['key', 'value'], 159 | impl(config, { key, value }) 160 | { 161 | config.definitions[key] = value; 162 | } 163 | }, 164 | 165 | '--sourcemaps-disable': 166 | { 167 | desc: 'Don\'t create sourcemaps (which are normally enabled in dev mode and when debugger attached)', 168 | impl(config) 169 | { 170 | config.output.pageJs.sourcemaps = 'disable'; 171 | config.output.clientJs.sourcemaps = 'disable'; 172 | } 173 | }, 174 | 175 | '--sourcemaps-enable': 176 | { 177 | desc: 'Create sourcemaps (which are normally disabled unless dev mode or when debugger attached)', 178 | impl(config) 179 | { 180 | config.output.pageJs.sourcemaps = 'enable'; 181 | config.output.clientJs.sourcemaps = 'enable'; 182 | } 183 | }, 184 | 185 | '--quiet': 186 | { 187 | desc: 'Produce less log output', 188 | impl(config) 189 | { 190 | config.quiet = true; 191 | } 192 | }, 193 | 194 | '--pretty': 195 | { 196 | desc: 'Format output HTML, CSS, and JavaScript. Warning: Looks better, but assumes whitespace around some HTML tags is not significant. Use --pretty-ish if that is a problem.', 197 | impl(config) 198 | { 199 | config.pretty = true; 200 | } 201 | }, 202 | 203 | '--pretty-ish': 204 | { 205 | desc: 'Format output HTML, CSS, and JavaScript, preserving whitespace around all HTML tags.', 206 | impl(config) 207 | { 208 | config.pretty = 'ish'; 209 | } 210 | }, 211 | 212 | '--help': 213 | { 214 | desc: 'Print basic help information and exit', 215 | impl() 216 | { 217 | usage(); 218 | process.exit(0); 219 | } 220 | }, 221 | }; 222 | 223 | export function usage() 224 | { 225 | let optionsHelp = ''; 226 | 227 | for (const flag in options) 228 | { 229 | const option = options[flag]; 230 | 231 | if (option.advanced) 232 | continue; 233 | 234 | let argText = ''; 235 | if (option.args) 236 | for (const argCamel of option.args) 237 | argText += ` <${camelToKebabCase(argCamel)}>`; 238 | 239 | optionsHelp += `\n`; 240 | optionsHelp += ` # ${option.desc}\n`; 241 | optionsHelp += ` ${flag}${argText}\n`; 242 | } 243 | 244 | // TOOD: Update usage to include yarn version 245 | console.log( 246 | `NakedJSX ${packageInfo.version} 247 | 248 | Usage: 249 | 250 | # ${options['--help'].desc} 251 | npx nakedjsx --help 252 | 253 | # Find and build NakedJSX pages in 254 | npx nakedjsx [options] 255 | 256 | Options: 257 | ${optionsHelp} 258 | Detailed documentation: 259 | 260 | https://nakedjsx.org/documentation/` 261 | ); 262 | } 263 | 264 | function determineRootDir(args) 265 | { 266 | if (args < 1) 267 | fatal(' is required.', usage); 268 | 269 | let rootDir = args.shift(); 270 | 271 | if (rootDir === '--help') 272 | { 273 | options['--help'].impl(); 274 | throw Error; 275 | } 276 | 277 | if (!fs.existsSync(rootDir)) 278 | fatal(`Pages directory (${rootDir}) does not exist`); 279 | 280 | // Get rid of symlinks etc 281 | rootDir = fs.realpathSync(rootDir); 282 | 283 | if (!fs.statSync(rootDir).isDirectory()) 284 | fatal(`Pages directory (${rootDir}) exists but is not a directory`); 285 | 286 | return rootDir; 287 | } 288 | 289 | function loadBaseConfig() 290 | { 291 | // 292 | // Attempt to load config from pages dir 293 | // 294 | 295 | const config = jsonClone(emptyConfig); 296 | const configFile = path.join(rootDir, configFilename); 297 | 298 | if (!fs.existsSync(configFile)) 299 | return config; 300 | 301 | try 302 | { 303 | merge(config, JSON.parse(fs.readFileSync(configFile))); 304 | } 305 | catch(error) 306 | { 307 | fatal('Failed to parse config file ' + error); 308 | } 309 | 310 | return config; 311 | } 312 | 313 | async function processCliArguments(args, config) 314 | { 315 | // 316 | // Process command line options 317 | // 318 | 319 | while (args.length) 320 | { 321 | let flag = args.shift(); 322 | let option = options[flag]; 323 | 324 | if (!option) 325 | { 326 | // Flag not found, is it a deprecated alias? 327 | 328 | let found = false; 329 | 330 | for (const [replacementFlag, replacementOption] of Object.entries(options)) 331 | { 332 | if (!replacementOption.deprecatedAlias) 333 | continue; 334 | 335 | if (replacementOption.deprecatedAlias.includes(flag)) 336 | { 337 | warn(`Flag ${flag} is a deprecated alias of ${replacementFlag}. Please update your usage.`); 338 | 339 | found = true; 340 | flag = replacementFlag; 341 | option = replacementOption; 342 | 343 | break; 344 | } 345 | } 346 | 347 | if (!found) 348 | fatal(`Unknown flag: ${flag}`, usage); 349 | } 350 | 351 | const optionArguments = {}; 352 | for (const argCamel of option.args || []) 353 | { 354 | if (args.length == 0) 355 | fatal(`${flag} missing required <${camelToKebabCase(argCamel)}> argument`, usage); 356 | 357 | optionArguments[argCamel] = args.shift(); 358 | } 359 | 360 | await option.impl(config, optionArguments); 361 | } 362 | } 363 | 364 | export async function main() 365 | { 366 | // [0] == node, [1] == this script or something directly or indirectly importing it 367 | const args = process.argv.slice(2); 368 | 369 | rootDir = determineRootDir(args); 370 | 371 | const config = loadBaseConfig(rootDir); 372 | 373 | const configBefore = JSON.stringify(config); 374 | await processCliArguments(args, config); 375 | let configDirty = JSON.stringify(config) !== configBefore; 376 | 377 | if (configSave) 378 | { 379 | const configPath = path.join(rootDir, configFilename); 380 | log(`Writing config to ${configPath}`); 381 | 382 | fs.writeFileSync(configPath, JSON.stringify(config, null, 4)); 383 | configDirty = false; 384 | } 385 | 386 | // 387 | // As a NakedJSX build gets older, which is expected when using repeatable builds, 388 | // browserslist will start to get out of date and will start to warn about it. 389 | // 390 | // This prevents that warning from appearing. 391 | // 392 | 393 | process.env.BROWSERSLIST_IGNORE_OLD_DATA = 'true'; 394 | 395 | // 396 | // If the config is dirty, pass it directly to @nakedjsx/core. 397 | // Otherwise we let it read the config file from root dir. 398 | // 399 | 400 | const options = {}; 401 | 402 | if (configDirty) 403 | options.configOverride = config; 404 | 405 | const nakedJsx = new NakedJSX(rootDir, options); 406 | 407 | if (developmentMode) 408 | await nakedJsx.developmentMode(); 409 | else 410 | await nakedJsx.build(); 411 | } 412 | -------------------------------------------------------------------------------- /build-system/css.mjs: -------------------------------------------------------------------------------- 1 | import postcssNested from 'postcss-nested'; 2 | import postcss from 'postcss'; 3 | import * as syntax from 'csso/syntax'; 4 | 5 | import { log, warn, convertToBase } from "../build-system/util.mjs"; 6 | 7 | const postcssProcessor = postcss(postcssNested); 8 | const loadCssCache = new Map(); 9 | const reserveCssCache = new Map(); 10 | 11 | export class ScopedCssSet 12 | { 13 | // A set of reserved CSS class names for the generator to avoid. 14 | reserved; 15 | 16 | // className -> { cssCodeDedup, cssCodeFinal } 17 | allClasses; 18 | 19 | // cssCodeDedup -> className 20 | cssToClassName; 21 | 22 | // Used to generate the next CSS class name. 23 | nextClassNameIndex; 24 | 25 | constructor(reserved = new Set()) 26 | { 27 | this.reserved = reserved; 28 | this.allClasses = new Map(); 29 | this.cssToClassName = new Map(); 30 | this.nextClassNameIndex = 0; 31 | } 32 | 33 | subset(classNames) 34 | { 35 | // Assumes the subset wants to avoid the same reserved names ... 36 | const scopedCssSet = new ScopedCssSet(this.reserved); 37 | 38 | for (const className of classNames) 39 | { 40 | const existingClass = this.allClasses.get(className); 41 | 42 | if (!existingClass) 43 | throw new Error(`Attempt to create ScopedCssSet subset including non-existent class: ${className}`); 44 | 45 | scopedCssSet.addCss(className, existingClass.cssCodeDedup, existingClass.cssCodeFinal); 46 | } 47 | 48 | return scopedCssSet; 49 | } 50 | 51 | collateAll() 52 | { 53 | let collatedCss = ''; 54 | 55 | for (const [key, value] of this.allClasses) 56 | collatedCss += value.cssCodeFinal; 57 | 58 | return collatedCss; 59 | } 60 | 61 | getClassName(scopedCss) 62 | { 63 | // 64 | // Scoped CSS doesn't include a top level class name, 65 | // but optionally includes nested CSS for child elements. 66 | // 67 | // For example: 68 | // 69 | // margin: var(--gap-2) auto; 70 | // padding: 0 var(--gap); 71 | // 72 | // #email { 73 | // display: block; 74 | // margin: 0 auto var(--gap-2); 75 | // max-width: var(--max-width-email); 76 | // width: 100%; 77 | // } 78 | // 79 | // This class sets margin and padding to the target element, 80 | // and additionally sets CSS for any child element with id 'email'. 81 | // 82 | // This function will wrap the code in a generated class name, 83 | // then compile and optimise it to a standard form. If the 84 | // standard form has not been seen before, allocate it a final 85 | // class name and return it. Otherwise, it returns the previously 86 | // allocated class name. 87 | // 88 | // If the standard form is empty, then an empty string is returned. 89 | // 90 | 91 | // First find a string that doesn't exist within the CSS code to use as a temporary classname, needed for loadCss(...) 92 | let tmpClassName = '_'; 93 | while (scopedCss.includes(tmpClassName)) 94 | tmpClassName += '_'; 95 | 96 | // This will optimise the CSS to a standard form, ideal for deduplication 97 | const cssCodeDedup = loadCss(`.${tmpClassName}{${scopedCss}}`); 98 | 99 | // The CSS may optimise away to nothing 100 | if (!cssCodeDedup) 101 | return ''; 102 | 103 | let className = this.cssToClassName.get(cssCodeDedup); 104 | if (className) 105 | return className; 106 | 107 | // 108 | // We've not seen this scoped CSS before, allocate it a new classname. 109 | // Be sure to avoid clashing with reserved classnames and classes 110 | // manually added via addCss(...). 111 | // 112 | 113 | do 114 | { 115 | className = convertToBase(this.nextClassNameIndex++, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"); 116 | } 117 | while (this.reserved.has(className) || this.allClasses.has(className)); 118 | 119 | // 120 | // Replace the temporary class name to create the the final CSS code 121 | // 122 | 123 | const cssCodeFinal = cssCodeDedup.replaceAll(tmpClassName, className); 124 | 125 | this.addCss(className, cssCodeDedup, cssCodeFinal); 126 | 127 | return className; 128 | } 129 | 130 | addCss(className, cssCodeDedup, cssCodeFinal) 131 | { 132 | this.cssToClassName.set(cssCodeDedup, className); 133 | this.allClasses.set(className, { cssCodeDedup, cssCodeFinal }); 134 | } 135 | 136 | reserveCommonCssClasses(commonCss) 137 | { 138 | if (!commonCss) 139 | return; 140 | 141 | const reserved = this.reserved; 142 | 143 | // 144 | // We avoid generating a class name that clashes with a class 145 | // manually predefined in the common js. 146 | // 147 | 148 | let cacheResult = reserveCssCache.get(commonCss); 149 | if (cacheResult) 150 | { 151 | for (const reservedClass of cacheResult) 152 | reserved.add(reservedClass); 153 | 154 | return; 155 | } 156 | 157 | cacheResult = []; 158 | 159 | syntax.walk( 160 | syntax.parse(commonCss), 161 | { 162 | visit: 'ClassSelector', 163 | enter(node, item, list) 164 | { 165 | cacheResult.push(node.name); 166 | reserved.add(node.name); 167 | } 168 | }); 169 | 170 | reserveCssCache.set(commonCss, cacheResult) 171 | } 172 | } 173 | 174 | export function loadCss(input, options) 175 | { 176 | const cacheKey = input + JSON.stringify(options) 177 | const cacheResult = loadCssCache.get(cacheKey); 178 | 179 | if (cacheResult) 180 | return cacheResult; 181 | 182 | // 183 | // First use postcss-nested to flatten nested CSS 184 | // 185 | 186 | let css = postcssProcessor.process(input, { from: undefined }).css; 187 | 188 | // 189 | // Get the uncompressed ast 190 | // 191 | 192 | let ast = syntax.parse(css); 193 | 194 | // 195 | // Perform an initial compression so that subsequent custom 196 | // compression operations are measuing their real impact. 197 | // 198 | 199 | const minifyOptions = { comments: false }; 200 | 201 | ast = syntax.compress(ast, minifyOptions).ast; 202 | 203 | // 204 | // Conditionally apply our custom CSS variable compression 205 | // 206 | 207 | if (options?.renameVariables) 208 | { 209 | compressCssVariables( 210 | ast, 211 | "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", 212 | [ /* variable names to keep can go in here */ ]); 213 | } 214 | 215 | // 216 | // Now generate the final CSS 217 | // 218 | 219 | const result = syntax.generate(ast); 220 | loadCssCache.set(cacheKey, result); 221 | 222 | return result; 223 | } 224 | 225 | function compressCssVariables(cssAst, digitSymbols, namesToPreserve = []) 226 | { 227 | // 228 | // First gather information about variable declarations. 229 | // 230 | 231 | const cssVars = {}; 232 | 233 | syntax.walk( 234 | cssAst, 235 | { 236 | visit: 'Declaration', 237 | enter(node, item, list) 238 | { 239 | if (!node.property.startsWith('--')) 240 | return; 241 | 242 | const name = node.property; 243 | const cssVar = cssVars[name]; 244 | 245 | if (!cssVar) 246 | { 247 | cssVars[name] = { declareCount: 1, useCount: 0, value: node.value.value }; 248 | return; 249 | } 250 | 251 | // 252 | // This CSS variable has been declared more than once. 253 | // 254 | // If it has been declared again with the same value, we pretend 255 | // that it wasn't. This allows us to consider flattening it away. 256 | // 257 | 258 | if (node.value.value === cssVar.value) 259 | return; 260 | 261 | // 262 | // This is probably a variable that varies based on a media query. 263 | // 264 | 265 | if (cssVar.declareCount++ == 1) 266 | { 267 | cssVar.values = [ cssVar.value, node.value.value ]; 268 | delete cssVar.value; // no longer accurate 269 | } 270 | else 271 | cssVar.values.push(node.value.value); 272 | } 273 | }); 274 | 275 | // 276 | // Now look at variable usage. 277 | // 278 | 279 | syntax.walk( 280 | cssAst, 281 | { 282 | visit: 'Function', 283 | enter(node, item, list) 284 | { 285 | if (node.name != 'var') 286 | return; 287 | 288 | const name = node.children.head.data.name; 289 | const cssVar = cssVars[name]; 290 | 291 | if (!cssVar) 292 | { 293 | warn(`css variable ${name} used but not declared`); 294 | return; 295 | } 296 | 297 | cssVar.useCount++; 298 | 299 | return this.skip; 300 | } 301 | }); 302 | 303 | // 304 | // Strip out any variable declarations that were not used. 305 | // 306 | 307 | syntax.walk( 308 | cssAst, 309 | { 310 | visit: 'Declaration', 311 | enter(node, item, list) 312 | { 313 | const name = node.property; 314 | const cssVar = cssVars[name]; 315 | 316 | if (!cssVar) 317 | return; 318 | 319 | if (cssVar.useCount === 0) 320 | { 321 | if (!list) 322 | { 323 | err(`could not remove css var ${name}`); 324 | return; 325 | } 326 | 327 | list.remove(item); 328 | } 329 | } 330 | }); 331 | 332 | // 333 | // Sort the variables from most to least used. This causes the shortest 334 | // replacement names to be allocated to the most commonly used variables. 335 | // 336 | 337 | const sortedNameUsage = Object.entries(cssVars).sort(([,a], [,b]) => b.useCount - a.useCount); 338 | 339 | // 340 | // Now make flattening and replacement decisions 341 | // 342 | 343 | const preserveNames = new Set(namesToPreserve); 344 | const renamedVariables = new Map(); 345 | const flattenedVariables = new Set(); 346 | 347 | let nextNameIndex = 0; 348 | let nextCssVarName; 349 | 350 | function createNextName() 351 | { 352 | nextCssVarName = '--' + convertToBase(nextNameIndex++, digitSymbols); 353 | } 354 | 355 | createNextName(); 356 | 357 | for (const [name, cssVar] of sortedNameUsage) 358 | { 359 | if (preserveNames.has(name)) 360 | continue; 361 | 362 | if (!cssVar.useCount) // then the rest will also have zero usage 363 | break; 364 | 365 | // 366 | // If the var is declared more than once we keep it as a variable 367 | // so that a media query can change its value at runtime. 368 | // 369 | // Also keep variables that are declared once but have a value 370 | // large enough such that flattening it would take more space. 371 | // 372 | 373 | let rename = false; 374 | 375 | if (cssVar.declareCount > 1) 376 | rename = true; 377 | else 378 | { 379 | // 380 | // We can assume that length == bytes for our CSS var names, due to ASCII naming. 381 | // 382 | 383 | const declationLength = `${nextCssVarName}:${cssVar.value};`.length; 384 | const totalRenamedLength = declationLength + (cssVar.useCount * `var(${nextCssVarName})`.length); 385 | 386 | // 387 | // TODO we should serialise the utf value to bytes to get the real length 388 | // 389 | 390 | const totalFlattenedLength = cssVar.useCount * cssVar.value.length; 391 | 392 | if (totalRenamedLength < totalFlattenedLength) 393 | rename = true; 394 | } 395 | 396 | if (rename) 397 | { 398 | renamedVariables.set(name, nextCssVarName); 399 | createNextName(); 400 | continue; 401 | } 402 | 403 | // 404 | // The variable is declared once, but has a short value so 405 | // we can save space by replacing all use of the variable 406 | // with the value itself. 407 | // 408 | 409 | flattenedVariables.add(name); 410 | } 411 | 412 | // 413 | // Finally, apply our flattening and name replacements 414 | // 415 | // Start with removing / renaming declarations 416 | // 417 | 418 | syntax.walk( 419 | cssAst, 420 | { 421 | visit: 'Declaration', 422 | enter(node, item, list) 423 | { 424 | const name = node.property; 425 | 426 | if (flattenedVariables.has(name)) 427 | { 428 | if (!list) 429 | throw new Error(`Could not remove flatten declaration of ${name}`); 430 | 431 | list.remove(item); 432 | } 433 | else if (renamedVariables.has(name)) 434 | { 435 | node.property = renamedVariables.get(name); 436 | } 437 | } 438 | }); 439 | 440 | // 441 | // Then replace var(--*) function calls with renamed vars, or flattened values 442 | // 443 | 444 | syntax.walk( 445 | cssAst, 446 | { 447 | visit: 'Function', 448 | enter(node, item, list) 449 | { 450 | if (node.name != 'var') 451 | return; 452 | 453 | const name = node.children.head.data.name; 454 | 455 | if (flattenedVariables.has(name)) 456 | { 457 | delete node.children; 458 | node.type = 'Identifier'; 459 | node.name = cssVars[name].value; 460 | } 461 | else if (renamedVariables.has(name)) 462 | { 463 | node.children.head.data.name = renamedVariables.get(name); 464 | } 465 | 466 | return this.skip; 467 | } 468 | }); 469 | } 470 | -------------------------------------------------------------------------------- /build-system/dev-client-injection.js: -------------------------------------------------------------------------------- 1 | 2 | // 3 | // Development mode injected script providing auto refresh services. 4 | // 5 | 6 | (function() 7 | { 8 | if (navigator.userAgent.indexOf("Chrome-Lighthouse") != -1) 9 | return; 10 | 11 | const reconnectionDelayMsMax = 60 * 1000; 12 | 13 | let reconnectionDelayMs = 0; 14 | 15 | function connect() 16 | { 17 | const idleUrl = `/nakedjsx:/idle?path=${encodeURIComponent(location.pathname)}`; 18 | 19 | console.log(`Connecting to development server on ${idleUrl}`); 20 | 21 | fetch(idleUrl) 22 | .then((result) => result.json()) 23 | .then( 24 | (result) => 25 | { 26 | reconnectionDelayMs = 0; 27 | 28 | switch (result.action) 29 | { 30 | case 'reload': 31 | location.reload(); 32 | return; // break would briefly and pointlessly reconnect 33 | 34 | default: 35 | console.log('Did not understand dev server idle response: ' + JSON.stringify(result)); 36 | } 37 | 38 | connect(); 39 | }) 40 | .catch( 41 | (error) => 42 | { 43 | reconnectionDelayMs = Math.min(reconnectionDelayMs + 1000, reconnectionDelayMsMax); 44 | 45 | console.error(error); 46 | setTimeout(connect, reconnectionDelayMs); 47 | }); 48 | } 49 | 50 | connect(); 51 | })(); -------------------------------------------------------------------------------- /build-system/dev-server.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import fsp from 'node:fs/promises'; 3 | import path from 'node:path'; 4 | import http from 'node:http' 5 | import { URL } from 'node:url'; 6 | 7 | import { log, err } from './util.mjs'; 8 | 9 | // 10 | // Super simple long-poll dev server. 11 | // 12 | // Used to notify a client that a uri path has been updated. 13 | // 14 | 15 | export class DevServer 16 | { 17 | #clientJsFile; 18 | 19 | #serverRoot; 20 | #serverUrl; 21 | 22 | #idleClients = new Map(); // page url -> [ idle http response ] 23 | 24 | constructor({ serverRoot, clientJsFile }) 25 | { 26 | if (!fs.existsSync(serverRoot)) 27 | throw new Error(`serverRoot ${serverRoot} does not exist`); 28 | 29 | if (!fs.existsSync(clientJsFile)) 30 | throw new Error(`clientJsFile ${clientJsFile} does not exist`); 31 | 32 | this.#serverRoot = serverRoot; 33 | this.#clientJsFile = clientJsFile; 34 | 35 | // 36 | // Start a local dev web server 37 | // 38 | 39 | const defaultServerPort = 8999; 40 | let serverPort = defaultServerPort; 41 | 42 | const server = http.createServer(this.#handleRequest.bind(this)); 43 | server.on( 44 | 'error', 45 | (e) => 46 | { 47 | if (e.code !== 'EADDRINUSE') 48 | throw e; 49 | 50 | server.close(); 51 | 52 | if (--serverPort <= defaultServerPort - 10) 53 | { 54 | fatal(`Ports ${defaultServerPort} - ${serverPort + 1} in use, giving up.`); 55 | } 56 | 57 | console.error(`* Port ${serverPort + 1} in use, trying ${serverPort}`); 58 | server.listen(serverPort); 59 | }); 60 | server.on( 61 | 'listening', 62 | () => 63 | { 64 | this.#serverUrl = `http://localhost:${serverPort}`; 65 | log(`Development web server started\n`); 66 | }); 67 | server.listen(serverPort); 68 | } 69 | 70 | get serverUrl() 71 | { 72 | return this.#serverUrl; 73 | } 74 | 75 | //// 76 | 77 | async #handleRequest(req, response) 78 | { 79 | const url = new URL(req.url, 'http://localhost/'); 80 | const pathname = url.pathname; 81 | 82 | log(`HTTP request for ${req.url}`); 83 | 84 | if (pathname.startsWith('/nakedjsx:/')) 85 | return this.#handleDevRequest(req, response, url); 86 | 87 | // For non dev requests, ensure we end the response 88 | try 89 | { 90 | let file; 91 | 92 | if (pathname.endsWith('/')) 93 | file = `${this.#serverRoot}${pathname}index.html`; 94 | else 95 | file = `${this.#serverRoot}${pathname}`; 96 | 97 | async function resolve(testfile, onResolved, onError) 98 | { 99 | try 100 | { 101 | const resolvedFile = await fsp.realpath(testfile); 102 | const stat = await fsp.stat(resolvedFile); 103 | 104 | if (stat.isFile()) 105 | await onResolved(resolvedFile); 106 | else 107 | await onError(new Error(`Not a file: ${resolvedFile}`)); 108 | } 109 | catch(error) 110 | { 111 | await onError(error); 112 | } 113 | } 114 | 115 | await resolve( 116 | file, 117 | async (resolvedFile) => this.#serveFile(response, resolvedFile), 118 | async (outerError) => 119 | { 120 | // If the bad path included a file extension, nothing more to try 121 | if (path.extname(file)) 122 | return this.#respondUtf8(response, 404, 'text/plain', outerError.toString()); 123 | 124 | // Try the same path but plop a .html on the end of it. 125 | await resolve( 126 | file + '.html', 127 | async (resolvedFile) => this.#serveFile(response, resolvedFile), 128 | async (innerError) => this.#respondUtf8(response, 404, 'text/plain', outerError.toString()) 129 | ); 130 | }); 131 | } 132 | catch(error) 133 | { 134 | err(error.stack); 135 | } 136 | finally 137 | { 138 | response.end(); 139 | } 140 | } 141 | 142 | #respondUtf8(response, code, contentType, content, headers = {}) 143 | { 144 | response.writeHead( 145 | code, 146 | { 147 | 'Content-Type': contentType, 148 | ...headers 149 | }); 150 | response.end(content, 'utf-8'); 151 | } 152 | 153 | #respondBinary(response, code, contentType, content, headers = {}) 154 | { 155 | response.writeHead( 156 | code, 157 | { 158 | 'Content-Type': contentType, 159 | 'Content-Length': content.length, 160 | ...headers 161 | }); 162 | response.end(content); 163 | } 164 | 165 | #getFileType(filename) 166 | { 167 | // 168 | // These MIME types and maxAge values are used by the dev server only. 169 | // 170 | 171 | const ext = path.extname(filename); 172 | const contentTypes = 173 | { 174 | '.html': { type: 'text/html', maxAge: -1 }, 175 | '.js': { type: 'text/javascript', maxAge: -1 }, // Needs to be -1 until a hash is put into /nakedjsx:/client.js request 176 | '.css': { type: 'text/css', maxAge: 300 }, 177 | '.svg': { type: 'image/svg+xml', maxAge: 300 }, 178 | '.webp': { type: 'image/webp', maxAge: 300 }, 179 | '.png': { type: 'image/png', maxAge: 300 }, 180 | '.jpg': { type: 'image/jpeg', maxAge: 300 }, 181 | '.jpeg': { type: 'image/jpeg', maxAge: 300 }, 182 | }; 183 | 184 | return contentTypes[ext] || { type: 'application/octet-stream', maxAge: -1 }; 185 | } 186 | 187 | async #serveFile(response, filepath) 188 | { 189 | // log(` (${filepath})`); 190 | 191 | if (!filepath.startsWith(this.#serverRoot) && filepath != this.#clientJsFile) 192 | return this.#respondUtf8(response, 404, 'text/plain'); 193 | 194 | const type = this.#getFileType(filepath); 195 | 196 | let cacheControl; 197 | if (type.maxAge > 0) 198 | cacheControl = `public, max-age=${type.maxAge}, immutable`; 199 | else 200 | cacheControl = `max-age=-1`; 201 | 202 | try 203 | { 204 | const content = await fsp.readFile(filepath); 205 | this.#respondBinary(response, 200, type.type, content, { 'Cache-Control': cacheControl }); 206 | } 207 | catch(error) 208 | { 209 | this.#respondUtf8(response, 500, 'text/plain', error.toString()); 210 | } 211 | } 212 | 213 | //// 214 | 215 | #handleDevRequest(req, response, url) 216 | { 217 | const functionPath = url.pathname.substring(url.pathname.indexOf(':') + 1); 218 | 219 | switch(functionPath) 220 | { 221 | case '/client.js': 222 | this.#serveFile(response, this.#clientJsFile); 223 | break; 224 | 225 | case '/idle': 226 | let idlePath = url.searchParams.get('path'); 227 | 228 | // 229 | // Normalise idle path 230 | // 231 | 232 | if (idlePath.endsWith('.html')) 233 | idlePath = idlePath.substring(0, idlePath.length - '.html'.length); 234 | 235 | if (idlePath.endsWith('/index')) 236 | idlePath = idlePath.substring(0, idlePath.length - 'index'.length); 237 | 238 | if (idlePath === '') 239 | pathPath = '/'; 240 | 241 | log(`Client is watching ${idlePath}`); 242 | 243 | const idleClients = this.#idleClients.get(idlePath); 244 | if (idleClients) 245 | idleClients.push(response); 246 | else 247 | this.#idleClients.set(idlePath, [ response ]); 248 | 249 | response.writeHead( 250 | 202, 251 | { 252 | 'Content-Type': 'application/json' 253 | }); 254 | response.flushHeaders(); 255 | 256 | break; 257 | 258 | default: 259 | this.#respondUtf8(response, 404, 'text/plain', ''); 260 | } 261 | } 262 | 263 | onUriPathUpdated(uriPath) 264 | { 265 | const idleClients = this.#idleClients.get(uriPath); 266 | if (idleClients) 267 | { 268 | this.#idleClients.delete(uriPath); 269 | 270 | for (let response of idleClients) 271 | response.end(JSON.stringify({ action: 'reload' }), 'utf-8'); 272 | } 273 | } 274 | } -------------------------------------------------------------------------------- /build-system/rollup/plugin-map-cache.mjs: -------------------------------------------------------------------------------- 1 | import { log } from '../util.mjs'; 2 | 3 | export function mapCachePlugin(plugin, cache = new Map()) 4 | { 5 | async function cacheImpl(partitionKey, key, fallback) 6 | { 7 | // 8 | // Assumes that nullish values are not legitimate cacheable results. 9 | // 10 | 11 | let partition = cache.get(partitionKey) 12 | if (!partition) 13 | cache.set(partitionKey, partition = new Map()); 14 | 15 | const cachedResult = partition.get(key); 16 | if (cachedResult) 17 | return cachedResult; 18 | 19 | const result = await fallback(); 20 | if (result) 21 | partition.set(key, result); 22 | 23 | return result; 24 | } 25 | 26 | const cachePlugin = 27 | new Proxy( 28 | plugin, 29 | { 30 | get(target, prop, receiver) 31 | { 32 | const value = target[prop]; 33 | 34 | // We're only interested in function properties 35 | if (!(value instanceof Function)) 36 | return value; 37 | 38 | // If this proxy implements this method, return it 39 | if (this[prop]) 40 | return this[prop]; 41 | 42 | // 43 | // Otherwise, return a generic wrapper for the original plugin functionality that 44 | // warns if an unimplemented proxy method costs significant time. 45 | // 46 | 47 | return async function(...args) 48 | { 49 | const beforeMs = new Date().getTime(); 50 | const result = value.apply(this === receiver ? target : this, args); 51 | const durationMs = new Date().getTime() - beforeMs; 52 | 53 | if (durationMs > 1) 54 | { 55 | if (prop == 'load') 56 | log(`Unimplemented cache of method ${prop} ${args[0]} missed ${durationMs} ms`); 57 | else 58 | log(`Unimplemented cache of method ${prop} missed ${durationMs} ms`); 59 | } 60 | 61 | return result; 62 | }; 63 | }, 64 | 65 | async transform(code, ...args) 66 | { 67 | return cacheImpl('transform', code, plugin.transform.bind(this, code, ...args)); 68 | }, 69 | 70 | async renderChunk(code, ...args) 71 | { 72 | return cacheImpl('renderChunk', code, plugin.renderChunk.bind(this, code, ...args)); 73 | } 74 | }); 75 | 76 | return cachePlugin; 77 | } 78 | -------------------------------------------------------------------------------- /build-system/util.mjs: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { LruCache } from './cache.mjs'; 3 | 4 | import { transformAsync } from "@babel/core"; 5 | import * as _parser from "@babel/parser"; 6 | import * as types from "@babel/types"; 7 | import _traverse from "@babel/traverse"; 8 | 9 | export const babel = 10 | { 11 | parse: _parser.parse, 12 | parseExpression: _parser.parseExpression, 13 | traverse: _traverse.default, 14 | types, 15 | transformAsync 16 | }; 17 | 18 | let benchmarkEnable = false; 19 | let benchmarkStart = null; 20 | 21 | const boldOn = process.stdout.isTTY ? '\x1b[1m' : ''; 22 | const boldOff = process.stdout.isTTY ? '\x1b[22m' : ''; 23 | const cursorUpStart = process.stdout.isTTY ? '\x1b[1F' : ''; 24 | const eraseLine = process.stdout.isTTY ? '\x1b[0K' : ''; 25 | 26 | let promptClear = `${cursorUpStart}${eraseLine}${cursorUpStart}`; 27 | let promptText; 28 | 29 | function setPrompt(newPrompt) 30 | { 31 | if (process.stdout.isTTY) 32 | { 33 | const firstPrompt = !promptText; 34 | 35 | promptText = `\n${boldOn}${newPrompt}${boldOff}`; 36 | 37 | if (firstPrompt) 38 | log.quiet || console.log(`${promptText}`); 39 | else 40 | log.quiet || console.log(`${promptClear}${promptText}`) 41 | } 42 | else 43 | log.quiet || console.log(`\n${newPrompt}`); 44 | } 45 | 46 | export function enableBenchmark(enable) 47 | { 48 | if (enable) 49 | { 50 | if (benchmarkEnable) 51 | return; 52 | 53 | benchmarkEnable = true; 54 | benchmarkStart = new Date().getTime(); 55 | } else { 56 | benchmarkEnable = false; 57 | benchmarkStart = null; 58 | } 59 | } 60 | 61 | function formatLogMessaage(message, prefix = '') 62 | { 63 | if (typeof message !== 'string') 64 | message = '' + message; 65 | 66 | let finalMessage; 67 | 68 | if (benchmarkEnable) 69 | { 70 | const thisTime = new Date().getTime(); 71 | const timeSinceStart = ((thisTime - benchmarkStart) / 1000).toFixed(3); 72 | const [, leadingNewlines] = message.match(/^(\n*)/); 73 | const followingMessage = message.substring(leadingNewlines.length); 74 | 75 | finalMessage = `${leadingNewlines}${timeSinceStart}: ${prefix}${followingMessage}`; 76 | } else { 77 | finalMessage = `${prefix}${message}`; 78 | } 79 | 80 | if (process.stdout.isTTY && promptText) 81 | return `${promptClear}\r${finalMessage}\n${promptText}`; 82 | else 83 | return `${finalMessage}`; 84 | } 85 | 86 | export function log(message) 87 | { 88 | log.quiet || console.log(formatLogMessaage(message)); 89 | } 90 | 91 | log.important = 92 | function(message) 93 | { 94 | // ignore the quiet setting 95 | console.log(formatLogMessaage(message)) 96 | }; 97 | 98 | log.boldOn = boldOn; 99 | log.boldOff = boldOff; 100 | log.setPrompt = setPrompt; 101 | log.quiet = false; 102 | 103 | export function warn(message) 104 | { 105 | console.warn(`${boldOn}${formatLogMessaage(message, 'WARNING: ')}${boldOff}`); 106 | } 107 | 108 | /** logs stacktrace if not logged before */ 109 | 110 | let nextStackId = 0; 111 | const uniqueStacks = new LruCache('__stack__', undefined, true); 112 | 113 | export function err(errorOrMessage) 114 | { 115 | function formatErrorMessage(message) 116 | { 117 | return `${boldOn}${formatLogMessaage(message, 'ERROR: ')}${boldOff}` 118 | } 119 | 120 | if (errorOrMessage instanceof Error) 121 | { 122 | const stack = errorOrMessage.stack; 123 | let stackId = uniqueStacks.get(stack); 124 | 125 | if (stackId !== undefined) 126 | console.error(formatErrorMessage(`${errorOrMessage.message}\n (stacktrace previously logged with ID: __stack_${stackId}__)`)); 127 | else 128 | { 129 | stackId = nextStackId++; 130 | uniqueStacks.set(stack, stackId); 131 | 132 | console.error(formatErrorMessage(`Stacktrace ID: __stack_${stackId}__\n`), stack); 133 | } 134 | } 135 | else 136 | console.error(formatErrorMessage(errorOrMessage)); 137 | } 138 | 139 | export function fatal(message, lastHurrahCallback) 140 | { 141 | err(message); 142 | if (lastHurrahCallback) 143 | lastHurrahCallback(); 144 | 145 | setPrompt('Exit due to error.'); 146 | process.exit(1); 147 | } 148 | 149 | export function jsonClone(src) 150 | { 151 | return JSON.parse(JSON.stringify(src)); 152 | } 153 | 154 | export function convertToAlphaNum(value) 155 | { 156 | return convertToBase(value, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"); 157 | } 158 | 159 | export function convertToBase(value, digitSymbols) 160 | { 161 | // 162 | // value:Number 163 | // digitSymbols:string 164 | // 165 | 166 | if (!Number.isInteger(value)) 167 | throw Error(`convertToBase does not currently support non-integer values (${value})`); 168 | 169 | let n = Math.abs(value); 170 | 171 | const base = digitSymbols.length; 172 | let out = ''; 173 | 174 | do 175 | { 176 | const digitIndex = n % base; 177 | out = digitSymbols.charAt(digitIndex) + out; 178 | n = (n - digitIndex) / base; 179 | } 180 | while (n); 181 | 182 | if (value < 0) 183 | return '-' + out; 184 | else 185 | return out; 186 | } 187 | 188 | export function removeQueryString(path) 189 | { 190 | const queryIndex = path.indexOf('?'); 191 | 192 | if (queryIndex != -1) 193 | return path.substring(0, queryIndex); 194 | else 195 | return path; 196 | } 197 | 198 | export function absolutePath(absoluteOrRelativePath, relativeFrom) 199 | { 200 | if (path.isAbsolute(absoluteOrRelativePath)) 201 | return path.normalize(absoluteOrRelativePath); 202 | else 203 | return path.normalize(path.join(relativeFrom ?? process.cwd(), absoluteOrRelativePath)); 204 | } 205 | 206 | export function camelToKebabCase(camel) 207 | { 208 | return camel.replace(/[A-Z]/g, char => '-' + char.toLowerCase()); 209 | } 210 | 211 | export function semicolonify(js) 212 | { 213 | if (js.trim().endsWith(';')) 214 | return js; 215 | else 216 | return `${js};`; 217 | } 218 | 219 | export function merge(target, source, loopPreventor = new Set()) 220 | { 221 | loopPreventor.add(source); 222 | 223 | for (const [key, value] of Object.entries(source)) 224 | { 225 | if (typeof target[key] === 'object' && typeof value === 'object') 226 | { 227 | if (loopPreventor.has(value)) 228 | throw new Error(`Refusing to merge self-referencing object`); 229 | 230 | merge(target[key], value, loopPreventor); 231 | } 232 | else 233 | target[key] = value; 234 | } 235 | } 236 | 237 | export function *uniqueGenerator(prefix, suffix) 238 | { 239 | let nextUniqueIndex = 0; 240 | 241 | for (;;) 242 | yield `${prefix}${convertToAlphaNum(nextUniqueIndex++)}${suffix}`; 243 | } 244 | -------------------------------------------------------------------------------- /dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | rm -rf node_modules package-lock.json 4 | yarn set version stable 5 | yarn install 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@nakedjsx/core", 3 | "version": "0.18.0-test.5", 4 | "description": "Build static HTML files from JSX and scoped CSS.", 5 | "type": "module", 6 | "bin": { 7 | "nakedjsx": "build-system/cli-bin.mjs" 8 | }, 9 | "exports": { 10 | ".": "./build-system/nakedjsx.mjs", 11 | "./cli-bin": "./build-system/cli-bin.mjs", 12 | "./client": "./runtime/client/jsx.mjs", 13 | "./page": "./runtime/page/page.mjs", 14 | "./page/jsx-runtime": "./runtime/page/page.mjs" 15 | }, 16 | "dependencies": { 17 | "@babel/core": "^7.20.12", 18 | "@babel/generator": "^7.21.4", 19 | "@babel/parser": "^7.22.5", 20 | "@babel/plugin-syntax-jsx": "^7.22.5", 21 | "@babel/plugin-transform-react-jsx": "^7.20.13", 22 | "@babel/preset-env": "^7.20.2", 23 | "@babel/preset-typescript": "^7.22.5", 24 | "@babel/traverse": "^7.22.5", 25 | "@babel/types": "^7.22.5", 26 | "@rollup/plugin-babel": "^6.0.3", 27 | "@rollup/plugin-commonjs": "^28.0.0", 28 | "@rollup/plugin-inject": "^5.0.3", 29 | "chokidar": "^3.5.3", 30 | "css-tree": "^2.3.1", 31 | "csso": "^5.0.5", 32 | "postcss": "^8.4.21", 33 | "postcss-nested": "^6.0.0", 34 | "prettier": "3.2.5", 35 | "rollup": "^3.10.1", 36 | "terser": "^5.16.1" 37 | }, 38 | "overrides": { 39 | "semver": "7.5.2" 40 | }, 41 | "author": "David Hogan", 42 | "license": "BSD-3-Clause", 43 | "publishConfig": { 44 | "access": "public" 45 | }, 46 | "keywords": [ 47 | "nakedjsx", 48 | "jsx", 49 | "scoped-css" 50 | ], 51 | "repository": { 52 | "type": "git", 53 | "url": "git+https://github.com/NakedJSX/core.git" 54 | }, 55 | "bugs": { 56 | "url": "https://github.com/NakedJSX/core/issues" 57 | }, 58 | "homepage": "https://nakedjsx.org" 59 | } 60 | -------------------------------------------------------------------------------- /runtime/client/jsx.mjs: -------------------------------------------------------------------------------- 1 | const assetUriPathPlaceholder = /^__NAKEDJSX_ASSET_DIR__/; 2 | const assetAttributeNames = new Set(['data', 'srcset', 'src', 'href']); 3 | 4 | // 5 | // Wrap Element.appendChild() so that it can add an array of elements, 6 | // which allows a JSX fragment to be passed to appendChild. 7 | // Additionally, strings are converted to text nodes. 8 | // 9 | 10 | const originalAppendChild = Element.prototype.appendChild; 11 | Element.prototype.appendChild = 12 | function(child) 13 | { 14 | if (child instanceof Node) 15 | return originalAppendChild.call(this, child); 16 | else if (Array.isArray(child)) 17 | { 18 | for (const childArrayMember of child) 19 | this.appendChild(childArrayMember); 20 | 21 | return child; 22 | } 23 | else if (child === false || child === null || child === undefined) 24 | return null; 25 | else 26 | return originalAppendChild.call(this, document.createTextNode(child.toString())); 27 | }; 28 | 29 | export function __nakedjsx__createElement(tag, props, ...children) 30 | { 31 | props = props || {}; 32 | 33 | if (typeof tag === "function") 34 | { 35 | // Make child elements selectively placeable via {props.children} 36 | props.children = children; 37 | 38 | return tag(props); 39 | } 40 | 41 | // 42 | // Support the tag for injecting raw HTML 43 | // 44 | 45 | if (tag === 'raw-content') 46 | { 47 | const dummy = document.createElement('div'); 48 | dummy.innerHTML = props.content; 49 | return [...dummy.children]; 50 | } 51 | 52 | // 53 | // We're dealing with a regular HTML-like tag, not a JSX function. 54 | // 55 | // SVG and mathml elements need to be created with a namespace. 56 | // 57 | 58 | let element; 59 | if (props.xmlns) 60 | element = document.createElementNS(props.xmlns, tag); 61 | else 62 | element = document.createElement(tag); 63 | 64 | for (const [name, value] of Object.entries(props)) 65 | { 66 | if (name.startsWith('on')) 67 | { 68 | const lowercaseName = name.toLowerCase(); 69 | 70 | if (lowercaseName in window) 71 | { 72 | element.addEventListener(lowercaseName.substring(2), value); 73 | continue; 74 | } 75 | } 76 | 77 | // 78 | // Skip attributes with a valud of false, null, or undefined. 79 | // 80 | 81 | if (value === false || value === null || value === undefined) 82 | continue; 83 | 84 | // 85 | // Boolean 'true' attribute values are converted to the presence of 86 | // an attribute with no assigned value. 87 | // 88 | 89 | if (value === true) 90 | { 91 | element.setAttribute(name, ''); 92 | continue; 93 | } 94 | 95 | // 96 | // Support capturing a reference to the created element. 97 | // 98 | 99 | if (name == 'ref') 100 | { 101 | if (typeof value === 'object') 102 | value.current = element; 103 | else 104 | console.error('ref must be an object'); 105 | continue; 106 | } 107 | 108 | // 109 | // Imported assets need to be resolved to their final path 110 | // 111 | 112 | if (assetAttributeNames.has(name)) 113 | if (typeof value === 'string') 114 | if (assetUriPathPlaceholder.test(value)) 115 | { 116 | element.setAttribute(name, nakedjsx.assetPath(value)); 117 | continue; 118 | } 119 | 120 | // 121 | // Default attribute assignment 122 | // 123 | 124 | element.setAttribute(name, value); 125 | }; 126 | 127 | if (props.xmlns) 128 | { 129 | // 130 | // HACK - we need to recreate all the children with the same namespace 131 | // as the parent element. Otherwise, SVG and MathML elements 132 | // will not render correctly. 133 | // 134 | // It would be better to handle this as a babel plugin. 135 | // 136 | 137 | let innerHTML = ''; 138 | for (const child of children) 139 | { 140 | if (child instanceof Node) 141 | innerHTML += child.outerHTML; 142 | else 143 | innerHTML += child; 144 | } 145 | element.innerHTML = innerHTML; 146 | } 147 | else 148 | { 149 | for (const child of children) 150 | element.appendChild(child); 151 | } 152 | 153 | return element; 154 | } 155 | 156 | export function __nakedjsx__createFragment(props) 157 | { 158 | return props.children; 159 | } 160 | 161 | function createRef() 162 | { 163 | // 164 | // A Ref is a container that recieves a reference to 165 | // a created HTML element. 166 | // 167 | 168 | return {}; 169 | } 170 | 171 | function assetPath(assetPath) 172 | { 173 | return assetPath.replace(assetUriPathPlaceholder, relativeAssetRoot); 174 | } 175 | 176 | export const nakedjsx = 177 | { 178 | createRef, 179 | assetPath 180 | }; -------------------------------------------------------------------------------- /runtime/page/document.mjs: -------------------------------------------------------------------------------- 1 | export const assetUriPathPlaceholder = '__NAKEDJSX_ASSET_DIR__'; 2 | 3 | // These elements are self closing (i.e.
, not
) 4 | const voidElements = 5 | new Set( 6 | [ 7 | 'area', 8 | 'base', 9 | 'br', 10 | 'col', 11 | 'embed', 12 | 'hr', 13 | 'img', 14 | 'input', 15 | 'link', 16 | 'meta', 17 | 'source', 18 | 'track', 19 | 'wbr' 20 | ]); 21 | 22 | function empty(s) 23 | { 24 | if (s === null) 25 | return true; 26 | 27 | if (s === undefined) 28 | return true; 29 | 30 | if (s.toString().trim() === '') 31 | return true; 32 | 33 | return false; 34 | } 35 | 36 | export class Element 37 | { 38 | static #pool = []; 39 | 40 | tagName; 41 | context; 42 | id; 43 | attributes; 44 | children; 45 | 46 | static From(tagName, context) 47 | { 48 | if (this.#pool.length) 49 | { 50 | const element = this.#pool.pop(); 51 | 52 | element.tagName = tagName; 53 | element.content = context; 54 | element.id = undefined; 55 | 56 | element.children.length = 0; 57 | element.attributes.clear(); 58 | 59 | return element; 60 | } 61 | 62 | return new Element(tagName, context); 63 | } 64 | 65 | constructor(tagName, context) 66 | { 67 | this.tagName = tagName; 68 | this.context = context; 69 | this.children = []; 70 | this.attributes = new Map(); 71 | } 72 | 73 | release() 74 | { 75 | Element.#pool.push(this); 76 | } 77 | 78 | setAttribute(key, value) 79 | { 80 | requireValidAttributeName(key); 81 | 82 | if (key === 'context') 83 | return; // already set at construction 84 | 85 | // If the value is empty, strip the attribute 86 | if (empty(value)) 87 | return; 88 | 89 | if (key === 'id') 90 | this.id = value; 91 | 92 | if (key === 'ref') 93 | { 94 | value.set(this); 95 | return; 96 | } 97 | 98 | this.attributes.set(key, value); 99 | } 100 | 101 | appendChild(child) 102 | { 103 | // 104 | // Limit what types can be added as child nodes. 105 | // 106 | // In particular we want to prevent a boolean from being added, 107 | // as that lets us use JSX like: 108 | // 109 | // { val > 0 && } 110 | // 111 | // which can evalulate to 'false'. 112 | // 113 | 114 | if (Array.isArray(child)) 115 | { 116 | for (const nestedChild of child) 117 | this.appendChild(nestedChild); 118 | 119 | return child; 120 | } 121 | 122 | switch (typeof child) 123 | { 124 | case 'object': 125 | if (child.toHtml) 126 | this.children.push(child); 127 | break; 128 | 129 | case 'string': 130 | this.children.push(child); 131 | break; 132 | } 133 | 134 | return child; 135 | } 136 | 137 | toHtml(renderContext) 138 | { 139 | const { relativeAssetRoot } = renderContext; 140 | 141 | var html; 142 | 143 | if (this.tagName) 144 | { 145 | // 146 | // Sometimes we want to inject a raw fragment, such as an SVG. 147 | // We do this via a custom raw-content tag with a content attribute. 148 | // 149 | 150 | if (this.tagName === 'raw-content') 151 | return this.attributes.get('content'); 152 | 153 | // Temporary workaround for https://github.com/prettier/prettier/issues/16184 154 | if (this.tagName === 'pre') 155 | html = '\n<' + this.tagName; 156 | else 157 | html = '<' + this.tagName; 158 | 159 | // Attributes 160 | for (const [key, value] of this.attributes.entries()) 161 | { 162 | html += ' ' + key; 163 | 164 | switch (typeof value) 165 | { 166 | case 'string': 167 | switch (key) 168 | { 169 | case "data": 170 | case "srcset": 171 | case "src": 172 | case "href": 173 | html += '="' + escapeHtml(value.replaceAll(assetUriPathPlaceholder, relativeAssetRoot)) + '"'; 174 | break; 175 | 176 | default: 177 | html += '="' + escapeHtml(value) + '"'; 178 | } 179 | break; 180 | 181 | case 'number': 182 | html += '="' + escapeHtml(value.toString()) + '"'; 183 | break; 184 | } 185 | } 186 | 187 | html += '>'; 188 | 189 | if (voidElements.has(this.tagName)) 190 | return html; 191 | } 192 | else 193 | html = ''; /* Text node */ 194 | 195 | for (const child of this.children) 196 | { 197 | // if (child.toHtml) 198 | if (child instanceof Element) 199 | { 200 | html += child.toHtml(renderContext); 201 | child.release(); 202 | } 203 | else if (typeof child === 'string') 204 | html += escapeHtml(child); 205 | } 206 | 207 | if (this.tagName) 208 | html += ''; 209 | 210 | return html; 211 | } 212 | } 213 | 214 | /** Caching proxy for a single element */ 215 | export class CachingElementProxy 216 | { 217 | key; 218 | cacheMap; 219 | element; 220 | 221 | constructor(key, cacheMap, element) 222 | { 223 | this.key = key; 224 | this.cacheMap = cacheMap; 225 | this.element = element; 226 | } 227 | 228 | deferredRender() 229 | { 230 | return this; 231 | } 232 | 233 | toHtml(renderContext) 234 | { 235 | let html = this.cacheMap.get(this.key) 236 | if (html) 237 | return html; 238 | 239 | if (this.element.toHtml) 240 | html = this.element.toHtml(renderContext); 241 | else if (typeof this.element === 'string') 242 | html = escapeHtml(this.element); 243 | 244 | this.cacheMap.set(this.key, html); 245 | return html; 246 | } 247 | } 248 | 249 | /** Caching proxy for a an array of element */ 250 | export class CachingHtmlRenderer 251 | { 252 | #html; 253 | #elements; 254 | 255 | constructor(elements) 256 | { 257 | this.#elements = elements; 258 | } 259 | 260 | deferredRender() 261 | { 262 | return this; 263 | } 264 | 265 | toHtml(renderContext) 266 | { 267 | if (this.#html) 268 | return this.#html; 269 | 270 | this.html = ''; 271 | 272 | for (const element of this.#elements) 273 | { 274 | if (!element) 275 | continue; 276 | 277 | if (element.toHtml) 278 | this.#html += element.toHtml(renderContext); 279 | else if (typeof element === 'string') 280 | this.#html += escapeHtml(element); 281 | } 282 | 283 | // Don't need the elements anymore 284 | this.#elements = null; 285 | 286 | return this.#html; 287 | } 288 | } 289 | 290 | export class ServerDocument 291 | { 292 | constructor(lang) 293 | { 294 | this.elementsWithCss = []; 295 | 296 | this.documentElement = Element.From("html"); 297 | this.documentElement.setAttribute("lang", lang); 298 | 299 | this.head = this.documentElement.appendChild(Element.From("head")); 300 | this.body = this.documentElement.appendChild(Element.From("body")); 301 | } 302 | 303 | toHtml(renderContext) 304 | { 305 | return '' + this.documentElement.toHtml(renderContext); 306 | } 307 | } 308 | 309 | function isValidAttributeName(attributeName) 310 | { 311 | // 312 | // HTML5 attribute names must consist of one or more characters other than controls, 313 | // U+0020 SPACE, U+0022 ("), U+0027 ('), U+003E (>), U+002F (/), U+003D (=), and noncharacters. 314 | // 315 | // https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 316 | // 317 | 318 | // 'controls' 319 | if (/[\u0000-\u001F\u007F-\u009F]/.test(attributeName)) 320 | return false; 321 | 322 | // Specific printable ASCII characters 323 | if (/[ "'>\/=]/.test(attributeName)) 324 | return false; 325 | 326 | // 'noncharacters' 327 | if (/[\uFDD0-\uFDEF\uFFFE\uFFFF\u{1FFFE}\u{1FFFF}\u{2FFFE}\u{2FFFF}\u{3FFFE}\u{3FFFF}\u{4FFFE}\u{4FFFF}\u{5FFFE}\u{5FFFF}\u{6FFFE}\u{6FFFF}\u{7FFFE}\u{7FFFF}\u{8FFFE}\u{8FFFF}\u{9FFFE}\u{9FFFF}\u{AFFFE}\u{AFFFF}\u{BFFFE}\u{BFFFF}\u{CFFFE}\u{CFFFF}\u{DFFFE}\u{DFFFF}\u{EFFFE}\u{EFFFF}\u{FFFFE}\u{FFFFF}\u{10FFFE}\u{10FFFF}]/u.test(attributeName)) 328 | return false; 329 | 330 | return true; 331 | } 332 | 333 | function requireValidAttributeName(attributeName) 334 | { 335 | if (!isValidAttributeName(attributeName)) 336 | throw Error("Invalid attribute name: " + attributeName); 337 | } 338 | 339 | function escapeHtml(text) 340 | { 341 | const htmlEscapeMap = 342 | { 343 | '&': '&', 344 | '<': '<', 345 | '>': '>', 346 | '"': '"', 347 | "'": ''' 348 | }; 349 | 350 | return text.replace( 351 | /[&<>"']/g, 352 | (m) => htmlEscapeMap[m] 353 | ); 354 | } 355 | -------------------------------------------------------------------------------- /runtime/page/page.mjs: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { AsyncLocalStorage } from 'node:async_hooks'; 3 | 4 | import { getCurrentJob } from '../../build-system/nakedjsx.mjs'; 5 | import { ServerDocument, CachingHtmlRenderer, Element } from './document.mjs'; 6 | import { convertToAlphaNum, log, semicolonify, babel } from '../../build-system/util.mjs'; 7 | import { LruCache } from '../../build-system/cache.mjs'; 8 | import { loadCss } from '../../build-system/css.mjs'; 9 | 10 | const interBuildCache = new Map(); 11 | const htmlEventHandlerCache = new LruCache('unbound identifiers', 1024); 12 | const asyncLocalStorage = new AsyncLocalStorage(); 13 | 14 | // Generated 2023-06-17 in Chrome using: JSON.stringify(Object.keys(window).filter(key => key.startsWith('on'))) 15 | const validHtmlEventHandlers = 16 | new Set( 17 | ["onsearch","onappinstalled","onbeforeinstallprompt","onbeforexrselect","onabort","onbeforeinput","onblur","oncancel","oncanplay","oncanplaythrough","onchange","onclick","onclose","oncontextlost","oncontextmenu","oncontextrestored","oncuechange","ondblclick","ondrag","ondragend","ondragenter","ondragleave","ondragover","ondragstart","ondrop","ondurationchange","onemptied","onended","onerror","onfocus","onformdata","oninput","oninvalid","onkeydown","onkeypress","onkeyup","onload","onloadeddata","onloadedmetadata","onloadstart","onmousedown","onmouseenter","onmouseleave","onmousemove","onmouseout","onmouseover","onmouseup","onmousewheel","onpause","onplay","onplaying","onprogress","onratechange","onreset","onresize","onscroll","onsecuritypolicyviolation","onseeked","onseeking","onselect","onslotchange","onstalled","onsubmit","onsuspend","ontimeupdate","ontoggle","onvolumechange","onwaiting","onwebkitanimationend","onwebkitanimationiteration","onwebkitanimationstart","onwebkittransitionend","onwheel","onauxclick","ongotpointercapture","onlostpointercapture","onpointerdown","onpointermove","onpointerrawupdate","onpointerup","onpointercancel","onpointerover","onpointerout","onpointerenter","onpointerleave","onselectstart","onselectionchange","onanimationend","onanimationiteration","onanimationstart","ontransitionrun","ontransitionstart","ontransitionend","ontransitioncancel","onafterprint","onbeforeprint","onbeforeunload","onhashchange","onlanguagechange","onmessage","onmessageerror","onoffline","ononline","onpagehide","onpageshow","onpopstate","onrejectionhandled","onstorage","onunhandledrejection","onunload","ondevicemotion","ondeviceorientation","ondeviceorientationabsolute","onbeforematch","oncontentvisibilityautostatechange"] 18 | ); 19 | 20 | export async function runWithPageAsyncLocalStorage(callback) 21 | { 22 | // 23 | // Our simple static Page.* API is enabled by the 24 | // current document being stored in async local 25 | // storage. The entire dynamic import() of the 26 | // rolled up page generation file is via here. 27 | // 28 | 29 | await asyncLocalStorage.run( 30 | Object.preventExtensions( 31 | { 32 | document: null, 33 | refs: new Map() 34 | }), 35 | callback) 36 | } 37 | 38 | function setDocument(document) 39 | { 40 | asyncLocalStorage.getStore().document = document; 41 | } 42 | 43 | function getDocument(document) 44 | { 45 | return asyncLocalStorage.getStore().document; 46 | } 47 | 48 | function hasAddedClientJs(clientJs) 49 | { 50 | const { thisRender } = getCurrentJob().page; 51 | 52 | return thisRender.inlineJsSet.has(clientJs); 53 | } 54 | 55 | function addClientJs(clientJs) 56 | { 57 | const { thisRender } = getCurrentJob().page; 58 | 59 | // 60 | // Add this JS and remember we did. 61 | // 62 | 63 | thisRender.inlineJsSet.add(clientJs); 64 | thisRender.inlineJs.push(clientJs); 65 | } 66 | 67 | /** 68 | * Async Ref class allowing refs to be safely shared by multiple async scopes. 69 | */ 70 | class Ref 71 | { 72 | constructor() 73 | { 74 | // 75 | // Each async context gets a unique 'this' key in the refs map, 76 | // allowing a single instance of ref to be used by multiple 77 | // async contexts. 78 | // 79 | 80 | const { refs } = asyncLocalStorage.getStore(); 81 | 82 | refs.set(this, {}); 83 | } 84 | 85 | set(element) 86 | { 87 | const { refs } = asyncLocalStorage.getStore(); 88 | 89 | refs.get(this).element = element; 90 | } 91 | 92 | appendJsx(jsx) 93 | { 94 | // 95 | // Set the parent context of jsx to the referenced element, 96 | // then render the jsx and append the result. 97 | // 98 | 99 | const { element } = asyncLocalStorage.getStore().refs.get(this); 100 | 101 | connectContexts(element.context, jsx); 102 | const rendered = renderNow(jsx); 103 | element.appendChild(rendered); 104 | } 105 | } 106 | 107 | /** 108 | * A callback that is passed the current page configuration. 109 | * @callback ConfigureCallback 110 | * @param {object} config 111 | */ 112 | 113 | export const Page = 114 | { 115 | /** 116 | * Alter the default config object for pages generated in this file. 117 | * Can be called between pages if needed. 118 | * @param {ConfigureCallback} callback - Called with the current config object for possible alteration. 119 | */ 120 | Config(callback) 121 | { 122 | callback(getCurrentJob().page.thisBuild.config); 123 | }, 124 | 125 | /** 126 | * Begin construction of a HTML document. 127 | * @param {string} lang - Will be placed in the 'lang' attribute of the html tag. 128 | */ 129 | Create(lang) 130 | { 131 | getCurrentJob().page.thisBuild.onPageCreate(); 132 | 133 | setDocument(new ServerDocument(lang)); 134 | }, 135 | 136 | /** 137 | * Append JSX to the head tag. 138 | * @param {*} child - JSX to be appended to the head tag. 139 | */ 140 | AppendHead(child) 141 | { 142 | getDocument().head.appendChild(renderNow(child)); 143 | }, 144 | 145 | /** 146 | * Append CSS to the common CSS placed before extracted scoped CSS. 147 | * @param {*} css - CSS to be added. 148 | */ 149 | AppendCss(css) 150 | { 151 | getCurrentJob().commonCss += css; 152 | }, 153 | 154 | /** 155 | * Append JSX to the body tag. 156 | * @param {*} child - JSX to be appended to the body tag. 157 | */ 158 | AppendBody(child) 159 | { 160 | getDocument().body.appendChild(renderNow(child)); 161 | }, 162 | 163 | // Page.Memo is not yet ready as it does not yet deal with rendering side effects like scoped CSS classes. 164 | // 165 | // /** 166 | // * In tempalte engine mode, caches the JSX output based on cacheKey. If cacheKey omitted, an attempt is made to generate one from all JavaScript identifiers used within the JSX. 167 | // * @param {*} jsx - JSX to wrap in a cache 168 | // * @param {*} cacheKey - 169 | // * @returns 170 | // */ 171 | // Memo(jsx, cacheKey) 172 | // { 173 | // // Calls to this function are replaced with generated code at build time. 174 | // }, 175 | 176 | /** 177 | * Add one or more JavaScript statement to the page. A statement can be actual Javascript code, or a string containing it. 178 | * @param {...object} jsCode - JavaScript code, or string containing it, to be added 179 | */ 180 | AppendJs(...jsCode) 181 | { 182 | const resultingJs = jsCode.map(semicolonify).join('\n'); 183 | 184 | addClientJs(resultingJs); 185 | }, 186 | 187 | /** 188 | * If it hasn't been added already, add JavaScript code to the page. 189 | * @param {...object} jsCode - JavaScript code to be added. More than one arg is magically converted to one by a babel plugin. 190 | */ 191 | AppendJsIfNew(...jsCode) 192 | { 193 | const resultingJs = jsCode.map(semicolonify).join('\n'); 194 | 195 | if (hasAddedClientJs(resultingJs)) // already added this combination of js before 196 | return; 197 | 198 | addClientJs(resultingJs); 199 | }, 200 | 201 | /** 202 | * Add client JS that invokes function with the supplied arguments. 203 | * @param {function|string} functionName - name of function to invoke in client JS. 204 | * @param {...object} args - zero or more arguments to evaluate at build time and pass the result to the function at client run time. 205 | */ 206 | AppendJsCall(functionName, ...args) 207 | { 208 | if (typeof functionName === 'function') 209 | functionName = functionName.name; 210 | 211 | if (typeof functionName !== 'string') 212 | throw Error(`Argument passed to AppendJsCall is not a string or a named function: ${functionName}`); 213 | 214 | functionName = functionName.trim(); 215 | if (functionName === '') 216 | throw Error(`AppendJsCall functionName is empty`); 217 | 218 | this.AppendJs(`${functionName}(${args.map(arg => JSON.stringify(arg))})`); 219 | }, 220 | 221 | /** 222 | * Allocate an id unique to the page 223 | */ 224 | UniqueId() 225 | { 226 | const { thisBuild, thisRender } = getCurrentJob().page; 227 | 228 | return thisBuild.config.uniquePrefix + convertToAlphaNum(thisRender.nextUniqueId++) + thisBuild.config.uniqueSuffix; 229 | }, 230 | 231 | /** 232 | * EvaluateNow JSX immediately - useful for parents that want children to pass data up to them via context. 233 | * 234 | * Normally, parents are evaluated before their children. 235 | * 236 | * @param {*} jsx - JSX element, or array of, to be rendered 237 | */ 238 | EvaluateNow(jsx) 239 | { 240 | const rendered = renderNow(jsx); 241 | return DeferredElement.From(makeContext(), () => rendered); 242 | }, 243 | 244 | /** 245 | * Create a Ref that can be passed to a JSX element to capture a reference to it. 246 | */ 247 | RefCreate() 248 | { 249 | return new Ref(); 250 | }, 251 | 252 | /** 253 | * Render the HTML page and pass it back to the build process. 254 | * @param {string} [outputFilename] - Override the default name of the generated html file, relative to the default output dir. 255 | */ 256 | async Render(outputFilename) 257 | { 258 | const { page, commonCss, onRenderStart, onRendered, developmentMode, templateEngineMode } = getCurrentJob(); 259 | const document = getDocument(); 260 | 261 | if (outputFilename) 262 | { 263 | if (templateEngineMode) 264 | throw new Error(`Can't specify page filename in template engine mode.`); 265 | 266 | outputFilename = path.join(path.dirname(page.htmlFile), outputFilename); 267 | } 268 | else 269 | outputFilename = page.htmlFile; 270 | 271 | // 272 | // Let the build system know that this page is fully configured. 273 | // At this point we can expect any client JS to be compiled. 274 | // 275 | // NOTHING ASYNC CAN BE SAFELY INVOKED BEFORE onRenderStart() 276 | // 277 | 278 | await onRenderStart(page, outputFilename); 279 | 280 | // 281 | // Now that we can know the output path, we can calculate a relative path 282 | // back to the site root. Any imported assets used by pages or client JS 283 | // need to be resolved relative to this path. 284 | // 285 | 286 | const fullOutputPath = 287 | path.normalize( 288 | path.join( 289 | page.outputRoot, 290 | outputFilename 291 | ) 292 | ); 293 | 294 | const relativeAssetRoot = 295 | path.relative( 296 | path.dirname(fullOutputPath), 297 | page.outputAssetRoot); 298 | 299 | // 300 | // We have our page structure, it's now time to process CSS attributes 301 | // 302 | 303 | const finalCss = 304 | loadCss( 305 | commonCss + page.thisBuild.scopedCssSet.collateAll(), 306 | { 307 | renameVariables: true 308 | }); 309 | 310 | if (finalCss) 311 | // Equivalent to this.AppendHead(); 312 | this.AppendHead( 313 | jsx( 314 | 'style', 315 | { 316 | children: jsx('raw-content', { content: finalCss }) 317 | }) 318 | ); 319 | 320 | // 321 | // Generate ); 359 | this.AppendBody( 360 | jsx( 361 | 'script', 362 | { 363 | children: jsx('raw-content', { content }) 364 | }) 365 | ); 366 | } 367 | 368 | // Make the page relative asset root available to client JS 369 | this.AppendHead( 370 | jsx( 371 | 'raw-content', 372 | { 373 | content: '' 374 | }) 375 | ); 376 | 377 | // 378 | // Render the document to HTML and pass result back 379 | // 380 | 381 | onRendered(document.toHtml({ relativeAssetRoot })); 382 | 383 | setDocument(null); 384 | }, 385 | 386 | //// 387 | 388 | /** 389 | * Get the full path for a path relative to the output directory for this page 390 | */ 391 | GetOutputPath(relativeOutputPath) 392 | { 393 | return path.join(getCurrentJob().page.outputDir, relativeOutputPath); 394 | }, 395 | 396 | /** 397 | * Get the full uri path for a path relative to the output directory for this page 398 | */ 399 | GetOutputUri(relativeOutputPath) 400 | { 401 | return getCurrentJob().page.uriPath + relativeOutputPath.split(path.sep).join('/'); 402 | }, 403 | 404 | //// 405 | 406 | Log(...args) 407 | { 408 | log(...args); 409 | }, 410 | 411 | /** 412 | * Object a named Map that persists between builds, useful for tag content caching. 413 | * @param {*} name 414 | */ 415 | CacheMapGet(name) 416 | { 417 | let cache = interBuildCache.get(name); 418 | 419 | if (!cache) 420 | { 421 | cache = new Map(); 422 | interBuildCache.set(name, cache); 423 | } 424 | 425 | return cache; 426 | }, 427 | 428 | //// 429 | 430 | /** 431 | * Is this a development mode build? 432 | */ 433 | IsDevelopmentMode() 434 | { 435 | return getCurrentJob().developmentMode; 436 | } 437 | }; 438 | 439 | /** WARNING: This internal API is subject to change without notice. */ 440 | export const __nakedjsx_page_internal__ = 441 | { 442 | getMemoCache(cacheId) 443 | { 444 | const memoCaches = getCurrentJob().page.thisBuild.cache.memo; 445 | let cache = memoCaches[cacheId]; 446 | if (cache) 447 | return cache; 448 | 449 | cache = new LruCache(cacheId); 450 | memoCaches[cacheId] = cache; 451 | 452 | return cache; 453 | }, 454 | 455 | memoCacheGet(cacheId, key) 456 | { 457 | const memoCache = this.getMemoCache(cacheId); 458 | return memoCache.get(key); 459 | }, 460 | 461 | memoCacheSet(cacheId, key, value) 462 | { 463 | const memoCache = this.getMemoCache(cacheId); 464 | const cachingHtmlRenderer = new CachingHtmlRenderer(renderNow(value)); 465 | 466 | memoCache.set(key, cachingHtmlRenderer); 467 | 468 | return cachingHtmlRenderer; 469 | } 470 | }; 471 | 472 | /* This export is needed for JSX edge case that will probably never happen to a NakedJSX user: https://github.com/facebook/react/issues/20031#issuecomment-710346866*/ 473 | export function createElement(tag, props, ...children) 474 | { 475 | if (children.length == 1) 476 | { 477 | props.children = children[0]; 478 | return jsx(tag, props); 479 | } 480 | else if (children.length > 1) 481 | { 482 | props.children = children; 483 | return jsxs(tag, props); 484 | } 485 | else 486 | return jsx(tag, props); 487 | } 488 | 489 | export const Fragment = Symbol(); 490 | 491 | /** Injected by the JSX compiler for more than one child */ 492 | export function jsxs(tag, props) 493 | { 494 | if (tag === Fragment) 495 | return props.children; 496 | 497 | // 498 | // Each element has a magical context prop that proxies 499 | // context data from parent elements (when attached). 500 | // 501 | // For this to be useful, parents JSX functions need to execute 502 | // before child tags - otherwise it would be too late 503 | // to provide context data to the child. 504 | // 505 | // The natural order of execution is depth first, so 506 | // we jump through a few hoops to change that. 507 | // 508 | 509 | props.context = makeContext(); 510 | 511 | for (const child of props.children) 512 | if (child instanceof DeferredElement) 513 | child.context._setParent(props.context); 514 | 515 | if (typeof tag === 'function') 516 | return DeferredElement.From(props.context, tagImpl.bind(null, tag, props)); 517 | else 518 | return DeferredElement.From(props.context, createElementImpl.bind(null, tag, props)); 519 | } 520 | 521 | /** Injected by the JSX compiler for zero or one children */ 522 | export function jsx(tag, props) 523 | { 524 | if (tag === Fragment) 525 | return props.children; 526 | 527 | // 528 | // See comment in jsxs() regarding contexts 529 | // 530 | 531 | props.context = makeContext(); 532 | 533 | if (props.children instanceof DeferredElement) 534 | props.children.context._setParent(props.context); 535 | 536 | if (typeof tag === 'function') 537 | return DeferredElement.From(props.context, tagImpl.bind(null, tag, props)); 538 | else 539 | return DeferredElement.From(props.context, createElementImpl.bind(null, tag, props)); 540 | } 541 | 542 | function processHtmlEventHandler(code) 543 | { 544 | const cachedResult = htmlEventHandlerCache.get(code); 545 | if (cachedResult) 546 | return cachedResult; 547 | 548 | const result = 549 | { 550 | unboundIdentifiers: new Set(), 551 | hasJsx: false 552 | } 553 | 554 | const ast = 555 | babel.parse( 556 | code, 557 | { 558 | sourceType: 'script', 559 | allowReturnOutsideFunction: true, 560 | plugins: ['jsx'] 561 | }); 562 | babel.traverse( 563 | ast, 564 | { 565 | Program(nodePath) 566 | { 567 | // 568 | // Babel has helpfully identified the globals. We need these 569 | // so we can prevent whatever they point to from being renamed 570 | // or optimised away. 571 | // 572 | 573 | for (const [ identifier, node ] of Object.entries(nodePath.scope.globals)) 574 | { 575 | if (babel.types.isIdentifier(node)) 576 | result.unboundIdentifiers.add(identifier); 577 | else if (babel.types.isJSXIdentifier(node)) 578 | result.hasJsx = true; 579 | } 580 | } 581 | }); 582 | 583 | return htmlEventHandlerCache.set(code, result); 584 | } 585 | 586 | function tagImpl(tag, props) 587 | { 588 | // Allow tag implementations to assume children is an array 589 | if (!Array.isArray(props.children)) 590 | props.children = [props.children]; 591 | 592 | const deferredRender = tag(props); 593 | connectContexts(props.context, deferredRender); 594 | return renderNow(deferredRender); 595 | } 596 | 597 | function createElementImpl(tag, props) 598 | { 599 | // 600 | // We're dealing with a regular HTML element, not a JSX function 601 | // 602 | 603 | const { scopedCssSet } = getCurrentJob().page.thisBuild; 604 | 605 | const element = Element.From(tag, props.context); 606 | const cssClasses = new Set(); 607 | 608 | for (const [name, value] of Object.entries(props)) 609 | { 610 | if (!value) 611 | continue; 612 | 613 | // skip magic props 614 | if (name === 'children' || name === 'context') 615 | continue; 616 | 617 | const nameLowercase = name.toLowerCase(); 618 | 619 | if (validHtmlEventHandlers.has(nameLowercase)) 620 | { 621 | const { unboundIdentifiers, hasJsx } = processHtmlEventHandler(value); 622 | 623 | // 624 | // TODO: consider deduplicating identical handlers 625 | // 626 | 627 | if (hasJsx) 628 | { 629 | // 630 | // If the event handler contains JSX, we need to 631 | // move the implementation to the client JS 632 | // where the JSX will be compiled. This involves 633 | // generating a unique function name and ensuring 634 | // that it isn't treeshaken away. 635 | // 636 | 637 | const identifier = `__nakedjsx_event_handler${Page.UniqueId()}`; 638 | Page.AppendJs(`window.${identifier} = function(event) { ${value} }`); 639 | element.setAttribute(name, `${identifier}.call(this,event)`); 640 | } 641 | else 642 | { 643 | // 644 | // With no JSX in use, it's smaller to leave the 645 | // event handler where it is. 646 | // 647 | // Each unbound identifier in an event handler must 648 | // be a reference to global scope JS (or a bug). 649 | // 650 | // If it's a reference to something in client JS, 651 | // we need to make sure that it's not tree shaken 652 | // out of the final bundle (it might be the only 653 | // reference). 654 | // 655 | // This approach prevents compressing of the name. 656 | // IDEALLY we'd use the ability of terser to not 657 | // remove specified unused identifiers, compress the 658 | // name, and then update all event handler source 659 | // that references the identifier. A lot of 660 | // complexity for a small compression gain though. 661 | // 662 | 663 | for (const identifier of unboundIdentifiers) 664 | Page.AppendJsIfNew(`window.${identifier} = ${identifier};`); 665 | 666 | element.setAttribute(nameLowercase, value); 667 | } 668 | } 669 | else if (name === 'className') 670 | for (const className of value.split(/[\s]+/)) 671 | cssClasses.add(className); 672 | else if (name === 'css') 673 | cssClasses.add(scopedCssSet.getClassName(value)); 674 | else 675 | element.setAttribute(name, value); 676 | } 677 | 678 | if (cssClasses.size) 679 | element.setAttribute('class', Array.from(cssClasses).join(' ')); 680 | 681 | if (Array.isArray(props.children)) 682 | { 683 | for (const child of props.children) 684 | element.appendChild(renderNow(child)); 685 | } 686 | else if (props.children) 687 | element.appendChild(renderNow(props.children)); 688 | 689 | return element; 690 | } 691 | 692 | class DeferredElement 693 | { 694 | static #pool = []; 695 | 696 | context; 697 | deferredRender; 698 | 699 | static From(context, deferredRender) 700 | { 701 | if (this.#pool.length) 702 | { 703 | const de = this.#pool.pop(); 704 | 705 | de.context = context; 706 | de.deferredRender = deferredRender; 707 | 708 | return de; 709 | } 710 | 711 | return new DeferredElement(context, deferredRender); 712 | } 713 | 714 | constructor(context, deferredRender) 715 | { 716 | this.context = context; 717 | this.deferredRender = deferredRender; 718 | } 719 | 720 | release() 721 | { 722 | DeferredElement.#pool.push(this); 723 | } 724 | } 725 | 726 | function renderNow(deferredRender) 727 | { 728 | if (Array.isArray(deferredRender)) 729 | return deferredRender.map(renderNow) 730 | else if (deferredRender instanceof DeferredElement) 731 | { 732 | const result = deferredRender.deferredRender(); 733 | deferredRender.release(); 734 | return result; 735 | } 736 | else if (deferredRender === undefined || deferredRender === null || deferredRender === false || deferredRender === true) 737 | return undefined; 738 | else if (typeof deferredRender === 'string') 739 | return deferredRender; 740 | else 741 | // Convert anything else, number etc, to a string 742 | return `${deferredRender}` 743 | } 744 | 745 | function makeContext() 746 | { 747 | let parent; 748 | 749 | function _setParent(newParent) 750 | { 751 | parent = newParent; 752 | } 753 | 754 | const context = 755 | new Proxy( 756 | new Map(), 757 | { 758 | set(target, property, value) 759 | { 760 | if (property.startsWith('_')) 761 | throw Error('Cannot set context properties with keys starting with _'); 762 | 763 | target.set(property, value); 764 | 765 | return true; 766 | }, 767 | 768 | get(target, property) 769 | { 770 | if (property === _setParent.name) 771 | return _setParent.bind(null); 772 | 773 | if (target.has(property)) 774 | return target.get(property); 775 | 776 | if (parent) 777 | return parent[property]; 778 | 779 | return undefined; 780 | } 781 | }); 782 | 783 | return context; 784 | } 785 | 786 | function connectContexts(parentContext, deferredRender) 787 | { 788 | if (Array.isArray(deferredRender)) 789 | { 790 | deferredRender.forEach(connectContexts.bind(null, parentContext)); 791 | return; 792 | } 793 | else if (deferredRender instanceof DeferredElement) 794 | { 795 | if (deferredRender.context) // can be null if Page.EvaluateNow is used 796 | deferredRender.context._setParent(parentContext); 797 | 798 | return; 799 | } 800 | } --------------------------------------------------------------------------------