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