Merge pull request '0.0.3: Exotic Type Gymnastics' (#3) from dev into main
	
		
			
	
		
	
	
		
	
		
			All checks were successful
		
		
	
	
	
				
					
				
			
		
			All checks were successful
		
		
	
	
Reviewed-on: #3
This commit is contained in:
		
						commit
						16bee20e93
					
				|  | @ -1,60 +0,0 @@ | |||
| name: Publish to npm | ||||
| 
 | ||||
| on: | ||||
|   push: | ||||
|     branches: | ||||
|       - main | ||||
|       - dev | ||||
| 
 | ||||
| jobs: | ||||
|   quality: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout repository | ||||
|         uses: actions/checkout@v2 | ||||
|       - name: Set up Node.js | ||||
|         uses: actions/setup-node@v2 | ||||
|         with: | ||||
|           node-version: '22' | ||||
|       - name: Install dependencies | ||||
|         run: npm ci | ||||
|       - name: Run Biome | ||||
|         run: npm run quality-check | ||||
| 
 | ||||
|   publish: | ||||
|     runs-on: ubuntu-latest | ||||
|     needs: quality | ||||
|     steps: | ||||
|       - name: Checkout repository | ||||
|         uses: actions/checkout@v2 | ||||
| 
 | ||||
|       - name: Set up Node.js | ||||
|         uses: actions/setup-node@v2 | ||||
|         with: | ||||
|           node-version: '22' | ||||
|           registry-url: 'https://registry.npmjs.org/' | ||||
| 
 | ||||
|       - name: Install dependencies | ||||
|         run: npm ci | ||||
| 
 | ||||
|       - name: Determine package name | ||||
|         id: package_name | ||||
|         run: | | ||||
|           if [ "${{ gitea.ref }}" == "refs/heads/main" ]; then | ||||
|             echo "PACKAGE_NAME=laterano" >> $GITEA_ENV | ||||
|             echo "ACCESS_LEVEL=public" >> $GITEA_ENV | ||||
|           elif [ "${{ gitea.ref }}" == "refs/heads/dev" ]; then | ||||
|             echo "PACKAGE_NAME=@astrian/laterano-dev" >> $GITEA_ENV | ||||
|             echo "ACCESS_LEVEL=restricted" >> $GITEA_ENV | ||||
|           fi | ||||
| 
 | ||||
|       - name: Update package.json for dev releases | ||||
|         if: GITEA.ref == 'refs/heads/dev' | ||||
|         run: | | ||||
|           jq --arg name "@astrian/laterano-dev" '.name=$name' package.json > temp.json && mv temp.json package.json | ||||
|           jq --arg version "0.0.0-dev.$(date +%s)" '.version=$version' package.json > temp.json && mv temp.json package.json | ||||
| 
 | ||||
|       - name: Publish to npm | ||||
|         run: npm publish --access $ACCESS_LEVEL | ||||
|         env: | ||||
|           NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | ||||
							
								
								
									
										129
									
								
								.gitea/workflows/workflow.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										129
									
								
								.gitea/workflows/workflow.yaml
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,129 @@ | |||
| name: Quality Check & Publish | ||||
| 
 | ||||
| on: | ||||
|   push: | ||||
|     branches: | ||||
|       - '*' | ||||
| 
 | ||||
| jobs: | ||||
|   quality: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout repository | ||||
|         uses: actions/checkout@v2 | ||||
|       - name: Set up Node.js | ||||
|         uses: actions/setup-node@v2 | ||||
|         with: | ||||
|           node-version: '22' | ||||
|       - name: Install dependencies | ||||
|         run: npm ci | ||||
|       - name: Run Biome | ||||
|         run: npm run quality-check | ||||
|     if: always() | ||||
|     outputs: | ||||
|       status: ${{ job.status }} | ||||
|      | ||||
|   quality-failed-webhook: | ||||
|     needs: quality | ||||
|     runs-on: ubuntu-latest | ||||
|     if: ${{ needs.quality.outputs.status == 'failure' }} | ||||
|     steps: | ||||
|       - name: Send webhook | ||||
|         run: | | ||||
|           QUALITY_CHECK_GROUP="Laterano CI/CD" | ||||
|           QUALITY_CHECK_TITLE="Quality Check Failed" | ||||
|           QUALITY_CHECK_MESSAGE="Quality check failed for commit ${{ gitea.sha }} in ${{ gitea.repository }}" | ||||
|            | ||||
|           # URL-encode the message | ||||
|           ENCODED_GROUP=$(echo "$MSG_GROUP" sed 's/%/%25/g; s/ /%20/g; s/\//%2F/g; s/\?/%3F/g; s/&/%26/g') | ||||
|           ENCODED_TITLE=$(echo "$QUALITY_CHECK_TITLE" | sed 's/%/%25/g' | sed 's/ /%20/g' | sed 's/!/%21/g' | sed 's/"/%22/g' | sed 's/#/%23/g' | sed 's/\$/%24/g' | sed 's/&/%26/g' | sed 's/'"'"'/%27/g' | sed 's/(/%28/g' | sed 's/)/%29/g' | sed 's/\*/%2A/g' | sed 's/+/%2B/g' | sed 's/,/%2C/g' | sed 's/\//%2F/g' | sed 's/:/%3A/g' | sed 's/;/%3B/g' | sed 's/=/%3D/g' | sed 's/?/%3F/g' | sed 's/@/%40/g') | ||||
|           ENCODED_MESSAGE=$(echo "$QUALITY_CHECK_MESSAGE" | sed 's/%/%25/g' | sed 's/ /%20/g' | sed 's/!/%21/g' | sed 's/"/%22/g' | sed 's/#/%23/g' | sed 's/\$/%24/g' | sed 's/&/%26/g' | sed 's/'"'"'/%27/g' | sed 's/(/%28/g' | sed 's/)/%29/g' | sed 's/\*/%2A/g' | sed 's/+/%2B/g' | sed 's/,/%2C/g' | sed 's/\//%2F/g' | sed 's/:/%3A/g' | sed 's/;/%3B/g' | sed 's/=/%3D/g' | sed 's/?/%3F/g' | sed 's/@/%40/g') | ||||
|            | ||||
|           echo "Webhook URL:" | ||||
|           echo "https://bark.nas.astrian.moe/${{ secrets.BARK_TOKEN }}/${ENCODED_TITLE}/${ENCODED_MESSAGE}?group=${ENCODED_GROUP}" | ||||
| 
 | ||||
|           curl -X GET "https://bark.nas.astrian.moe/${{ secrets.BARK_TOKEN }}/${ENCODED_TITLE}/${ENCODED_MESSAGE}?group=${ENCODED_GROUP}"           | ||||
| 
 | ||||
|   publish: | ||||
|     runs-on: ubuntu-latest | ||||
|     needs: quality | ||||
|     if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' }} | ||||
|     steps: | ||||
|       - name: Checkout repository | ||||
|         uses: actions/checkout@v2 | ||||
| 
 | ||||
|       - name: Set up Node.js | ||||
|         uses: actions/setup-node@v2 | ||||
|         with: | ||||
|           node-version: '22' | ||||
|           registry-url: 'https://registry.npmjs.org/' | ||||
| 
 | ||||
|       - name: Install dependencies | ||||
|         run: npm ci | ||||
| 
 | ||||
|       - name: Determine package name | ||||
|         id: package_name | ||||
|         run: | | ||||
|           if [ "${{ gitea.ref }}" == "refs/heads/main" ]; then | ||||
|             echo "PACKAGE_NAME=laterano" >> $GITEA_ENV | ||||
|             echo "ACCESS_LEVEL=public" >> $GITEA_ENV | ||||
|           elif [ "${{ gitea.ref }}" == "refs/heads/dev" ]; then | ||||
|             echo "PACKAGE_NAME=@astrian/laterano-dev" >> $GITEA_ENV | ||||
|             echo "ACCESS_LEVEL=restricted" >> $GITEA_ENV | ||||
|           fi           | ||||
| 
 | ||||
|       - name: Update package.json for dev releases | ||||
|         if: GITEA.ref == 'refs/heads/dev' | ||||
|         run: | | ||||
|           jq --arg name "@astrian/laterano-dev" '.name=$name' package.json > temp.json && mv temp.json package.json | ||||
|           jq --arg version "0.0.0-dev.$(date +%s)" '.version=$version' package.json > temp.json && mv temp.json package.json | ||||
|           echo VERSION_CODE=$(jq -r '.version' package.json) >> $GITEA_ENV | ||||
|           echo PACKAGE_NAME="@astrian/laterano-dev" >> $GITEA_ENV           | ||||
| 
 | ||||
|       - name: Get version code | ||||
|         if: GITEA.ref == 'refs/heads/main' | ||||
|         run: | | ||||
|           echo VERSION_CODE=$(jq -r '.version' package.json) >> $GITEA_ENV | ||||
|           echo PACKAGE_NAME="laterano" >> $GITEA_ENV           | ||||
| 
 | ||||
|       - name: Publish to npm | ||||
|         run: npm publish --access $ACCESS_LEVEL | ||||
|         env: | ||||
|           NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | ||||
|    | ||||
|       - name: Send webhook | ||||
|         run: | | ||||
|           PACKAGE_NAME="${{ steps.package_name.outputs.PACKAGE_NAME }}" | ||||
|           MSG_GROUP="Laterano CI/CD" | ||||
|           MSG_TITLE="Package Published" | ||||
|           MSG_MESSAGE="Branch ${{ gitea.ref }}: published to npm with version $VERSION_CODE" | ||||
|            | ||||
|           # URL-encode the message | ||||
|           ENCODED_MESSAGE=$(echo "$MSG_MESSAGE" | sed 's/%/%25/g' | sed 's/ /%20/g' | sed 's/!/%21/g' | sed 's/"/%22/g' | sed 's/#/%23/g' | sed 's/\$/%24/g' | sed 's/&/%26/g' | sed 's/'"'"'/%27/g' | sed 's/(/%28/g' | sed 's/)/%29/g' | sed 's/\*/%2A/g' | sed 's/+/%2B/g' | sed 's/,/%2C/g' | sed 's/\//%2F/g' | sed 's/:/%3A/g' | sed 's/;/%3B/g' | sed 's/=/%3D/g' | sed 's/?/%3F/g' | sed 's/@/%40/g') | ||||
|           ENCODED_GROUP=$(echo "$MSG_GROUP" | sed 's/%/%25/g' | sed 's/ /%20/g' | sed 's/!/%21/g' | sed 's/"/%22/g' | sed 's/#/%23/g' | sed 's/\$/%24/g' | sed 's/&/%26/g' | sed 's/'"'"'/%27/g' | sed 's/(/%28/g' | sed 's/)/%29/g' | sed 's/\*/%2A/g' | sed 's/+/%2B/g' | sed 's/,/%2C/g' | sed 's/\//%2F/g' | sed 's/:/%3A/g' | sed 's/;/%3B/g' | sed 's/=/%3D/g' | sed 's/?/%3F/g' | sed 's/@/%40/g') | ||||
|           ENCODED_TITLE=$(echo "$MSG_TITLE" | sed 's/%/%25/g' | sed 's/ /%20/g' | sed 's/!/%21/g' | sed 's/"/%22/g' | sed 's/#/%23/g' | sed 's/\$/%24/g' | sed 's/&/%26/g' | sed 's/'"'"'/%27/g' | sed 's/(/%28/g' | sed 's/)/%29/g' | sed 's/\*/%2A/g' | sed 's/+/%2B/g' | sed 's/,/%2C/g' | sed 's/\//%2F/g' | sed 's/:/%3A/g' | sed 's/;/%3B/g' | sed 's/=/%3D/g' | sed 's/?/%3F/g' | sed 's/@/%40/g') | ||||
|            | ||||
|           echo "Webhook URL:" | ||||
|           echo "https://bark.nas.astrian.moe/${{ secrets.BARK_TOKEN }}/${ENCODED_TITLE}/${ENCODED_MESSAGE}?group=${ENCODED_GROUP}" | ||||
| 
 | ||||
|           curl -X GET "https://bark.nas.astrian.moe/${{ secrets.BARK_TOKEN }}/${ENCODED_TITLE}/${ENCODED_MESSAGE}?group=${ENCODED_GROUP}"           | ||||
| 
 | ||||
|   publish-failed-webhook: | ||||
|     needs: publish | ||||
|     runs-on: ubuntu-latest | ||||
|     if: ${{ needs.publish.outputs.status == 'failure' }} | ||||
|     steps: | ||||
|       - name: Send webhook | ||||
|         run: | | ||||
|           PACKAGE_NAME="${{ steps.package_name.outputs.PACKAGE_NAME }}" | ||||
|           MSG_GROUP="Laterano CI/CD" | ||||
|           MSG_TITLE="Package Publish Failed" | ||||
|           MSG_MESSAGE="Package $PACKAGE_NAME failed to publish to npm with version $VERSION_CODE" | ||||
|            | ||||
|           # URL-encode the message | ||||
|           ENCODED_MESSAGE=$(echo "$MSG_MESSAGE" | sed 's/%/%25/g' | sed 's/ /%20/g' | sed 's/!/%21/g' | sed 's/"/%22/g' | sed 's/#/%23/g' | sed 's/\$/%24/g' | sed 's/&/%26/g' | sed 's/'"'"'/%27/g' | sed 's/(/%28/g' | sed 's/)/%29/g' | sed 's/\*/%2A/g' | sed 's/+/%2B/g' | sed 's/,/%2C/g' | sed 's/\//%2F/g' | sed 's/:/%3A/g' | sed 's/;/%3B/g' | sed 's/=/%3D/g' | sed 's/?/%3F/g' | sed 's/@/%40/g') | ||||
|           ENCODED_GROUP=$(echo "$MSG_GROUP" | sed 's/%/%25/g' | sed 's/ /%20/g' | sed 's/!/%21/g' | sed 's/"/%22/g' | sed 's/#/%23/g' | sed 's/\$/%24/g' | sed 's/&/%26/g' | sed 's/'"'"'/%27/g' | sed 's/(/%28/g' | sed 's/)/%29/g' | sed 's/\*/%2A/g' | sed 's/+/%2B/g' | sed 's/,/%2C/g' | sed 's/\//%2F/g' | sed 's/:/%3A/g' | sed 's/;/%3B/g' | sed 's/=/%3D/g' | sed 's/?/%3F/g' | sed 's/@/%40/g') | ||||
|           ENCODED_TITLE=$(echo "$MSG_TITLE" | sed 's/%/%25/g' | sed 's/ /%20/g' | sed 's/!/%21/g' | sed 's/"/%22/g' | sed 's/#/%23/g' | sed 's/\$/%24/g' | sed 's/&/%26/g' | sed 's/'"'"'/%27/g' | sed 's/(/%28/g' | sed 's/)/%29/g' | sed 's/\*/%2A/g' | sed 's/+/%2B/g' | sed 's/,/%2C/g' | sed 's/\//%2F/g' | sed 's/:/%3A/g' | sed 's/;/%3B/g' | sed 's/=/%3D/g' | sed 's/?/%3F/g' | sed 's/@/%40/g') | ||||
|           echo "Webhook URL:" | ||||
|           echo "https://bark.nas.astrian.moe/${{ secrets.BARK_TOKEN }}/${ENCODED_TITLE}/${ENCODED_MESSAGE}?group=${ENCODED_GROUP}" | ||||
|           curl -X GET "https://bark.nas.astrian.moe/${{ secrets.BARK_TOKEN }}/${ENCODED_TITLE}/${ENCODED_MESSAGE}?group=${ENCODED_GROUP}"           | ||||
							
								
								
									
										24
									
								
								.github/workflows/workflow.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								.github/workflows/workflow.yaml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -0,0 +1,24 @@ | |||
| name: Quality Check | ||||
| 
 | ||||
| on: | ||||
|   push: | ||||
|     branches: | ||||
|       - '*' | ||||
| 
 | ||||
| jobs: | ||||
|   quality: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout repository | ||||
|         uses: actions/checkout@v2 | ||||
|       - name: Set up Node.js | ||||
|         uses: actions/setup-node@v2 | ||||
|         with: | ||||
|           node-version: '22' | ||||
|       - name: Install dependencies | ||||
|         run: npm ci | ||||
|       - name: Run Biome | ||||
|         run: npm run quality-check | ||||
|     if: always() | ||||
|     outputs: | ||||
|       status: ${{ job.status }} | ||||
							
								
								
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							|  | @ -110,4 +110,8 @@ tmp/ | |||
| temp/ | ||||
| 
 | ||||
| # macOS Finder metadata files | ||||
| .DS_Store | ||||
| .DS_Store | ||||
| 
 | ||||
| # vim file | ||||
| *.swp | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										8638
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8638
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										10
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								package.json
									
									
									
									
									
								
							|  | @ -1,14 +1,16 @@ | |||
| { | ||||
| 	"name": "laterano", | ||||
| 	"version": "0.0.2", | ||||
| 	"version": "0.0.3", | ||||
| 	"main": "dist/main.min.js", | ||||
| 	"types": "dist/types.d.ts", | ||||
| 	"types": "dist/types/main.d.ts", | ||||
| 	"module": "dist/main.min.js", | ||||
| 	"scripts": { | ||||
| 		"build": "tsc && rollup -c && npm run cleanup-intermediate", | ||||
| 		"prepare": "npm run build", | ||||
| 		"cleanup-intermediate": "rimraf dist/main.js dist/types", | ||||
| 		"quality-check": "biome ci ." | ||||
| 		"cleanup-intermediate": "rimraf dist/main.js dist/utils", | ||||
| 		"quality-check": "biome ci .", | ||||
| 		"qc": "npm run quality-check", | ||||
| 		"lint": "biome format . --write" | ||||
| 	}, | ||||
| 	"repository": { | ||||
| 		"type": "git", | ||||
|  |  | |||
|  | @ -5,7 +5,7 @@ import dts from 'rollup-plugin-dts' | |||
| 
 | ||||
| export default [ | ||||
| 	{ | ||||
| 		input: 'dist/main.js', | ||||
| 		input: 'src/main.ts', | ||||
| 		output: [ | ||||
| 			{ | ||||
| 				file: 'dist/main.min.js', | ||||
|  | @ -15,10 +15,20 @@ export default [ | |||
| 		], | ||||
| 		plugins: [resolve(), typescript()], | ||||
| 	}, | ||||
| 	{ | ||||
| 		input: 'dist/utils/index.js', | ||||
| 		output: { | ||||
| 			file: 'dist/utils.bundle.min.js', | ||||
| 			format: 'esm', | ||||
| 			inlineDynamicImports: true, | ||||
| 			plugins: [terser()], | ||||
| 		}, | ||||
| 		plugins: [resolve(), typescript({ outDir: 'dist' })], | ||||
| 	}, | ||||
| 	{ | ||||
| 		input: 'dist/types/main.d.ts', | ||||
| 		output: { | ||||
| 			file: 'dist/types.d.ts', | ||||
| 			file: 'dist/types/main.d.ts', | ||||
| 			format: 'es', | ||||
| 		}, | ||||
| 		plugins: [dts()], | ||||
|  |  | |||
							
								
								
									
										927
									
								
								src/main.ts
									
									
									
									
									
								
							
							
						
						
									
										927
									
								
								src/main.ts
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										13
									
								
								src/utils/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/utils/index.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,13 @@ | |||
| import initState from './initState' | ||||
| import parseTemplate from './parseTemplate' | ||||
| import processTemplateMacros from './processTemplateMarcos' | ||||
| import setupArrowFunctionHandler from './setupArrowFunctionHandler' | ||||
| import triggerDomUpdates from './triggerDomUpdates' | ||||
| 
 | ||||
| export default { | ||||
| 	parseTemplate, | ||||
| 	processTemplateMacros, | ||||
| 	setupArrowFunctionHandler, | ||||
| 	triggerDomUpdates, | ||||
| 	initState, | ||||
| } | ||||
							
								
								
									
										102
									
								
								src/utils/initState.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										102
									
								
								src/utils/initState.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,102 @@ | |||
| import triggerDomUpdates from './triggerDomUpdates' | ||||
| 
 | ||||
| export default function initState( | ||||
| 	ops: { | ||||
| 		stateToElementsMap: Record<string, Set<HTMLElement>> | ||||
| 		textBindings: Array<{ | ||||
| 			node: Text | ||||
| 			expr: string | ||||
| 			originalContent: string | ||||
| 		}> | ||||
| 		attributeBindings: Array<{ | ||||
| 			element: Element | ||||
| 			attrName: string | ||||
| 			expr: string | ||||
| 			template: string | ||||
| 		}> | ||||
| 		updateTextNode: (node: Text, value: string) => void | ||||
| 		getNestedState: (keyPath: string) => unknown | ||||
| 		scheduleUpdate: (elements: Set<HTMLElement>) => void | ||||
| 		conditionalElements: Map< | ||||
| 			Element, | ||||
| 			{ | ||||
| 				expr: string | ||||
| 				placeholder: Comment | ||||
| 				isPresent: boolean | ||||
| 			} | ||||
| 		> | ||||
| 		evaluateIfCondition: (element: Element, expr: string) => void | ||||
| 		currentRenderingElement?: HTMLElement | ||||
| 		statesListenersSelf: Record<string, (...args: unknown[]) => void> | ||||
| 	}, | ||||
| 	states?: Record<string, unknown>, | ||||
| 	statesListeners?: { [key: string]: (value: unknown) => void } | undefined, | ||||
| ) { | ||||
| 	console.log(states) | ||||
| 	// copy state from options
 | ||||
| 	return new Proxy( | ||||
| 		{ ...(states || {}) }, | ||||
| 		{ | ||||
| 			set: ( | ||||
| 				target: Record<string, unknown>, | ||||
| 				keyPath: string, | ||||
| 				value: unknown, | ||||
| 			) => { | ||||
| 				const valueRoute = keyPath.split('.') | ||||
| 				let currentTarget = target | ||||
| 				for (const i in valueRoute) { | ||||
| 					const key = valueRoute[i] | ||||
| 					if (Number.parseInt(i) === valueRoute.length - 1) { | ||||
| 						currentTarget[key] = value | ||||
| 					} else { | ||||
| 						if (!currentTarget[key]) currentTarget[key] = {} | ||||
| 						currentTarget = currentTarget[key] as Record<string, unknown> | ||||
| 					} | ||||
| 				} | ||||
| 				// trigger dom updates
 | ||||
| 				triggerDomUpdates(keyPath, { | ||||
| 					stateToElementsMap: ops.stateToElementsMap, | ||||
| 					textBindings: ops.textBindings, | ||||
| 					attributeBindings: ops.attributeBindings, | ||||
| 					updateTextNode: ops.updateTextNode, | ||||
| 					getNestedState: ops.getNestedState, | ||||
| 					scheduleUpdate: ops.scheduleUpdate, | ||||
| 				}) | ||||
| 				if (ops.statesListenersSelf[keyPath]) | ||||
| 					ops.statesListenersSelf[keyPath](value) | ||||
| 
 | ||||
| 				// trigger %if macros
 | ||||
| 				if (ops.conditionalElements.size > 0) | ||||
| 					ops.conditionalElements.forEach((info, element) => { | ||||
| 						if (info.expr.includes(keyPath)) | ||||
| 							ops.evaluateIfCondition(element, info.expr) | ||||
| 					}) | ||||
| 
 | ||||
| 				// trigger state update events
 | ||||
| 				statesListeners?.[keyPath]?.(value) | ||||
| 
 | ||||
| 				return true | ||||
| 			}, | ||||
| 			get: (target: Record<string, unknown>, keyPath: string) => { | ||||
| 				// collect state dependencies
 | ||||
| 				if (ops.currentRenderingElement) { | ||||
| 					if (!ops.stateToElementsMap[keyPath]) | ||||
| 						ops.stateToElementsMap[keyPath] = new Set() | ||||
| 					ops.stateToElementsMap[keyPath].add(ops.currentRenderingElement) | ||||
| 				} | ||||
| 
 | ||||
| 				const valueRoute = keyPath.split('.') | ||||
| 				let currentTarget = target | ||||
| 				for (const i in valueRoute) { | ||||
| 					const key = valueRoute[i] | ||||
| 					if (Number.parseInt(i) === valueRoute.length - 1) | ||||
| 						return currentTarget[key] | ||||
| 
 | ||||
| 					if (!currentTarget[key]) currentTarget[key] = {} | ||||
| 					currentTarget = currentTarget[key] as Record<string, unknown> | ||||
| 				} | ||||
| 				return undefined | ||||
| 			}, | ||||
| 		}, | ||||
| 	) | ||||
| } | ||||
							
								
								
									
										16
									
								
								src/utils/parseTemplate.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/utils/parseTemplate.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,16 @@ | |||
| export default function parseTemplate(template: string): Element { | ||||
| 	const parser = new DOMParser() | ||||
| 	const doc = parser.parseFromString(template, 'text/html') | ||||
| 
 | ||||
| 	const mainContent = doc.body.firstElementChild | ||||
| 	let rootElement: Element | ||||
| 
 | ||||
| 	if (mainContent) rootElement = document.importNode(mainContent, true) | ||||
| 	else { | ||||
| 		const container = document.createElement('div') | ||||
| 		container.innerHTML = template | ||||
| 		rootElement = container | ||||
| 	} | ||||
| 
 | ||||
| 	return rootElement | ||||
| } | ||||
							
								
								
									
										755
									
								
								src/utils/processTemplateMarcos.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										755
									
								
								src/utils/processTemplateMarcos.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,755 @@ | |||
| import setupArrowFunctionHandler from './setupArrowFunctionHandler' | ||||
| 
 | ||||
| interface CustomElement extends HTMLElement { | ||||
| 	setState(key_path: string, value: unknown): void | ||||
| 	getState(key_path: string): unknown | ||||
| } | ||||
| 
 | ||||
| interface ListRenderingContext { | ||||
| 	states: Record<string, unknown> | ||||
| 	stateToElementsMap: Record<string, Set<HTMLElement>> | ||||
| 	statesListeners: Record<string, (value: unknown) => void> | ||||
| 	setState: (keyPath: string, value: unknown) => void | ||||
| 	getState: (keyPath: string) => unknown | ||||
| 	triggerFunc: (eventName: string, ...args: unknown[]) => void | ||||
| } | ||||
| 
 | ||||
| export default function processTemplateMacros( | ||||
| 	element: Element, | ||||
| 	context: CustomElement, | ||||
| 	options: { | ||||
| 		updateTextNode: (node: Text, expr: string, originalContent: string) => void | ||||
| 		setupAttributeBinding: ( | ||||
| 			element: Element, | ||||
| 			attrName: string, | ||||
| 			expr: string, | ||||
| 			attrValue: string, | ||||
| 		) => void | ||||
| 		setupFunctionCallHandler: ( | ||||
| 			element: Element, | ||||
| 			eventName: string, | ||||
| 			handlerValue: string, | ||||
| 		) => void | ||||
| 		setupExpressionHandler: ( | ||||
| 			element: Element, | ||||
| 			eventName: string, | ||||
| 			handlerValue: string, | ||||
| 		) => void | ||||
| 		stateToElementsMap: Record<string, Set<HTMLElement>> | ||||
| 		textBindings: { | ||||
| 			node: Text | ||||
| 			expr: string | ||||
| 			originalContent: string | ||||
| 		}[] | ||||
| 		availableFuncs: string[] | ||||
| 		stateListeners: Record<string, (newValue: unknown) => void> | ||||
| 		conditionalElements: Map< | ||||
| 			Element, | ||||
| 			{ expr: string; placeholder: Comment; isPresent: boolean } | ||||
| 		> | ||||
| 		evaluateIfCondition: (element: Element, expr: string) => void | ||||
| 		extractStatePathsFromExpression: (expr: string) => string[] | ||||
| 		states: Record<string, unknown> | ||||
| 		triggerFunc: (eventName: string, ...args: unknown[]) => void | ||||
| 	}, | ||||
| ) { | ||||
| 	/* | ||||
| 	 * We define that those prefix are available as macros: | ||||
| 	 * - @ means event binding macro, such as @click="handleClick" | ||||
| 	 * - : means dynamic attribute macro, such as :src="imageUrl" | ||||
| 	 * - % means component controlling macro, such as %if="condition", %for="item in items" and %connect="stateName" | ||||
| 	 */ | ||||
| 
 | ||||
| 	// Traverse all child nodes, including text nodes
 | ||||
| 	const walker = document.createTreeWalker( | ||||
| 		element, | ||||
| 		NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, | ||||
| 		null, | ||||
| 	) | ||||
| 
 | ||||
| 	// Store nodes and expressions that need to be updated
 | ||||
| 	const textBindings: Array<{ | ||||
| 		node: Text | ||||
| 		expr: string | ||||
| 		originalContent: string | ||||
| 	}> = [] | ||||
| 	const ifDirectivesToProcess: Array<{ element: Element; expr: string }> = [] | ||||
| 
 | ||||
| 	// Traverse the DOM tree
 | ||||
| 	let currentNode: Node | null | ||||
| 	let flag = true | ||||
| 	while (flag) { | ||||
| 		currentNode = walker.nextNode() | ||||
| 		if (!currentNode) { | ||||
| 			flag = false | ||||
| 			break | ||||
| 		} | ||||
| 
 | ||||
| 		// Handle text nodes
 | ||||
| 		if (currentNode.nodeType === Node.TEXT_NODE) { | ||||
| 			const textContent = currentNode.textContent || '' | ||||
| 			const textNode = currentNode as Text | ||||
| 
 | ||||
| 			// Check if it contains Handlebars expressions {{ xxx }}
 | ||||
| 			if (textContent.includes('{{')) { | ||||
| 				// Save the original content, including expressions
 | ||||
| 				const originalContent = textContent | ||||
| 
 | ||||
| 				// Record nodes and expressions that need to be updated
 | ||||
| 				const matches = textContent.match(/\{\{\s*([^}]+)\s*\}\}/g) | ||||
| 				if (matches) { | ||||
| 					for (const match of matches) { | ||||
| 						// Extract the expression content, removing {{ }} and spaces
 | ||||
| 						const expr = match.replace(/\{\{\s*|\s*\}\}/g, '').trim() | ||||
| 
 | ||||
| 						// Store the node, expression, and original content for later updates
 | ||||
| 						textBindings.push({ node: textNode, expr, originalContent }) | ||||
| 
 | ||||
| 						// Set the initial value
 | ||||
| 						options.updateTextNode(textNode, expr, originalContent) | ||||
| 
 | ||||
| 						// Add dependency relationship for this state path
 | ||||
| 						if (!options.stateToElementsMap[expr]) | ||||
| 							options.stateToElementsMap[expr] = new Set() | ||||
| 
 | ||||
| 						options.stateToElementsMap[expr].add( | ||||
| 							textNode as unknown as HTMLElement, | ||||
| 						) | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		// Handle element nodes (can extend to handle attribute bindings, etc.)
 | ||||
| 		else if (currentNode.nodeType === Node.ELEMENT_NODE) { | ||||
| 			const currentElementNode = currentNode as Element // Renamed to avoid conflict with outer 'element'
 | ||||
| 
 | ||||
| 			// Traverse all macro attributes
 | ||||
| 
 | ||||
| 			// Detect :attr="" bindings, such as :src="imageUrl"
 | ||||
| 			for (const attr of Array.from(currentElementNode.attributes)) { | ||||
| 				if (attr.name.startsWith(':')) { | ||||
| 					const attrName = attr.name.substring(1) // Remove ':'
 | ||||
| 					const expr = attr.value.trim() | ||||
| 
 | ||||
| 					// Remove the attribute, as it is not a standard HTML attribute
 | ||||
| 					currentElementNode.removeAttribute(attr.name) | ||||
| 
 | ||||
| 					// Set up attribute binding
 | ||||
| 					options.setupAttributeBinding( | ||||
| 						currentElementNode, | ||||
| 						attrName, | ||||
| 						expr, | ||||
| 						attr.value, | ||||
| 					) | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			// Process @event bindings, such as @click="handleClick"
 | ||||
| 			const eventBindings = Array.from(currentElementNode.attributes).filter( | ||||
| 				(attr) => attr.name.startsWith('@'), | ||||
| 			) | ||||
| 			// eventBindings.forEach((attr) => {
 | ||||
| 			for (const attr of eventBindings) { | ||||
| 				const eventName = attr.name.substring(1) // Remove '@'
 | ||||
| 				const handlerValue = attr.value.trim() | ||||
| 
 | ||||
| 				// Remove the attribute, as it is not a standard HTML attribute
 | ||||
| 				currentElementNode.removeAttribute(attr.name) | ||||
| 
 | ||||
| 				// Handle different types of event handlers
 | ||||
| 				if (handlerValue.includes('=>')) { | ||||
| 					// Handle arrow function: @click="e => setState('count', count + 1)"
 | ||||
| 					setupArrowFunctionHandler( | ||||
| 						currentElementNode, | ||||
| 						eventName, | ||||
| 						handlerValue, | ||||
| 						{ | ||||
| 							createHandlerContext: (_event: Event, _element: Element) => ({ | ||||
| 								states: options.states, | ||||
| 								stateToElementsMap: options.stateToElementsMap, | ||||
| 								statesListeners: options.stateListeners, | ||||
| 								setState: context.setState.bind(context), | ||||
| 								getState: context.getState.bind(context), | ||||
| 								triggerFunc: options.triggerFunc.bind(context), | ||||
| 							}), | ||||
| 						}, | ||||
| 					) | ||||
| 				} else if (handlerValue.includes('(') && handlerValue.includes(')')) { | ||||
| 					// Handle function call: @click="increment(5)"
 | ||||
| 					options.setupFunctionCallHandler( | ||||
| 						currentElementNode, | ||||
| 						eventName, | ||||
| 						handlerValue, | ||||
| 					) | ||||
| 				} else if ( | ||||
| 					options.availableFuncs.includes(handlerValue) && | ||||
| 					typeof (context as unknown as Record<string, unknown>)[ | ||||
| 						handlerValue | ||||
| 					] === 'function' | ||||
| 				) { | ||||
| 					// Handle method reference: @click="handleClick"
 | ||||
| 					currentElementNode.addEventListener( | ||||
| 						eventName, | ||||
| 						( | ||||
| 							context as unknown as Record<string, (...args: unknown[]) => void> | ||||
| 						)[handlerValue].bind(context), | ||||
| 					) | ||||
| 				} else { | ||||
| 					// Handle simple expression: @click="count++" or @input="name = $event.target.value"
 | ||||
| 					options.setupExpressionHandler( | ||||
| 						currentElementNode, | ||||
| 						eventName, | ||||
| 						handlerValue, | ||||
| 					) | ||||
| 				} | ||||
| 			} | ||||
| 
 | ||||
| 			// Process %-started macros, such as %connect="stateName", %if="condition", %for="item in items"
 | ||||
| 			const macroBindings = Array.from(currentElementNode.attributes).filter( | ||||
| 				(attr) => attr.name.startsWith('%'), | ||||
| 			) | ||||
| 
 | ||||
| 			// macroBindings.forEach((attr) => {
 | ||||
| 			for (const attr of macroBindings) { | ||||
| 				const macroName = attr.name.substring(1) // Remove '%'
 | ||||
| 				const expr = attr.value.trim() | ||||
| 
 | ||||
| 				// Remove the attribute, as it is not a standard HTML attribute
 | ||||
| 				currentElementNode.removeAttribute(attr.name) | ||||
| 
 | ||||
| 				// Handle different types of macros
 | ||||
| 				if (macroName === 'connect') | ||||
| 					// Handle state connection: %connect="stateName"
 | ||||
| 					setupTwoWayBinding(currentElementNode, expr, { | ||||
| 						getNestedState: context.getState.bind(context), | ||||
| 						setState: context.setState.bind(context), | ||||
| 						statesListeners: options.stateListeners, | ||||
| 					}) | ||||
| 				else if (macroName === 'if') { | ||||
| 					ifDirectivesToProcess.push({ element: currentElementNode, expr }) | ||||
| 				} else if (macroName === 'for') { | ||||
| 					const listContext: ListRenderingContext = { | ||||
| 						states: options.states, | ||||
| 						stateToElementsMap: options.stateToElementsMap, | ||||
| 						statesListeners: options.stateListeners, | ||||
| 						setState: context.setState.bind(context), | ||||
| 						getState: context.getState.bind(context), | ||||
| 						triggerFunc: options.triggerFunc.bind(context), | ||||
| 					} | ||||
| 					setupListRendering(currentElementNode, expr, listContext) | ||||
| 				} else if (macroName === 'key') continue | ||||
| 				else console.warn(`Unknown macro: %${macroName}`) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// Save text binding relationships for updates
 | ||||
| 	options.textBindings = textBindings | ||||
| 
 | ||||
| 	// Process all collected %if directives after the main traversal
 | ||||
| 	for (const { element: ifElement, expr } of ifDirectivesToProcess) { | ||||
| 		setupConditionRendering(ifElement, expr, { | ||||
| 			conditionalElements: options.conditionalElements, | ||||
| 			evaluateIfCondition: options.evaluateIfCondition.bind(context), | ||||
| 			extractStatePathsFromExpression: options.extractStatePathsFromExpression, | ||||
| 			stateToElementsMap: options.stateToElementsMap, | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // Handle two-way data binding (%connect macro)
 | ||||
| function setupTwoWayBinding( | ||||
| 	element: Element, | ||||
| 	expr: string, | ||||
| 	ops: { | ||||
| 		getNestedState: (path: string) => unknown | ||||
| 		setState: (path: string, value: unknown) => void | ||||
| 		statesListeners: Record<string, (newValue: unknown) => void> | ||||
| 	}, | ||||
| ) { | ||||
| 	// Get the initial value
 | ||||
| 	const value = ops.getNestedState(expr) | ||||
| 
 | ||||
| 	// Set the initial value
 | ||||
| 	if (value !== undefined) | ||||
| 		element.setAttribute('data-laterano-connect', String(value)) | ||||
| 	else | ||||
| 		console.error( | ||||
| 			`State \`${expr}\` not found in the component state. Although Laterano will try to work with it, it may has potentially unexpected behavior.`, | ||||
| 		) | ||||
| 
 | ||||
| 	// Add event listener for input events
 | ||||
| 	element.addEventListener('input', (event: Event) => { | ||||
| 		const target = event.target as HTMLInputElement | ||||
| 		const newValue = target.value | ||||
| 
 | ||||
| 		// Update the state
 | ||||
| 		ops.setState(expr, newValue) | ||||
| 	}) | ||||
| 
 | ||||
| 	// Add event listener for state changes
 | ||||
| 	ops.statesListeners[expr] = (newValue: unknown) => { | ||||
| 		if (element instanceof HTMLInputElement) element.value = newValue as string | ||||
| 		else element.setAttribute('data-laterano-connect', String(newValue)) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // Handle condition rendering (%if macro)
 | ||||
| function setupConditionRendering( | ||||
| 	element: Element, | ||||
| 	expr: string, | ||||
| 	ops: { | ||||
| 		conditionalElements: Map< | ||||
| 			Element, | ||||
| 			{ expr: string; placeholder: Comment; isPresent: boolean } | ||||
| 		> | ||||
| 		evaluateIfCondition: (element: Element, expr: string) => void | ||||
| 		extractStatePathsFromExpression: (expr: string) => string[] | ||||
| 		stateToElementsMap: Record<string, Set<HTMLElement>> | ||||
| 	}, | ||||
| ) { | ||||
| 	const placeholder = document.createComment(` %if: ${expr} `) | ||||
| 	element.parentNode?.insertBefore(placeholder, element) | ||||
| 
 | ||||
| 	ops.conditionalElements.set(element, { | ||||
| 		expr, | ||||
| 		placeholder, | ||||
| 		isPresent: true, | ||||
| 	}) | ||||
| 
 | ||||
| 	ops.evaluateIfCondition(element, expr) | ||||
| 
 | ||||
| 	const statePaths = ops.extractStatePathsFromExpression(expr) | ||||
| 	for (const path of statePaths) { | ||||
| 		if (!ops.stateToElementsMap[path]) ops.stateToElementsMap[path] = new Set() | ||||
| 		ops.stateToElementsMap[path].add(element as HTMLElement) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // Evaluate expressions using the item context
 | ||||
| function evaluateExpressionWithItemContext( | ||||
| 	expression: string, | ||||
| 	itemContext: Record<string, unknown>, | ||||
| 	context: ListRenderingContext, | ||||
| 	index?: number, | ||||
| 	itemVar?: string, | ||||
| 	indexVar?: string, | ||||
| ): unknown { | ||||
| 	try { | ||||
| 		// Check if the expression directly references the item variable
 | ||||
| 		if (itemVar && expression === itemVar) { | ||||
| 			return itemContext[itemVar] | ||||
| 		} | ||||
| 
 | ||||
| 		// Check if the expression is an item property path
 | ||||
| 		if (itemVar && expression.startsWith(`${itemVar}.`)) { | ||||
| 			const propertyPath = expression.substring(itemVar.length + 1) | ||||
| 			const parts = propertyPath.split('.') | ||||
| 			let value = itemContext[itemVar] | ||||
| 
 | ||||
| 			for (const part of parts) { | ||||
| 				if (value === undefined || value === null) return undefined | ||||
| 				value = (value as { [key: string]: unknown })[part] | ||||
| 			} | ||||
| 
 | ||||
| 			return value | ||||
| 		} | ||||
| 
 | ||||
| 		// Check if the expression directly references the index variable
 | ||||
| 		if (indexVar && expression === indexVar) { | ||||
| 			return index | ||||
| 		} | ||||
| 
 | ||||
| 		// Create a merged context (component state + item context)
 | ||||
| 		const mergedContext = { ...context.states, ...itemContext } | ||||
| 
 | ||||
| 		// Create a function to evaluate the expression
 | ||||
| 		const contextKeys = Object.keys(mergedContext) | ||||
| 		const contextValues = Object.values(mergedContext) | ||||
| 
 | ||||
| 		// Use the with statement to allow the expression to access all properties in the context
 | ||||
| 		const func = new Function(...contextKeys, `return ${expression}`) | ||||
| 		return func(...contextValues) | ||||
| 	} catch (error) { | ||||
| 		console.error( | ||||
| 			`Error evaluating expression with item context: ${expression}`, | ||||
| 			error, | ||||
| 		) | ||||
| 		return undefined | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // Set up nested list rendering
 | ||||
| function setupNestedListRendering( | ||||
| 	element: Element, | ||||
| 	expr: string, | ||||
| 	parentItemContext: Record<string, unknown>, | ||||
| 	context: ListRenderingContext, | ||||
| ) { | ||||
| 	// Parse the expression (e.g., "subItem in item.subItems")
 | ||||
| 	const match = expr.match(/(?:\(([^,]+),\s*([^)]+)\)|([^,\s]+))\s+in\s+(.+)/) | ||||
| 	if (!match) { | ||||
| 		console.error(`Invalid nested %for expression: ${expr}`) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// Extract the item variable name, index variable name (optional), and collection expression
 | ||||
| 	const itemVar = match[3] || match[1] | ||||
| 	const indexVar = match[2] || null | ||||
| 	const collectionExpr = match[4].trim() | ||||
| 
 | ||||
| 	// Evaluate the collection expression, using the parent item context
 | ||||
| 	const collection = evaluateExpressionWithItemContext( | ||||
| 		collectionExpr, | ||||
| 		parentItemContext, | ||||
| 		context, | ||||
| 	) | ||||
| 
 | ||||
| 	if (!collection || !Array.isArray(collection)) { | ||||
| 		console.warn( | ||||
| 			`Nested collection "${collectionExpr}" is not an array or does not exist`, | ||||
| 		) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// Create a placeholder comment
 | ||||
| 	const placeholder = document.createComment(` %for: ${expr} `) | ||||
| 	element.parentNode?.insertBefore(placeholder, element) | ||||
| 
 | ||||
| 	// Remove the original template element from the DOM
 | ||||
| 	const template = element.cloneNode(true) as Element | ||||
| 	element.parentNode?.removeChild(element) | ||||
| 
 | ||||
| 	// Create an element for each item
 | ||||
| 	collection.forEach((item, index) => { | ||||
| 		const itemElement = template.cloneNode(true) as Element | ||||
| 
 | ||||
| 		// Create a nested item context, merging the parent context
 | ||||
| 		const nestedItemContext = { | ||||
| 			...parentItemContext, | ||||
| 			[itemVar]: item, | ||||
| 		} | ||||
| 
 | ||||
| 		if (indexVar) { | ||||
| 			nestedItemContext[indexVar] = index | ||||
| 		} | ||||
| 
 | ||||
| 		// Recursively process this item and its children
 | ||||
| 		processElementWithItemContext(itemElement, nestedItemContext, context) | ||||
| 
 | ||||
| 		// Insert the item element into the DOM
 | ||||
| 		placeholder.parentNode?.insertBefore(itemElement, placeholder.nextSibling) | ||||
| 	}) | ||||
| } | ||||
| 
 | ||||
| // Recursively process the element and its children, applying the item context
 | ||||
| function processElementWithItemContext( | ||||
| 	element: Element, | ||||
| 	itemContext: Record<string, unknown>, | ||||
| 	context: ListRenderingContext, | ||||
| ) { | ||||
| 	// Store the item context of the element so that subsequent updates can find it
 | ||||
| 	;(element as { _itemContext?: Record<string, unknown> })._itemContext = | ||||
| 		itemContext | ||||
| 
 | ||||
| 	// Process bindings in text nodes
 | ||||
| 	const processTextNodes = (node: Node) => { | ||||
| 		if (node.nodeType === Node.TEXT_NODE) { | ||||
| 			const textContent = node.textContent || '' | ||||
| 			if (textContent.includes('{{')) { | ||||
| 				const textNode = node as Text | ||||
| 				const updatedContent = textContent.replace( | ||||
| 					/\{\{\s*([^}]+)\s*\}\}/g, | ||||
| 					(match, expr) => { | ||||
| 						const value = evaluateExpressionWithItemContext( | ||||
| 							expr.trim(), | ||||
| 							itemContext, | ||||
| 							context, | ||||
| 						) | ||||
| 						return value !== undefined ? String(value) : '' | ||||
| 					}, | ||||
| 				) | ||||
| 				textNode.textContent = updatedContent | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// Process the text nodes of the element itself
 | ||||
| 	for (const node of Array.from(element.childNodes)) | ||||
| 		if (node.nodeType === Node.TEXT_NODE) processTextNodes(node) | ||||
| 
 | ||||
| 	// Process attribute bindings (:attr)
 | ||||
| 	for (const attr of Array.from(element.attributes)) { | ||||
| 		if (attr.name.startsWith(':')) { | ||||
| 			const attrName = attr.name.substring(1) | ||||
| 			const expr = attr.value.trim() | ||||
| 			const value = evaluateExpressionWithItemContext( | ||||
| 				expr, | ||||
| 				itemContext, | ||||
| 				context, | ||||
| 			) | ||||
| 
 | ||||
| 			if (value !== undefined) element.setAttribute(attrName, String(value)) | ||||
| 
 | ||||
| 			// Remove the original binding attribute
 | ||||
| 			element.removeAttribute(attr.name) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// Process event bindings (@event)
 | ||||
| 	for (const attr of Array.from(element.attributes)) { | ||||
| 		if (attr.name.startsWith('@')) { | ||||
| 			const eventName = attr.name.substring(1) | ||||
| 			const handlerValue = attr.value.trim() | ||||
| 
 | ||||
| 			// Remove the original binding attribute
 | ||||
| 			element.removeAttribute(attr.name) | ||||
| 
 | ||||
| 			// Add event listener
 | ||||
| 			element.addEventListener(eventName, (event: Event) => { | ||||
| 				try { | ||||
| 					// Create a merged context
 | ||||
| 					const mergedContext = { | ||||
| 						...context.states, | ||||
| 						...itemContext, | ||||
| 						$event: event, | ||||
| 						$el: element, | ||||
| 						setState: context.setState, | ||||
| 						getState: context.getState, | ||||
| 						triggerFunc: (eventName: string, ...args: unknown[]) => | ||||
| 							context.triggerFunc(eventName, ...args), | ||||
| 					} | ||||
| 
 | ||||
| 					// Execute the expression
 | ||||
| 					const fnStr = `with(this) { ${handlerValue} }` | ||||
| 					new Function(fnStr).call(mergedContext) | ||||
| 				} catch (err) { | ||||
| 					console.error( | ||||
| 						`Error executing event handler with item context: ${handlerValue}`, | ||||
| 						err, | ||||
| 					) | ||||
| 				} | ||||
| 			}) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// Process conditional rendering (%if)
 | ||||
| 	let isConditional = false | ||||
| 	let shouldDisplay = true | ||||
| 
 | ||||
| 	for (const attr of Array.from(element.attributes)) { | ||||
| 		if (attr.name === '%if') { | ||||
| 			isConditional = true | ||||
| 			const expr = attr.value.trim() | ||||
| 
 | ||||
| 			// Remove the original binding attribute
 | ||||
| 			element.removeAttribute(attr.name) | ||||
| 
 | ||||
| 			// Calculate the condition
 | ||||
| 			const result = evaluateExpressionWithItemContext( | ||||
| 				expr, | ||||
| 				itemContext, | ||||
| 				context, | ||||
| 			) | ||||
| 			shouldDisplay = Boolean(result) | ||||
| 
 | ||||
| 			// Apply the condition
 | ||||
| 			if (!shouldDisplay) (element as HTMLElement).style.display = 'none' | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// If the condition evaluates to false, skip further processing of this element
 | ||||
| 	if (isConditional && !shouldDisplay) { | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// Process nested list rendering (%for)
 | ||||
| 	let hasForDirective = false | ||||
| 
 | ||||
| 	for (const attr of Array.from(element.attributes)) { | ||||
| 		if (attr.name === '%for') { | ||||
| 			hasForDirective = true | ||||
| 			const forExpr = attr.value.trim() | ||||
| 
 | ||||
| 			// Remove the original binding attribute
 | ||||
| 			element.removeAttribute(attr.name) | ||||
| 
 | ||||
| 			// Set up nested list rendering
 | ||||
| 			setupNestedListRendering(element, forExpr, itemContext, context) | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	// If this element is a list element, skip child element processing
 | ||||
| 	if (hasForDirective) return | ||||
| 
 | ||||
| 	// Recursively process all child elements
 | ||||
| 	for (const child of Array.from(element.children)) | ||||
| 		processElementWithItemContext(child, itemContext, context) | ||||
| } | ||||
| 
 | ||||
| // Handle list rendering (%for macro)
 | ||||
| function setupListRendering( | ||||
| 	element: Element, | ||||
| 	expr: string, | ||||
| 	context: ListRenderingContext, | ||||
| ) { | ||||
| 	// Parse the expression (e.g., "item in items" or "(item, index) in items")
 | ||||
| 	const match = expr.match(/(?:\(([^,]+),\s*([^)]+)\)|([^,\s]+))\s+in\s+(.+)/) | ||||
| 	if (!match) { | ||||
| 		console.error(`Invalid %for expression: ${expr}`) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// Extract the item variable name, index variable name (optional), and collection expression
 | ||||
| 	const itemVar = match[3] || match[1] | ||||
| 	const indexVar = match[2] || null | ||||
| 	const collectionExpr = match[4].trim() | ||||
| 
 | ||||
| 	// Create a placeholder comment
 | ||||
| 	const placeholder = document.createComment(` %for: ${expr} `) | ||||
| 	element.parentNode?.insertBefore(placeholder, element) | ||||
| 
 | ||||
| 	// Remove the original template element from the DOM
 | ||||
| 	const template = element.cloneNode(true) as Element | ||||
| 	element.parentNode?.removeChild(element) | ||||
| 
 | ||||
| 	// Store current rendered items
 | ||||
| 	const renderedItems: Array<{ | ||||
| 		element: Element | ||||
| 		key: unknown | ||||
| 		data: unknown | ||||
| 		index: number | ||||
| 	}> = [] | ||||
| 
 | ||||
| 	// Create a function to update the list when the collection changes
 | ||||
| 	const updateList = () => { | ||||
| 		const collection = evaluateExpressionWithItemContext( | ||||
| 			collectionExpr, | ||||
| 			{}, | ||||
| 			context, | ||||
| 		) | ||||
| 		if (!collection || !Array.isArray(collection)) { | ||||
| 			console.warn( | ||||
| 				`Collection "${collectionExpr}" is not an array or does not exist`, | ||||
| 			) | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		const parentNode = placeholder.parentNode | ||||
| 		if (!parentNode) { | ||||
| 			console.error("Placeholder's parentNode is null. Cannot update list.") | ||||
| 			return | ||||
| 		} | ||||
| 
 | ||||
| 		// Detach all currently rendered DOM items
 | ||||
| 		for (const item of renderedItems) | ||||
| 			if (item.element.parentNode === parentNode) | ||||
| 				parentNode.removeChild(item.element) | ||||
| 
 | ||||
| 		// Get key attribute if available
 | ||||
| 		const keyAttr = template.getAttribute('%key') | ||||
| 		if (!keyAttr) | ||||
| 			console.warn( | ||||
| 				'%key attribute not found in the template, which is not a recommended practice.', | ||||
| 			) | ||||
| 
 | ||||
| 		// Store a map of existing items by key for reuse
 | ||||
| 		const existingElementsByKey = new Map() | ||||
| 		for (const item of renderedItems) | ||||
| 			if (item.key !== undefined) existingElementsByKey.set(item.key, item) | ||||
| 
 | ||||
| 		// Clear rendered items
 | ||||
| 		renderedItems.length = 0 | ||||
| 
 | ||||
| 		// document fragment
 | ||||
| 		const fragment = document.createDocumentFragment() | ||||
| 
 | ||||
| 		// Create or update items in the list
 | ||||
| 		collection.forEach((item, index) => { | ||||
| 			// Determine the key for this item
 | ||||
| 			const key = keyAttr | ||||
| 				? evaluateExpressionWithItemContext( | ||||
| 						keyAttr, | ||||
| 						{ [itemVar]: item }, | ||||
| 						context, | ||||
| 						index, | ||||
| 						itemVar, | ||||
| 						indexVar ? indexVar : undefined, | ||||
| 					) | ||||
| 				: index | ||||
| 
 | ||||
| 			// Check if we can reuse an existing element
 | ||||
| 			const existingItem = existingElementsByKey.get(key) | ||||
| 			let itemElement: Element | ||||
| 
 | ||||
| 			if (existingItem) { | ||||
| 				// Reuse existing element
 | ||||
| 				itemElement = existingItem.element | ||||
| 				existingElementsByKey.delete(key) // Remove from map so we know it's been used
 | ||||
| 			} else { | ||||
| 				// Create a new element
 | ||||
| 				itemElement = template.cloneNode(true) as Element | ||||
| 			} | ||||
| 
 | ||||
| 			// Update item data
 | ||||
| 			renderedItems.push({ | ||||
| 				element: itemElement, | ||||
| 				key, | ||||
| 				data: item, | ||||
| 				index, | ||||
| 			}) | ||||
| 
 | ||||
| 			// Create item context for this item
 | ||||
| 			const itemContext = { | ||||
| 				[itemVar]: item, | ||||
| 			} | ||||
| 			if (indexVar) itemContext[indexVar] = index | ||||
| 
 | ||||
| 			// insert %key attribute
 | ||||
| 			if (keyAttr) { | ||||
| 				const keyValue = evaluateExpressionWithItemContext( | ||||
| 					keyAttr, | ||||
| 					itemContext, | ||||
| 					context, | ||||
| 				) | ||||
| 				itemElement.setAttribute('data-laterano-key', String(keyValue)) | ||||
| 			} | ||||
| 
 | ||||
| 			// remove original %key attribute
 | ||||
| 			itemElement.removeAttribute('%key') | ||||
| 
 | ||||
| 			// Process the element with the item context
 | ||||
| 			processElementWithItemContext(itemElement, itemContext, context) | ||||
| 
 | ||||
| 			// Insert the element to the document fragment
 | ||||
| 			fragment.appendChild(itemElement) | ||||
| 		}) | ||||
| 
 | ||||
| 		// Insert the document fragment into the DOM
 | ||||
| 		placeholder.parentNode?.insertBefore(fragment, placeholder.nextSibling) | ||||
| 
 | ||||
| 		// Remove any remaining unused items
 | ||||
| 		for (const item of existingElementsByKey.values()) | ||||
| 			if (item.element.parentNode) | ||||
| 				item.element.parentNode.removeChild(item.element) | ||||
| 	} | ||||
| 
 | ||||
| 	// Initial render
 | ||||
| 	updateList() | ||||
| 
 | ||||
| 	// Set up state dependency for collection changes
 | ||||
| 	if (!context.stateToElementsMap[collectionExpr]) | ||||
| 		context.stateToElementsMap[collectionExpr] = new Set() | ||||
| 
 | ||||
| 	// Using a unique identifier for this list rendering instance
 | ||||
| 	const listVirtualElement = document.createElement('div') | ||||
| 	context.stateToElementsMap[collectionExpr].add( | ||||
| 		listVirtualElement as HTMLElement, | ||||
| 	) | ||||
| 
 | ||||
| 	// Add listener for state changes
 | ||||
| 	context.statesListeners[collectionExpr] = () => { | ||||
| 		updateList() | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										76
									
								
								src/utils/setupArrowFunctionHandler.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								src/utils/setupArrowFunctionHandler.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,76 @@ | |||
| export default function setupArrowFunctionHandler( | ||||
| 	element: Element, | ||||
| 	eventName: string, | ||||
| 	handlerValue: string, | ||||
| 	ops: { | ||||
| 		createHandlerContext: ( | ||||
| 			event: Event, | ||||
| 			element: Element, | ||||
| 		) => { | ||||
| 			states: Record<string, unknown> | ||||
| 			stateToElementsMap: Record<string, Set<HTMLElement>> | ||||
| 			statesListeners: Record<string, (value: unknown) => void> | ||||
| 			setState: (keyPath: string, value: unknown) => void | ||||
| 			getState: (keyPath: string) => unknown | ||||
| 			triggerFunc: (eventName: string, ...args: unknown[]) => void | ||||
| 		} | ||||
| 	}, | ||||
| ) { | ||||
| 	element.addEventListener(eventName, (event: Event) => { | ||||
| 		try { | ||||
| 			// Arrow function parsing
 | ||||
| 			const splitted = handlerValue.split('=>') | ||||
| 			if (splitted.length !== 2) { | ||||
| 				throw new Error(`Invalid arrow function syntax: ${handlerValue}`) | ||||
| 			} | ||||
| 			const paramsStr = (() => { | ||||
| 				if (splitted[0].includes('(')) return splitted[0].trim() | ||||
| 				return `(${splitted[0].trim()})` | ||||
| 			})() | ||||
| 			const bodyStr = splitted[1].trim() | ||||
| 
 | ||||
| 			// Check if the function body is wrapped in {}
 | ||||
| 			const isMultiline = bodyStr.startsWith('{') && bodyStr.endsWith('}') | ||||
| 
 | ||||
| 			// If it is a multiline function body, remove the outer braces
 | ||||
| 			if (isMultiline) { | ||||
| 				// Remove the outer braces
 | ||||
| 				let bodyStr = handlerValue.split('=>')[1].trim() | ||||
| 				bodyStr = bodyStr.substring(1, bodyStr.length - 1) | ||||
| 
 | ||||
| 				// Build code for multiline arrow function
 | ||||
| 				const functionCode = ` | ||||
| 									return function${paramsStr} { | ||||
| 										${bodyStr} | ||||
| 									} | ||||
| 								` | ||||
| 
 | ||||
| 				// Create context object
 | ||||
| 				const context = ops.createHandlerContext(event, element) | ||||
| 
 | ||||
| 				// Create and call function
 | ||||
| 				const handlerFn = new Function(functionCode).call(null) | ||||
| 				handlerFn.apply(context, [event]) | ||||
| 			} else { | ||||
| 				// Single line arrow function, directly return expression result
 | ||||
| 				const functionCode = ` | ||||
| 									return function${paramsStr} { | ||||
| 										return ${bodyStr} | ||||
| 									} | ||||
| 								` | ||||
| 
 | ||||
| 				// Create context object
 | ||||
| 				const context = ops.createHandlerContext(event, element) | ||||
| 
 | ||||
| 				// Create and call function
 | ||||
| 				const handlerFn = new Function(functionCode).call(null) | ||||
| 				handlerFn.apply(context, [event]) | ||||
| 			} | ||||
| 		} catch (err) { | ||||
| 			console.error( | ||||
| 				`Error executing arrow function handler: ${handlerValue}`, | ||||
| 				err, | ||||
| 			) | ||||
| 		} | ||||
| 	}) | ||||
| } | ||||
							
								
								
									
										51
									
								
								src/utils/triggerDomUpdates.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								src/utils/triggerDomUpdates.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,51 @@ | |||
| export default function triggerDomUpdates( | ||||
| 	keyPath: string, | ||||
| 	ops: { | ||||
| 		stateToElementsMap: Record<string, Set<HTMLElement>> | ||||
| 		scheduleUpdate: (elements: Set<HTMLElement>) => void | ||||
| 		textBindings: | ||||
| 			| Array<{ | ||||
| 					node: Text | ||||
| 					expr: string | ||||
| 					originalContent: string | ||||
| 			  }> | ||||
| 			| undefined | ||||
| 		attributeBindings: | ||||
| 			| Array<{ | ||||
| 					element: Element | ||||
| 					attrName: string | ||||
| 					expr: string | ||||
| 					template: string | ||||
| 			  }> | ||||
| 			| undefined | ||||
| 		updateTextNode: (node: Text, expr: string, template: string) => void | ||||
| 		getNestedState: (path: string) => unknown | ||||
| 	}, | ||||
| ) { | ||||
| 	if (ops.stateToElementsMap[keyPath]) { | ||||
| 		const updateQueue = new Set<HTMLElement>() | ||||
| 
 | ||||
| 		for (const element of ops.stateToElementsMap[keyPath]) | ||||
| 			updateQueue.add(element) | ||||
| 
 | ||||
| 		ops.scheduleUpdate(updateQueue) | ||||
| 	} | ||||
| 
 | ||||
| 	// Update text bindings that depend on this state
 | ||||
| 	if (ops.textBindings) { | ||||
| 		// this._textBindings.forEach((binding) => {
 | ||||
| 		for (const binding of ops.textBindings) | ||||
| 			if (binding.expr === keyPath || binding.expr.startsWith(`${keyPath}.`)) | ||||
| 				ops.updateTextNode(binding.node, binding.expr, binding.originalContent) | ||||
| 	} | ||||
| 
 | ||||
| 	// Update attribute bindings that depend on this state
 | ||||
| 	if (ops.attributeBindings) { | ||||
| 		for (const binding of ops.attributeBindings) | ||||
| 			if (binding.expr === keyPath || binding.expr.startsWith(`${keyPath}.`)) { | ||||
| 				const value = ops.getNestedState(binding.expr) | ||||
| 				if (value !== undefined) | ||||
| 					binding.element.setAttribute(binding.attrName, String(value)) | ||||
| 			} | ||||
| 	} | ||||
| } | ||||
|  | @ -35,7 +35,9 @@ | |||
| 		// "baseUrl": "./",                                  /* Specify the base directory to resolve non-relative module names. */ | ||||
| 		// "paths": {},                                      /* Specify a set of entries that re-map imports to additional lookup locations. */ | ||||
| 		// "rootDirs": [],                                   /* Allow multiple folders to be treated as one when resolving modules. */ | ||||
| 		// "typeRoots": ["./src/types"],                                  /* Specify multiple folders that act like './node_modules/@types'. */ | ||||
| 		"typeRoots": [ | ||||
| 			"./src/types" | ||||
| 		] /* Specify multiple folders that act like './node_modules/@types'. */, | ||||
| 		// "types": [],                                      /* Specify type package names to be included without being referenced in a source file. */ | ||||
| 		// "allowUmdGlobalAccess": true,                     /* Allow accessing UMD globals from modules. */ | ||||
| 		// "moduleSuffixes": [],                             /* List of file name suffixes to search when resolving a module. */ | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	Block a user