diff --git a/.github/workflows/fly-deploy.yml b/.github/workflows/fly-deploy.yml index 011f0571f..18f396b07 100644 --- a/.github/workflows/fly-deploy.yml +++ b/.github/workflows/fly-deploy.yml @@ -23,6 +23,10 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Deploy to fly.io uses: superfly/flyctl-actions/setup-flyctl@master - - run: flyctl deploy --remote-only + - run: | + docker container run --rm -t -v "${{ github.workspace }}":/app -v go-modules:/go/pkg/mod ghcr.io/starfederation/datastar-dev -c 'git lfs fetch --all && git lfs pull && git lfs checkout' + docker container run --rm -t -v "${{ github.workspace }}":/app -v go-modules:/go/pkg/mod ghcr.io/starfederation/datastar-dev -c 'task tools' + docker container run --rm -t -v "${{ github.workspace }}":/app -v go-modules:/go/pkg/mod ghcr.io/starfederation/datastar-dev -c 'task support' + flyctl deploy --local-only env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/.gitignore b/.gitignore index 17443a191..a7f6926a2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ datastar_site data .task .idea +.DS_Store node_modules datastar-website *_bin +.DS_Store \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index f3d836887..d28343d28 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -24,7 +24,7 @@ "type": "go", "request": "launch", "mode": "auto", - "program": "${workspaceFolder}/code/go/cmd/tsbuild/main.go", + "program": "${workspaceFolder}/code/go/cmd/build/main.go", "cwd": "${workspaceFolder}" } ] diff --git a/CHANGELOG.md b/CHANGELOG.md index 1890aff8e..731e49d3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,29 @@ ## 0.21.0 - Unreleased +We’ve overhauled Datastar in v0.21.0, doubling down on making nestable signals declarative. To that end, we’ve removed special characters, made the API more explicit and consistent, and fixed some restrictions to nested signals that we discovered. Signal values are now accessed in expressions using the syntax `signalName.value`, actions no longer have a prefix, and attribute keys support nested signals using dot-delimited paths. + +### Added + +- Added the ability to merge one-off signals using the syntax `data-signals-foo="value"`. +- Added the ability to use dot-delimited paths to denote nested signals in applicable attribute keys (`data-signals-foo.bar="value"`). +- Added the ability to use multiple attributes using the syntax `data-attributes="{attrName1: value1, attrName2: value2}"`. +- Added the ability to use a single classes using the syntax `data-class-hidden="foo.value"`. +- Added the ability to use a key instead of a value to denote a signal name in the `data-bind`, `data-indicator` and `data-ref` attributes (`data-bind-foo`, `data-indicator-foo`, `data-ref-foo`). +- Added error codes and links to descriptions in the console for every error thrown. + ### Changed -- Changed the action plugin prefix from `$` to `@`. -- Renamed the `data-store` attribute to `data-merge-signals`. +- Signals no longer have the `$` prefix and must be acessed using a `.value` suffix (`signalName.value`). +- Action plugins no longer have the `$` prefix. +- Renamed the `data-store` attribute to `data-signals`. +- Renamed the `data-bind` attribute to `data-attributes`. - Renamed the `data-model` attribute to `data-bind`. +- Changed the `data-*` attribute modifier delimiter from `.` to `:` (`data-on-keydown:debounce_100ms:throttle_lead="value"`). +- The the `get()`, `post()`, `put()`, and `delete()` plugins have been replaced by a single `sse()` plugin that accepts the method as an option (`sse(url, {method="get"})`). +- The `setAll()` and `toggleAll` plugins now accept a dot-delimited path format, instead of a regular expression. ### Fixed -- Fixed headers not merging correctly. +- Fixed headers not merging correctly. - Fixed new lines in the SDK protocol for paths. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f199f009..774cc2155 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,9 +12,9 @@ Anything outside of this scope may not be accepted. If you have a need for a fea Before submitting bug reports and feature requests, please search the [open issues](https://github.com/starfederation/datastar/issues) and the _#help_ channel in the [Discord server](https://discord.gg/bnRNgZjgPh) to see if it has already been addressed. When submitting a [new issue](https://github.com/starfederation/datastar/issues/new), please use a descriptive title and include a clear description and as much relevant information as possible. -## Documentation +## Documentation -Datastar’s documentation is under active development. All the markdown files live in [this folder](https://github.com/starfederation/datastar/tree/develop/code/go/site/static/md). Improvements to them can be submitted via pull requests. +Datastar’s documentation is under active development. All the markdown files live in [this folder](https://github.com/starfederation/datastar/tree/develop/site/static/md). Improvements to them can be submitted via pull requests. ## Pull Requests diff --git a/Dockerfile-dev b/Dockerfile-dev index 37892dd63..8379bf102 100644 --- a/Dockerfile-dev +++ b/Dockerfile-dev @@ -39,6 +39,11 @@ RUN apt update && sudo apt upgrade \ && \ go install github.com/valyala/quicktemplate/qtc@latest \ && \ + # Install flyctl cli \ + curl -L https://fly.io/install.sh | sh \ + && \ + ln -s /root/.fly/bin/flyctl /usr/local/bin/fly \ + && \ # Make this a safe .git directory git config --global --add safe.directory /app diff --git a/Makefile b/Makefile index bc8d81ccb..bb3a2b2db 100644 --- a/Makefile +++ b/Makefile @@ -21,14 +21,11 @@ dev: --image-check # Build the Docker image image-build: docker build -f Dockerfile-dev . -t ${IMAGE_NAME} --build-arg TAG=${TAG} --no-cache -ifeq ($(ARCH),arm64) - ${DOCKER_RUN} --name ${CONTAINER}-$@ ${IMAGE_NAME} -c 'wget -O code/go/site/tailwindcli https://github.com/dobicinaitis/tailwind-cli-extra/releases/download/v1.7.21/tailwindcss-extra-linux-arm64' -endif ${DOCKER_RUN} --name ${CONTAINER}-$@ ${IMAGE_NAME} -c 'git lfs fetch --all && git lfs pull && git lfs checkout' ${DOCKER_RUN} --name ${CONTAINER}-$@ ${IMAGE_NAME} -c 'task tools' # Run the passed in task command task: --image-check - ${DOCKER_RUN} --name ${CONTAINER}-$@ -e DEV_PORT="${DEV_PORT}" -p ${DEV_PORT}:${DEV_PORT} ${IMAGE_NAME} ${IMAGE_NAME} -c 'task $(filter-out $@,$(MAKECMDGOALS)) $(MAKEFLAGS)' + ${DOCKER_RUN} --name ${CONTAINER}-$@ -e DEV_PORT="${DEV_PORT}" -p ${DEV_PORT}:${DEV_PORT} ${IMAGE_NAME} -c 'task $(filter-out $@,$(MAKECMDGOALS)) $(MAKEFLAGS)' # Run the test suite test: --image-check ${DOCKER_RUN} --name ${CONTAINER}-$@ -e DEV_PORT="${DEV_PORT}" -p ${DEV_PORT}:${DEV_PORT} ${IMAGE_NAME} -c 'task test' diff --git a/README.md b/README.md index f4c44d875..2ca5d2027 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,29 @@ ![Discord](https://img.shields.io/discord/1296224603642925098) ![GitHub Repo stars](https://img.shields.io/github/stars/starfederation/datastar?style=flat) -

+

# Datastar -### A real-time hypermedia framework. +### The hypermedia framework. -Datastar helps you build real-time web applications with the simplicity of server-side rendering and the power of a full-stack SPA framework. +Datastar helps you build reactive web applications with the simplicity of server-side rendering and the power of a full-stack SPA framework. + +Getting started is as easy as adding a single script tag to your HTML. + +```html + +``` + +Then start adding frontend reactivity using declarative `data-*` attributes. +Datastar helps you build reactive web applications with the simplicity of server-side rendering and the power of a full-stack SPA framework. Here’s what frontend reactivity looks like using Datastar: ```html - -
- + +
+ ``` Visit the [Datastar Website »](https://data-star.dev/) diff --git a/Taskfile.yml b/Taskfile.yml index f916f0ad6..a216d58e4 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -14,15 +14,17 @@ tasks: cmds: - go install github.com/go-task/task/v3/cmd/task@latest - go install github.com/a-h/templ/cmd/templ@latest - - cmd: test -f code/go/site/tailwindcli || wget -O code/go/site/tailwindcli https://github.com/dobicinaitis/tailwind-cli-extra/releases/download/v1.7.21/tailwindcss-extra-linux-x64 - platforms: [linux] - - cmd: test -f code/go/site/tailwindcli || wget -O code/go/site/tailwindcli https://github.com/dobicinaitis/tailwind-cli-extra/releases/download/v1.7.21/tailwindcss-extra-macos-arm64 + - cmd: test -f site/tailwindcli || wget -O site/tailwindcli https://github.com/dobicinaitis/tailwind-cli-extra/releases/download/v1.7.21/tailwindcss-extra-linux-x64 + platforms: [linux/amd64] + - cmd: test -f site/tailwindcli || wget -O site/tailwindcli https://github.com/dobicinaitis/tailwind-cli-extra/releases/download/v1.7.21/tailwindcss-extra-linux-arm64 + platforms: [linux/arm64] + - cmd: test -f site/tailwindcli || wget -O site/tailwindcli https://github.com/dobicinaitis/tailwind-cli-extra/releases/download/v1.7.21/tailwindcss-extra-macos-arm64 platforms: [darwin/arm64] - - cmd: test -f code/go/site/tailwindcli || wget -O code/go/site/tailwindcli https://github.com/dobicinaitis/tailwind-cli-extra/releases/download/v1.7.21/tailwindcss-extra-macos-x64 + - cmd: test -f site/tailwindcli || wget -O site/tailwindcli https://github.com/dobicinaitis/tailwind-cli-extra/releases/download/v1.7.21/tailwindcss-extra-macos-x64 platforms: [darwin/amd64] - - cmd: test -f code/go/site/tailwindcli || wget -O code/go/site/tailwindcli https://github.com/dobicinaitis/tailwind-cli-extra/releases/download/v1.7.21/tailwindcss-extra-windows-x64.exe + - cmd: test -f site/tailwindcli || wget -O site/tailwindcli https://github.com/dobicinaitis/tailwind-cli-extra/releases/download/v1.7.21/tailwindcss-extra-windows-x64.exe platforms: [windows] - - chmod +x code/go/site/tailwindcli + - chmod +x site/tailwindcli - go install github.com/valyala/quicktemplate/qtc@latest version: @@ -37,7 +39,7 @@ tasks: cmds: - qtc - tsbuild: + build: deps: - qtc sources: @@ -48,20 +50,20 @@ tasks: generates: - "bundles/**/*" cmds: - - go run code/go/cmd/tsbuild/main.go - - cp -r bundles/* code/go/site/static/js/ - - mkdir -p code/ts/library/dist/ - - cp -r bundles/* code/ts/library/dist/ - - mkdir -p code/go/site/static/librarySource - - rm -rf code/go/site/static/librarySource/* - - cp -r code/ts/library/src/* code/go/site/static/librarySource/ + - go run build/cmd/build/main.go + - cp -r bundles/* site/static/js/ + - mkdir -p library/dist/ + - cp -r bundles/* library/dist/ + - mkdir -p site/static/librarySource + - rm -rf site/static/librarySource/* + - cp -r library/src/* site/static/librarySource/ libpub: dir: code/ts/library requires: vars: [VERSION] deps: - - tsbuild + - build cmds: - git push origin - git tag v{{.VERSION}} @@ -75,7 +77,7 @@ tasks: - task: deploy css: - dir: code/go/site + dir: site sources: - "**/*.templ" - "**/*.md" @@ -103,15 +105,15 @@ tasks: support: sources: - - code/go/**/*.templ - - code/go/**/*.go - - code/go/**/*.md - - code/go/site/static/**/* + - "**/*.templ" + - "**/*.go" + - "**/*.md" + - site/static/**/* generates: - ./datastar-website deps: - kill - - tsbuild + - build - templ - css @@ -122,16 +124,17 @@ tasks: - support cmds: - go mod tidy - - go build -o ./datastar-website code/go/cmd/site/main.go + - go build -o ./datastar-website "site/cmd/site/main.go" deploy: method: none deps: + - support cmds: - fly deploy --local-only test: - dir: code/go/site/smoketests + dir: site/smoketests deps: - support cmds: diff --git a/VERSION b/VERSION index 9d2632160..2c835f025 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.20.1 \ No newline at end of file +0.21.0-beta1 \ No newline at end of file diff --git a/code/go/.gitignore b/build/.gitignore similarity index 100% rename from code/go/.gitignore rename to build/.gitignore diff --git a/code/go/cmd/tsbuild/main.go b/build/cmd/build/main.go similarity index 70% rename from code/go/cmd/tsbuild/main.go rename to build/cmd/build/main.go index 994e2d42d..ed79cdec3 100644 --- a/code/go/cmd/tsbuild/main.go +++ b/build/cmd/build/main.go @@ -4,7 +4,7 @@ import ( "log" "time" - "github.com/starfederation/datastar/code/go/tsbuild" + build "github.com/starfederation/datastar/build" ) func main() { @@ -14,7 +14,7 @@ func main() { log.Printf("Datastar built in %s", time.Since(start)) }() - if err := tsbuild.Build(); err != nil { + if err := build.Build(); err != nil { log.Fatal(err) } diff --git a/code/go/tsbuild/consts.go b/build/consts.go similarity index 99% rename from code/go/tsbuild/consts.go rename to build/consts.go index 275a15518..c9d7b00ac 100644 --- a/code/go/tsbuild/consts.go +++ b/build/consts.go @@ -1,4 +1,4 @@ -package tsbuild +package build import ( "time" diff --git a/code/go/tsbuild/consts_datastar_client.qtpl b/build/consts_datastar_client.qtpl similarity index 100% rename from code/go/tsbuild/consts_datastar_client.qtpl rename to build/consts_datastar_client.qtpl diff --git a/code/go/tsbuild/consts_datastar_readme.qtpl b/build/consts_datastar_readme.qtpl similarity index 60% rename from code/go/tsbuild/consts_datastar_readme.qtpl rename to build/consts_datastar_readme.qtpl index 406db4a25..90ca21d4d 100644 --- a/code/go/tsbuild/consts_datastar_readme.qtpl +++ b/build/consts_datastar_readme.qtpl @@ -6,20 +6,29 @@ ![Discord](https://img.shields.io/discord/1296224603642925098) ![GitHub Repo stars](https://img.shields.io/github/stars/starfederation/datastar?style=flat) -

+

# Datastar -### A real-time hypermedia framework. +### The hypermedia framework. -Datastar helps you build real-time web applications with the simplicity of server-side rendering and the power of a full-stack SPA framework. +Datastar helps you build reactive web applications with the simplicity of server-side rendering and the power of a full-stack SPA framework. + +Getting started is as easy as adding a single script tag to your HTML. + +```html + +``` + +Then start adding frontend reactivity using declarative `data-*` attributes. +Datastar helps you build reactive web applications with the simplicity of server-side rendering and the power of a full-stack SPA framework. Here’s what frontend reactivity looks like using Datastar: ```html - -
- + +
+ ``` Visit the [Datastar Website »](https://data-star.dev/) diff --git a/code/go/tsbuild/consts_dotnet.qtpl b/build/consts_dotnet.qtpl similarity index 100% rename from code/go/tsbuild/consts_dotnet.qtpl rename to build/consts_dotnet.qtpl diff --git a/code/go/tsbuild/consts_go.qtpl b/build/consts_go.qtpl similarity index 100% rename from code/go/tsbuild/consts_go.qtpl rename to build/consts_go.qtpl diff --git a/code/go/tsbuild/consts_php.qtpl b/build/consts_php.qtpl similarity index 100% rename from code/go/tsbuild/consts_php.qtpl rename to build/consts_php.qtpl diff --git a/code/go/tsbuild/run.go b/build/run.go similarity index 80% rename from code/go/tsbuild/run.go rename to build/run.go index a8e1c4b79..79839b363 100644 --- a/code/go/tsbuild/run.go +++ b/build/run.go @@ -1,4 +1,4 @@ -package tsbuild +package build import ( "compress/gzip" @@ -42,7 +42,7 @@ func extractVersion() (string, error) { version := strings.TrimSpace(string(versionBytes)) // Write out the version to the version file. - versionPath := "code/ts/library/src/engine/version.ts" + versionPath := "library/src/engine/version.ts" versionContents := fmt.Sprintf("export const VERSION = '%s';\n", version) if err := os.WriteFile(versionPath, []byte(versionContents), 0644); err != nil { return "", fmt.Errorf("error writing version file: %w", err) @@ -60,8 +60,8 @@ func createBundles() error { result := api.Build(api.BuildOptions{ EntryPoints: []string{ - "code/ts/library/src/bundles/datastar-core.ts", - "code/ts/library/src/bundles/datastar.ts", + "library/src/bundles/datastar-core.ts", + "library/src/bundles/datastar.ts", }, Outdir: outDir, Bundle: true, @@ -127,15 +127,15 @@ func writeOutConsts(version string) error { }) templates := map[string]func(data *ConstTemplateData) string{ - "README.md": datastarREADME, - "code/ts/library/README.md": datastarREADME, - "code/ts/library/src/engine/consts.ts": datastarClientConsts, - "code/ts/library/package.json": datastarClientPackageJSON, - "code/go/sdk/consts.go": goConsts, - "code/dotnet/sdk/src/Consts.fs": dotnetConsts, - "code/php/sdk/src/Consts.php": phpConsts, - "code/php/sdk/src/enums/EventType.php": phpEventType, - "code/php/sdk/src/enums/FragmentMergeMode.php": phpFragmentMergeMode, + "README.md": datastarREADME, + "library/README.md": datastarREADME, + "library/src/engine/consts.ts": datastarClientConsts, + "library/package.json": datastarClientPackageJSON, + "sdk/go/consts.go": goConsts, + "sdk/dotnet/src/Consts.fs": dotnetConsts, + "sdk/php/src/Consts.php": phpConsts, + "sdk/php/src/enums/EventType.php": phpEventType, + "sdk/php/src/enums/FragmentMergeMode.php": phpFragmentMergeMode, } for path, tmplFn := range templates { diff --git a/bundles/datastar-core.js b/bundles/datastar-core.js index 4e7bb91a6..3e9daea70 100644 --- a/bundles/datastar-core.js +++ b/bundles/datastar-core.js @@ -1,16 +1,4 @@ -"use strict";(()=>{var D="datastar",Re="datastar-event",Ge="Datastar-Request";var Ke="type module";var O={Morph:"morph",Inner:"inner",Outer:"outer",Prepend:"prepend",Append:"append",Before:"before",After:"after",UpsertAttributes:"upsertAttributes"},qe=O.Morph,L={MergeFragments:"datastar-merge-fragments",MergeSignals:"datastar-merge-signals",RemoveFragments:"datastar-remove-fragments",RemoveSignals:"datastar-remove-signals",ExecuteScript:"datastar-execute-script"};var G=t=>{let e=new Error;return e.name=`${D}${t}`,e},y=G(400),X=G(409),W=G(404),F=G(403),ae=G(405),Je=G(503);function $(t){let e={};for(let[n,r]of Object.entries(t))n.startsWith("_")||(typeof r=="object"&&!Array.isArray(r)?e[n]=$(r):e[n]=r);return e}function le(t,e,n){let r={};if(!n)Object.assign(r,e);else for(let o in e){let i=t[o]?.value;i==null&&(r[o]=e[o])}return r}async function Ye(t,e){let n=t.getReader(),r;for(;!(r=await n.read()).done;)e(r.value)}function Ze(t){let e,n,r,o=!1;return function(s){e===void 0?(e=s,n=0,r=-1):e=zt(e,s);let a=e.length,u=0;for(;n0){let u=o.decode(s.subarray(0,a)),m=a+(s[a+1]===32?2:1),l=o.decode(s.subarray(m));switch(u){case"data":r.data=r.data?r.data+` -`+l:l;break;case"event":r.event=l;break;case"id":t(r.id=l);break;case"retry":let c=parseInt(l,10);isNaN(c)||e(r.retry=c);break}}}}function zt(t,e){let n=new Uint8Array(t.length+e.length);return n.set(t),n.set(e,t.length),n}function ze(){return{data:"",event:"",id:"",retry:void 0}}var et="text/event-stream",Yt=1e3,Qe="last-event-id";function Pe(t,{signal:e,headers:n,onopen:r,onmessage:o,onclose:i,onerror:s,openWhenHidden:a,fetch:u,retryScaler:m=2,retryMaxWaitMs:l=3e4,retryMaxCount:c=10,...f}){return new Promise((g,_)=>{let E=0,S={...n};S.accept||(S.accept=et);let v;function p(){v.abort(),document.hidden||M()}a||document.addEventListener("visibilitychange",p);let d=Yt,T=0;function h(){document.removeEventListener("visibilitychange",p),window.clearTimeout(T),v.abort()}e?.addEventListener("abort",()=>{h(),g()});let x=u??window.fetch,R=r??function(){};async function M(){v=new AbortController;try{let N=await x(t,{...f,headers:S,signal:v.signal});await R(N),await Ye(N.body,Ze(Xe(P=>{P?S[Qe]=P:delete S[Qe]},P=>{d=P},o))),i?.(),h(),g()}catch(N){if(!v.signal.aborted)try{let P=s?.(N)??d;window.clearTimeout(T),T=window.setTimeout(M,P),d*=m,d=Math.min(d,l),E++,E>=c?(h(),_(Je)):console.error(`Datastar failed to reach ${f.method}:${t.toString()} retry in ${P}ms`)}catch(P){h(),_(P)}}}M()})}var K=`${D}-sse`,Me=`${D}-settling`,U=`${D}-swapping`,ue="started",ce="finished";function k(t,e){document.addEventListener(K,n=>{if(n.detail.type!=t)return;let{argsRaw:r}=n.detail;e(r)})}var tt=t=>`${t}`.includes("text/event-stream");function De(t,e){document.dispatchEvent(new CustomEvent(K,{detail:{type:t,argsRaw:e}}))}function I(t){return async(e,n,r)=>{if(!n?.length)throw y;let o=r?.onlyRemoteSignals??!0,i=Object.assign({"Content-Type":"application/json",[Ge]:!0},r?.headers),s=e.signals().value,a=Object.assign({},s);o&&(a=$(a));let u=JSON.stringify(a),{el:{id:m}}=e;De(ue,{elID:m});let l=new URL(n,window.location.origin);t=t.toUpperCase();let c={method:t,headers:i,onmessage:f=>{if(!f.event.startsWith(D))return;let g=f.event,_={},E=f.data.split(` -`);for(let v of E){let p=v.indexOf(" "),d=v.slice(0,p),T=_[d];T||(T=[],_[d]=T);let h=v.slice(p+1).trim();T.push(h)}let S={};for(let[v,p]of Object.entries(_))S[v]=p.join(` -`);De(g,S)},onerror:f=>{if(tt(f))throw f;f&&console.error(f.message)},onclose:()=>{De(ce,{elID:m})}};if(t==="GET"){let f=new URLSearchParams(l.search);f.append(D,u),l.search=f.toString()}else c.body=u;try{let f=l.toString();await Pe(f,c)}catch(f){if(!tt(f))throw f}}}var Jn={pluginType:3,name:"delete",method:I("delete")};var Xn={pluginType:3,name:"get",method:I("get")};var nr={pluginType:3,name:"patch",method:I("patch")};var sr={pluginType:3,name:"post",method:I("post")};var cr={pluginType:3,name:"put",method:I("put")};var vr={pluginType:3,name:"clipboard",method:(t,e)=>{if(!navigator.clipboard)throw F;navigator.clipboard.writeText(e)}};var xr={pluginType:3,name:"setAll",method:(t,e,n)=>{let r=new RegExp(e);t.walkSignals((o,i)=>r.test(o)&&(i.value=n))}};var Mr={pluginType:3,name:"toggleAll",method:(t,e)=>{let n=new RegExp(e);t.walkSignals((r,o)=>n.test(r)&&(o.value=!o.value))}};var Ir={pluginType:3,name:"clampFit",method:(t,e,n,r,o,i)=>Math.max(o,Math.min(i,(e-n)/(r-n)*(i-o)+o))};var Hr={pluginType:3,name:"clampFitInt",method:(t,e,n,r,o,i)=>Math.round(Math.max(o,Math.min(i,(e-n)/(r-n)*(i-o)+o)))};var $r={pluginType:3,name:"fit",method:(t,e,n,r,o,i)=>(e-n)/(r-n)*(i-o)+o};var Br={pluginType:3,name:"fitInt",method:(t,e,n,r,o,i)=>Math.round((e-n)/(r-n)*(i-o)+o)};var Zt=`${D}-indicator`,so=`${Zt}-loading`,ao={pluginType:1,name:"indicator",mustHaveEmptyKey:!0,onLoad:t=>{let{expression:e,upsertSignal:n,el:r}=t,i=n(e,!1),s=a=>{let{type:u,argsRaw:{elID:m}}=a.detail;if(m===r.id)switch(u){case ue:i.value=!0;break;case ce:i.value=!1;break}};return document.addEventListener(K,s),()=>{document.removeEventListener(K,s)}}};var nt={pluginType:1,name:"computed",mustNotEmptyKey:!0,onLoad:t=>{let e=t.signals();return e[t.key]=t.reactivity.computed(()=>t.expressionFn(t)),()=>{let n=t.signals();delete n[t.key]}}};var rt={pluginType:1,name:"mergeSignals",removeNewLines:!0,macros:{pre:[{pluginType:0,name:"signals",regexp:/(?.+)/g,replacer:t=>{let{whole:e}=t;return`Object.assign({...ctx.signals()}, ${e})`}}]},allowedModifiers:new Set(["ifmissing"]),onLoad:t=>{let e=t.expressionFn(t),n=le(t.signals(),e,t.modifiers.has("ifmissing"));t.mergeSignals(n),delete t.el.dataset[t.rawKey]}};var ot={pluginType:1,name:"star",onLoad:()=>{alert("YOU ARE PROBABLY OVERCOMPLICATING IT")}};var fe=t=>t.replace(/[A-Z]+(?![a-z])|[A-Z]/g,(e,n)=>(n?"-":"")+e.toLowerCase()),H=t=>t.trim()==="true";var Xt=/^data:(?[^;]+);base64,(?.*)$/,it=["change","input","keydown"],Ro={pluginType:1,name:"bind",onLoad:t=>{let{el:e,expression:n,expressionFn:r,key:o,upsertSignal:i,reactivity:{effect:s}}=t,a=()=>{},u=()=>{},m=o==="";if(m){if(typeof n!="string")throw new Error("Invalid expression");if(n.includes("$"))throw new Error("Not an expression");let c=e.tagName.toLowerCase(),f="",g=c.includes("input"),_=e.getAttribute("type"),E=c.includes("checkbox")||g&&_==="checkbox";E&&(f=!1);let S=c.includes("select"),v=c.includes("radio")||g&&_==="radio",p=g&&_==="file";v&&(e.getAttribute("name")?.length||e.setAttribute("name",n));let d=i(n,f);a=()=>{let T="value"in e,h=d.value,x=`${h}`;if(E||v){let R=e;E?R.checked=h:v&&(R.checked=x===R.value)}else if(!p)if(S){let R=e;R.multiple?Array.from(R.options).forEach(M=>{M?.disabled||(M.selected=h.includes(M.value))}):R.value=x}else T?e.value=x:e.setAttribute("value",x)},u=async()=>{if(p){let x=[...e?.files||[]],R=[],M=[],N=[];await Promise.all(x.map(Be=>new Promise(Jt=>{let Z=new FileReader;Z.onload=()=>{if(typeof Z.result!="string")throw y;let xe=Z.result.match(Xt);if(!xe?.groups)throw y;R.push(xe.groups.contents),M.push(xe.groups.mime),N.push(Be.name)},Z.onloadend=()=>Jt(void 0),Z.readAsDataURL(Be)}))),d.value=R;let P=t.signals(),Ue=`${n}Mimes`,je=`${n}Names`;Ue in P&&(P[`${Ue}`].value=M),je in P&&(P[`${je}`].value=N);return}let T=d.value,h=e||e;if(typeof T=="number")d.value=Number(h.value||h.getAttribute("value"));else if(typeof T=="string")d.value=h.value||h.getAttribute("value")||"";else if(typeof T=="boolean")E?d.value=h.checked||h.getAttribute("checked")==="true":d.value=!!(h.value||h.getAttribute("value"));else if(!(typeof T>"u"))if(typeof T=="bigint")d.value=BigInt(h.value||h.getAttribute("value")||"0");else if(Array.isArray(T)){if(S){let M=[...e.selectedOptions].map(N=>N.value);d.value=M}else d.value=JSON.parse(h.value).split(",");console.log(h.value)}else throw ae}}else{let c=fe(o);a=()=>{let f=r(t),g;typeof f=="string"?g=f:g=JSON.stringify(f),!g||g==="false"||g==="null"||g==="undefined"?e.removeAttribute(c):e.setAttribute(c,g)}}m&&it.forEach(c=>{e.addEventListener(c,u)});let l=s(async()=>{a()});return()=>{l(),m&&it.forEach(c=>{e.removeEventListener(c,u)})}}};var Do={pluginType:1,name:"class",mustHaveEmptyKey:!0,mustNotEmptyExpression:!0,onLoad:t=>t.reactivity.effect(()=>{let e=t.expressionFn(t);for(let[n,r]of Object.entries(e)){let o=n.split(" ");r?t.el.classList.add(...o):t.el.classList.remove(...o)}})};function Le(t){if(!t||t?.length===0)return 0;for(let e of t){if(e.endsWith("ms"))return Number(e.replace("ms",""));if(e.endsWith("s"))return Number(e.replace("s",""))*1e3;try{return parseFloat(e)}catch{}}return 0}function Q(t,e,n=!1){return t?t.includes(e)||n:!1}function st(t,e,n=!1,r=!0){let o=-1,i=()=>o&&clearTimeout(o);return function(...a){i(),n&&!o&&t(...a),o=setTimeout(()=>{r&&t(...a),i()},e)}}function at(t,e,n=!0,r=!1){let o=!1;return function(...s){o||(n&&t(...s),o=!0,setTimeout(()=>{o=!1,r&&t(...s)},e))}}var Qt=new Set(["window","once","passive","capture","debounce","throttle","remote","outside"]),lt="",Wo={pluginType:1,name:"on",mustNotEmptyKey:!0,mustNotEmptyExpression:!0,argumentNames:["evt"],onLoad:t=>{let{el:e,key:n,expressionFn:r}=t,o=t.el;t.modifiers.get("window")&&(o=window);let i=c=>{r(t,c)},s=t.modifiers.get("debounce");if(s){let c=Le(s),f=Q(s,"leading",!1),g=Q(s,"noTrail",!0);i=st(i,c,f,g)}let a=t.modifiers.get("throttle");if(a){let c=Le(a),f=Q(a,"noLead",!0),g=Q(a,"noTrail",!1);i=at(i,c,f,g)}let u={capture:!0,passive:!1,once:!1};t.modifiers.has("capture")||(u.capture=!1),t.modifiers.has("passive")&&(u.passive=!0),t.modifiers.has("once")&&(u.once=!0),[...t.modifiers.keys()].filter(c=>!Qt.has(c)).forEach(c=>{let f=t.modifiers.get(c)||[],g=i;i=()=>{let E=event,S=E[c],v;if(typeof S=="function")v=S(...f);else if(typeof S=="boolean")v=S;else if(typeof S=="string"){let p=S.toLowerCase().trim(),d=f.join("").toLowerCase().trim();v=p===d}else throw y;v&&g(E)}});let l=fe(n).toLowerCase();switch(l){case"load":return i(),delete t.el.dataset.onLoad,()=>{};case"raf":let c,f=()=>{i(),c=requestAnimationFrame(f)};return c=requestAnimationFrame(f),()=>{c&&cancelAnimationFrame(c)};case"signals-change":return t.reactivity.effect(()=>{let E=t.signals().value;t.modifiers.has("remote")&&(E=$(E));let S=JSON.stringify(E);lt!==S&&(lt=S,i())});default:if(t.modifiers.has("outside")){o=document;let _=i,E=!1;i=v=>{let p=v?.target;if(!p)return;let d=e.id===p.id;d&&E&&(E=!1),!d&&!E&&(_(v),E=!0)}}return o.addEventListener(l,i,u),()=>{o.removeEventListener(l,i)}}}};var jo={pluginType:1,name:"ref",mustHaveEmptyKey:!0,mustNotEmptyExpression:!0,bypassExpressionFunctionCreation:()=>!0,onLoad:t=>{let e=t.expression;return t.upsertSignal(e,t.el),()=>{t.removeSignals(e)}}};var qo={pluginType:1,name:"text",mustHaveEmptyKey:!0,onLoad:t=>{let{el:e,expressionFn:n}=t;if(!(e instanceof HTMLElement))throw y;return t.reactivity.effect(()=>{let r=n(t);e.textContent=`${r}`})}};var oi={pluginType:1,name:"persist",allowedModifiers:new Set(["local","session","remote"]),onLoad:t=>{let e=t.key||D,n=t.expression,r=new Set;if(n.trim()!==""){let l=t.expressionFn(t).split(" ");for(let c of l)r.add(c)}let o="",i=t.modifiers.has("session")?"session":"local",s=t.modifiers.has("remote"),a=m=>{let l=t.signals();if(s&&(l=$(l)),r.size>0){let f={};for(let g of r){let _=g.split("."),E=f,S=l;for(let p=0;p<_.length-1;p++){let d=_[p];E[d]||(E[d]={}),E=E[d],S=S[d]}let v=_[_.length-1];E[v]=S[v]}l=f}let c=JSON.stringify(l);c!==o&&(i==="session"?window.sessionStorage.setItem(e,c):window.localStorage.setItem(e,c),o=c)};window.addEventListener(Re,a);let u;if(i==="session"?u=window.sessionStorage.getItem(e):u=window.localStorage.getItem(e),u){let m=JSON.parse(u);for(let l in m)t.upsertSignal(l,m[l])}return()=>{window.removeEventListener(Re,a)}}};var ui={pluginType:1,name:"replaceUrl",mustHaveEmptyKey:!0,mustNotEmptyExpression:!0,onLoad:t=>t.reactivity.effect(()=>{let e=t.expressionFn(t),n=window.location.href,r=new URL(e,n).toString();window.history.replaceState({},"",r)})};var ut="once",ct="half",ft="full",di={pluginType:1,name:"intersects",allowedModifiers:new Set([ut,ct,ft]),mustHaveEmptyKey:!0,onLoad:t=>{let{modifiers:e}=t,n={threshold:0};e.has(ft)?n.threshold=1:e.has(ct)&&(n.threshold=.5);let r=new IntersectionObserver(o=>{o.forEach(i=>{i.isIntersecting&&(t.expressionFn(t),e.has(ut)&&(r.disconnect(),delete t.el.dataset[t.rawKey]))})},n);return r.observe(t.el),()=>r.disconnect()}};function pt(t){if(t.id)return t.id;let e=0,n=o=>(e=(e<<5)-e+o,e&e),r=o=>o.split("").forEach(i=>n(i.charCodeAt(0)));for(;t.parentNode;){if(t.id){r(`${t.id}`);break}else if(t===t.ownerDocument.documentElement)r(t.tagName);else{for(let o=1,i=t;i.previousElementSibling;i=i.previousElementSibling,o++)n(o);t=t.parentNode}t=t.parentNode}return D+e}function mt(t,e,n=!0){if(!(t instanceof HTMLElement||t instanceof SVGElement))throw W;t.tabIndex||t.setAttribute("tabindex","0"),t.scrollIntoView(e),n&&t.focus()}var pe="smooth",Ne="instant",Oe="auto",dt="hstart",gt="hcenter",ht="hend",yt="hnearest",bt="vstart",St="vcenter",Et="vend",vt="vnearest",en="focus",me="center",Tt="start",At="end",_t="nearest",vi={pluginType:1,name:"scrollIntoView",mustHaveEmptyKey:!0,mustHaveEmptyExpression:!0,allowedModifiers:new Set([pe,Ne,Oe,dt,gt,ht,yt,bt,St,Et,vt,en]),onLoad:({el:t,modifiers:e,rawKey:n})=>{t.tabIndex||t.setAttribute("tabindex","0");let r={behavior:pe,block:me,inline:me};return e.has(pe)&&(r.behavior=pe),e.has(Ne)&&(r.behavior=Ne),e.has(Oe)&&(r.behavior=Oe),e.has(dt)&&(r.inline=Tt),e.has(gt)&&(r.inline=me),e.has(ht)&&(r.inline=At),e.has(yt)&&(r.inline=_t),e.has(bt)&&(r.block=Tt),e.has(St)&&(r.block=me),e.has(Et)&&(r.block=At),e.has(vt)&&(r.block=_t),mt(t,r,e.has("focus")),delete t.dataset[n],()=>{}}};var _i={pluginType:1,name:"show",mustHaveEmptyKey:!0,mustNotEmptyExpression:!0,onLoad:t=>t.reactivity.effect(async()=>{t.expressionFn(t)?t.el.style.display==="none"&&t.el.style.removeProperty("display"):t.el.style.setProperty("display","none")})};var ee=document,q=!!ee.startViewTransition;var ke="view-transition",Mi={pluginType:1,name:ke,onGlobalInit(){let t=!1;if(document.head.childNodes.forEach(e=>{e instanceof HTMLMetaElement&&e.name===ke&&(t=!0)}),!t){let e=document.createElement("meta");e.name=ke,e.content="same-origin",document.head.appendChild(e)}},onLoad:t=>{if(!q){console.error("Browser does not support view transitions");return}return t.reactivity.effect(()=>{let{el:e,expressionFn:n}=t,r=n(t);if(!r)return;let o=e.style;o.viewTransitionName=r})}};var wt="[a-zA-Z_$]+",tn=wt+"[0-9a-zA-Z_$.]*";function de(t,e,n,r=!0){let o=r?tn:wt;return new RegExp(`(?${t}(?<${e}>${o})${n})`,"g")}var xt={name:"action",pluginType:0,regexp:de("@","action","(?\\((?.*)\\))",!1),replacer:({action:t,args:e})=>{let n=["ctx"];e&&n.push(...e.split(",").map(o=>o.trim()));let r=n.join(",");return`ctx.actions.${t}.method(${r})`}};var Rt={name:"signal",pluginType:0,regexp:de("\\$","signal","(?\\([^\\)]*\\))?"),replacer:t=>{let{signal:e,method:n}=t,r="ctx.signals()";if(!n?.length)return`${r}.${e}.value`;let o=e.split("."),i=o.pop(),s=o.join(".");return`${r}.${s}.value.${i}${n}`}};var ss={pluginType:2,name:L.ExecuteScript,onGlobalInit:async()=>{k(L.ExecuteScript,({autoRemove:t=`${!0}`,attributes:e=Ke,script:n})=>{let r=H(t);if(!n?.length)throw y;let o=document.createElement("script");e.split(` -`).forEach(i=>{let s=i.indexOf(" "),a=s?i.slice(0,s):i,u=s?i.slice(s):"";o.setAttribute(a.trim(),u.trim())}),o.text=n,document.head.appendChild(o),r&&o.remove()})}};var he=new WeakSet;function Lt(t,e,n={}){t instanceof Document&&(t=t.documentElement);let r;typeof e=="string"?r=ln(e):r=e;let o=un(r),i=on(t,o,n);return Nt(t,o,i)}function Nt(t,e,n){if(n.head.block){let r=t.querySelector("head"),o=e.querySelector("head");if(r&&o){let i=kt(o,r,n);Promise.all(i).then(()=>{Nt(t,e,Object.assign(n,{head:{block:!1,ignore:!0}}))});return}}if(n.morphStyle==="innerHTML")return Ot(e,t,n),t.children;if(n.morphStyle==="outerHTML"||n.morphStyle==null){let r=fn(e,t,n);if(!r)throw W;let o=r?.previousSibling,i=r?.nextSibling,s=ye(t,r,n);return r?cn(o,s,i):[]}else throw y}function ye(t,e,n){if(!(n.ignoreActive&&t===document.activeElement))if(e==null){if(n.callbacks.beforeNodeRemoved(t)===!1)return;t.remove(),n.callbacks.afterNodeRemoved(t);return}else{if(be(t,e))return n.callbacks.beforeNodeMorphed(t,e)===!1?void 0:(t instanceof HTMLHeadElement&&n.head.ignore||(e instanceof HTMLHeadElement&&t instanceof HTMLHeadElement&&n.head.style!==O.Morph?kt(e,t,n):(rn(e,t),Ot(e,t,n))),n.callbacks.afterNodeMorphed(t,e),t);if(n.callbacks.beforeNodeRemoved(t)===!1||n.callbacks.beforeNodeAdded(e)===!1)return;if(!t.parentElement)throw y;return t.parentElement.replaceChild(e,t),n.callbacks.afterNodeAdded(e),n.callbacks.afterNodeRemoved(t),e}}function Ot(t,e,n){let r=t.firstChild,o=e.firstChild,i;for(;r;){if(i=r,r=i.nextSibling,o==null){if(n.callbacks.beforeNodeAdded(i)===!1)return;e.appendChild(i),n.callbacks.afterNodeAdded(i),j(n,i);continue}if(It(i,o,n)){ye(o,i,n),o=o.nextSibling,j(n,i);continue}let s=sn(t,e,i,o,n);if(s){o=Pt(o,s,n),ye(s,i,n),j(n,i);continue}let a=an(t,i,o,n);if(a){o=Pt(o,a,n),ye(a,i,n),j(n,i);continue}if(n.callbacks.beforeNodeAdded(i)===!1)return;e.insertBefore(i,o),n.callbacks.afterNodeAdded(i),j(n,i)}for(;o!==null;){let s=o;o=o.nextSibling,Ct(s,n)}}function rn(t,e){let n=t.nodeType;if(n===1){for(let r of t.attributes)e.getAttribute(r.name)!==r.value&&e.setAttribute(r.name,r.value);for(let r of e.attributes)t.hasAttribute(r.name)||e.removeAttribute(r.name)}if((n===Node.COMMENT_NODE||n===Node.TEXT_NODE)&&e.nodeValue!==t.nodeValue&&(e.nodeValue=t.nodeValue),t instanceof HTMLInputElement&&e instanceof HTMLInputElement&&t.type!=="file")e.value=t.value||"",ge(t,e,"value"),ge(t,e,"checked"),ge(t,e,"disabled");else if(t instanceof HTMLOptionElement)ge(t,e,"selected");else if(t instanceof HTMLTextAreaElement&&e instanceof HTMLTextAreaElement){let r=t.value,o=e.value;r!==o&&(e.value=r),e.firstChild&&e.firstChild.nodeValue!==r&&(e.firstChild.nodeValue=r)}}function ge(t,e,n){let r=t.getAttribute(n),o=e.getAttribute(n);r!==o&&(r?e.setAttribute(n,r):e.removeAttribute(n))}function kt(t,e,n){let r=[],o=[],i=[],s=[],a=n.head.style,u=new Map;for(let l of t.children)u.set(l.outerHTML,l);for(let l of e.children){let c=u.has(l.outerHTML),f=n.head.shouldReAppend(l),g=n.head.shouldPreserve(l);c||g?f?o.push(l):(u.delete(l.outerHTML),i.push(l)):a===O.Append?f&&(o.push(l),s.push(l)):n.head.shouldRemove(l)!==!1&&o.push(l)}s.push(...u.values());let m=[];for(let l of s){let c=document.createRange().createContextualFragment(l.outerHTML).firstChild;if(!c)throw y;if(n.callbacks.beforeNodeAdded(c)){if(c.hasAttribute("href")||c.hasAttribute("src")){let f,g=new Promise(_=>{f=_});c.addEventListener("load",function(){f(void 0)}),m.push(g)}e.appendChild(c),n.callbacks.afterNodeAdded(c),r.push(c)}}for(let l of o)n.callbacks.beforeNodeRemoved(l)!==!1&&(e.removeChild(l),n.callbacks.afterNodeRemoved(l));return n.head.afterHeadMorphed(e,{added:r,kept:i,removed:o}),m}function V(){}function on(t,e,n){return{target:t,newContent:e,config:n,morphStyle:n.morphStyle,ignoreActive:n.ignoreActive,idMap:gn(t,e),deadIds:new Set,callbacks:Object.assign({beforeNodeAdded:V,afterNodeAdded:V,beforeNodeMorphed:V,afterNodeMorphed:V,beforeNodeRemoved:V,afterNodeRemoved:V},n.callbacks),head:Object.assign({style:"merge",shouldPreserve:r=>r.getAttribute("im-preserve")==="true",shouldReAppend:r=>r.getAttribute("im-re-append")==="true",shouldRemove:V,afterHeadMorphed:V},n.head)}}function It(t,e,n){return!t||!e?!1:t.nodeType===e.nodeType&&t.tagName===e.tagName?t?.id?.length&&t.id===e.id?!0:te(n,t,e)>0:!1}function be(t,e){return!t||!e?!1:t.nodeType===e.nodeType&&t.tagName===e.tagName}function Pt(t,e,n){for(;t!==e;){let r=t;if(t=t?.nextSibling,!r)throw y;Ct(r,n)}return j(n,e),e.nextSibling}function sn(t,e,n,r,o){let i=te(o,n,e),s=null;if(i>0){s=r;let a=0;for(;s!=null;){if(It(n,s,o))return s;if(a+=te(o,s,t),a>i)return null;s=s.nextSibling}}return s}function an(t,e,n,r){let o=n,i=e.nextSibling,s=0;for(;o&&i;){if(te(r,o,t)>0)return null;if(be(e,o))return o;if(be(i,o)&&(s++,i=i.nextSibling,s>=2))return null;o=o.nextSibling}return o}var Mt=new DOMParser;function ln(t){let e=t.replace(/]*>|>)([\s\S]*?)<\/svg>/gim,"");if(e.match(/<\/html>/)||e.match(/<\/head>/)||e.match(/<\/body>/)){let n=Mt.parseFromString(t,"text/html");if(e.match(/<\/html>/))return he.add(n),n;{let r=n.firstChild;return r?(he.add(r),r):null}}else{let r=Mt.parseFromString(``,"text/html").body.querySelector("template")?.content;if(!r)throw W;return he.add(r),r}}function un(t){if(t==null)return document.createElement("div");if(he.has(t))return t;if(t instanceof Node){let e=document.createElement("div");return e.append(t),e}else{let e=document.createElement("div");for(let n of[...t])e.append(n);return e}}function cn(t,e,n){let r=[],o=[];for(;t;)r.push(t),t=t.previousSibling;for(;r.length>0;){let i=r.pop();o.push(i),e?.parentElement?.insertBefore(i,e)}for(o.push(e);n;)r.push(n),o.push(n),n=n.nextSibling;for(;r.length;)e?.parentElement?.insertBefore(r.pop(),e.nextSibling);return o}function fn(t,e,n){let r=t.firstChild,o=r,i=0;for(;r;){let s=pn(r,e,n);s>i&&(o=r,i=s),r=r.nextSibling}return o}function pn(t,e,n){return be(t,e)?.5+te(n,t,e):0}function Ct(t,e){j(e,t),e.callbacks.beforeNodeRemoved(t)!==!1&&(t.remove(),e.callbacks.afterNodeRemoved(t))}function mn(t,e){return!t.deadIds.has(e)}function dn(t,e,n){return t.idMap.get(n)?.has(e)||!1}function j(t,e){let n=t.idMap.get(e);if(n)for(let r of n)t.deadIds.add(r)}function te(t,e,n){let r=t.idMap.get(e);if(!r)return 0;let o=0;for(let i of r)mn(t,i)&&dn(t,i,n)&&++o;return o}function Dt(t,e){let n=t.parentElement,r=t.querySelectorAll("[id]");for(let o of r){let i=o;for(;i!==n&&i;){let s=e.get(i);s==null&&(s=new Set,e.set(i,s)),s.add(o.id),i=i.parentElement}}}function gn(t,e){let n=new Map;return Dt(t,n),Dt(e,n),n}var bs={pluginType:2,name:L.MergeFragments,onGlobalInit:async t=>{let e=document.createElement("template");k(L.MergeFragments,({fragments:n="
",selector:r="",mergeMode:o=qe,settleDuration:i=`${300}`,useViewTransition:s=`${!1}`})=>{let a=parseInt(i),u=H(s);e.innerHTML=n.trim(),[...e.content.children].forEach(l=>{if(!(l instanceof Element))throw y;let c=r||`#${l.getAttribute("id")}`,g=[...document.querySelectorAll(c)||[]];if(!g.length)throw y;q&&u?ee.startViewTransition(()=>Ft(t,o,a,l,g)):Ft(t,o,a,l,g)})})}};function Ft(t,e,n,r,o){for(let i of o){i.classList.add(U);let s=i.outerHTML,a=i;switch(e){case O.Morph:let m=Lt(a,r,{callbacks:{beforeNodeRemoved:(l,c)=>(t.cleanup(l),!0)}});if(!m?.length)throw y;a=m[0];break;case O.Inner:a.innerHTML=r.innerHTML;break;case O.Outer:a.replaceWith(r);break;case O.Prepend:a.prepend(r);break;case O.Append:a.append(r);break;case O.Before:a.before(r);break;case O.After:a.after(r);break;case O.UpsertAttributes:r.getAttributeNames().forEach(l=>{let c=r.getAttribute(l);a.setAttribute(l,c)});break;default:throw y}t.cleanup(a),a.classList.add(U),t.applyPlugins(document.body),setTimeout(()=>{i.classList.remove(U),a.classList.remove(U)},n);let u=a.outerHTML;s!==u&&(a.classList.add(Me),setTimeout(()=>{a.classList.remove(Me)},n))}}var ws={pluginType:2,name:L.MergeSignals,onGlobalInit:async t=>{k(L.MergeSignals,({signals:e="{}",onlyIfMissing:n=`${!1}`})=>{let r=H(n),o=` return Object.assign({...ctx.signals()}, ${e})`;try{let s=new Function("ctx",o)(t),a=le(t.signals(),s,r);t.mergeSignals(a),t.applyPlugins(document.body)}catch(i){console.log(o),console.error(i);debugger}})}};var Os={pluginType:2,name:L.RemoveFragments,onGlobalInit:async()=>{k(L.RemoveFragments,({selector:t,settleDuration:e=`${300}`,useViewTransition:n=`${!1}`})=>{if(!t.length)throw y;let r=parseInt(e),o=H(n),i=document.querySelectorAll(t),s=()=>{for(let a of i)a.classList.add(U);setTimeout(()=>{for(let a of i)a.remove()},r)};q&&o?ee.startViewTransition(()=>s()):s()})}};var Vs={pluginType:2,name:L.RemoveSignals,onGlobalInit:async t=>{k(L.RemoveSignals,({paths:e=""})=>{let n=e.split(` -`).map(r=>r.trim());if(!n?.length)throw y;t.removeSignals(...n)})}};var yn=Symbol.for("preact-signals"),C=1,J=2,oe=4,Y=8,Se=16,z=32;function ve(){re++}function Te(){if(re>1){re--;return}let t,e=!1;for(;ne!==void 0;){let n=ne;for(ne=void 0,Fe++;n!==void 0;){let r=n._nextBatchedEffect;if(n._nextBatchedEffect=void 0,n._flags&=~J,!(n._flags&Y)&&Wt(n))try{n._callback()}catch(o){e||(t=o,e=!0)}n=r}}if(Fe=0,re--,e)throw t}function Ht(t){if(re>0)return t();ve();try{return t()}finally{Te()}}var A;var ne,re=0,Fe=0,Ee=0;function Vt(t){if(A===void 0)return;let e=t._node;if(e===void 0||e._target!==A)return e={_version:0,_source:t,_prevSource:A._sources,_nextSource:void 0,_target:A,_prevTarget:void 0,_nextTarget:void 0,_rollbackNode:e},A._sources!==void 0&&(A._sources._nextSource=e),A._sources=e,t._node=e,A._flags&z&&t._subscribe(e),e;if(e._version===-1)return e._version=0,e._nextSource!==void 0&&(e._nextSource._prevSource=e._prevSource,e._prevSource!==void 0&&(e._prevSource._nextSource=e._nextSource),e._prevSource=A._sources,e._nextSource=void 0,A._sources._nextSource=e,A._sources=e),e}function w(t){this._value=t,this._version=0,this._node=void 0,this._targets=void 0}w.prototype.brand=yn;w.prototype._refresh=function(){return!0};w.prototype._subscribe=function(t){this._targets!==t&&t._prevTarget===void 0&&(t._nextTarget=this._targets,this._targets!==void 0&&(this._targets._prevTarget=t),this._targets=t)};w.prototype._unsubscribe=function(t){if(this._targets!==void 0){let e=t._prevTarget,n=t._nextTarget;e!==void 0&&(e._nextTarget=n,t._prevTarget=void 0),n!==void 0&&(n._prevTarget=e,t._nextTarget=void 0),t===this._targets&&(this._targets=n)}};w.prototype.subscribe=function(t){return Ve(()=>{let e=this.value,n=A;A=void 0;try{t(e)}finally{A=n}})};w.prototype.valueOf=function(){return this.value};w.prototype.toString=function(){return this.value+""};w.prototype.toJSON=function(){return this.value};w.prototype.peek=function(){let t=A;A=void 0;try{return this.value}finally{A=t}};Object.defineProperty(w.prototype,"value",{get(){let t=Vt(this);return t!==void 0&&(t._version=this._version),this._value},set(t){if(t!==this._value){if(Fe>100)throw y;this._value=t,this._version++,Ee++,ve();try{for(let e=this._targets;e!==void 0;e=e._nextTarget)e._target._notify()}finally{Te()}}}});function Ae(t){return new w(t)}function Wt(t){for(let e=t._sources;e!==void 0;e=e._nextSource)if(e._source._version!==e._version||!e._source._refresh()||e._source._version!==e._version)return!0;return!1}function $t(t){for(let e=t._sources;e!==void 0;e=e._nextSource){let n=e._source._node;if(n!==void 0&&(e._rollbackNode=n),e._source._node=e,e._version=-1,e._nextSource===void 0){t._sources=e;break}}}function Ut(t){let e=t._sources,n;for(;e!==void 0;){let r=e._prevSource;e._version===-1?(e._source._unsubscribe(e),r!==void 0&&(r._nextSource=e._nextSource),e._nextSource!==void 0&&(e._nextSource._prevSource=r)):n=e,e._source._node=e._rollbackNode,e._rollbackNode!==void 0&&(e._rollbackNode=void 0),e=r}t._sources=n}function B(t){w.call(this,void 0),this._fn=t,this._sources=void 0,this._globalVersion=Ee-1,this._flags=oe}B.prototype=new w;B.prototype._refresh=function(){if(this._flags&=~J,this._flags&C)return!1;if((this._flags&(oe|z))===z||(this._flags&=~oe,this._globalVersion===Ee))return!0;if(this._globalVersion=Ee,this._flags|=C,this._version>0&&!Wt(this))return this._flags&=~C,!0;let t=A;try{$t(this),A=this;let e=this._fn();(this._flags&Se||this._value!==e||this._version===0)&&(this._value=e,this._flags&=~Se,this._version++)}catch(e){this._value=e,this._flags|=Se,this._version++}return A=t,Ut(this),this._flags&=~C,!0};B.prototype._subscribe=function(t){if(this._targets===void 0){this._flags|=oe|z;for(let e=this._sources;e!==void 0;e=e._nextSource)e._source._subscribe(e)}w.prototype._subscribe.call(this,t)};B.prototype._unsubscribe=function(t){if(this._targets!==void 0&&(w.prototype._unsubscribe.call(this,t),this._targets===void 0)){this._flags&=~z;for(let e=this._sources;e!==void 0;e=e._nextSource)e._source._unsubscribe(e)}};B.prototype._notify=function(){if(!(this._flags&J)){this._flags|=oe|J;for(let t=this._targets;t!==void 0;t=t._nextTarget)t._target._notify()}};Object.defineProperty(B.prototype,"value",{get(){if(this._flags&C)throw y;let t=Vt(this);if(this._refresh(),t!==void 0&&(t._version=this._version),this._flags&Se)throw this._value;return this._value}});function jt(t){return new B(t)}function Bt(t){let e=t._cleanup;if(t._cleanup=void 0,typeof e=="function"){ve();let n=A;A=void 0;try{e()}catch(r){throw t._flags&=~C,t._flags|=Y,He(t),r}finally{A=n,Te()}}}function He(t){for(let e=t._sources;e!==void 0;e=e._nextSource)e._source._unsubscribe(e);t._fn=void 0,t._sources=void 0,Bt(t)}function bn(t){if(A!==this)throw y;Ut(this),A=t,this._flags&=~C,this._flags&Y&&He(this),Te()}function ie(t){this._fn=t,this._cleanup=void 0,this._sources=void 0,this._nextBatchedEffect=void 0,this._flags=z}ie.prototype._callback=function(){let t=this._start();try{if(this._flags&Y||this._fn===void 0)return;let e=this._fn();typeof e=="function"&&(this._cleanup=e)}finally{t()}};ie.prototype._start=function(){if(this._flags&C)throw y;this._flags|=C,this._flags&=~Y,Bt(this),$t(this),ve();let t=A;return A=this,bn.bind(this,t)};ie.prototype._notify=function(){this._flags&J||(this._flags|=J,this._nextBatchedEffect=ne,ne=this)};ie.prototype._dispose=function(){this._flags|=Y,this._flags&C||He(this)};function Ve(t){let e=new ie(t);try{e._callback()}catch(n){throw e._dispose(),n}return e._dispose.bind(e)}var _e=class{get value(){return We(this)}set value(e){Ht(()=>Sn(this,e))}peek(){return We(this,{peek:!0})}},se=t=>Object.assign(new _e,Object.entries(t).reduce((e,[n,r])=>{if(["value","peek"].some(o=>o===n))throw F;return typeof r!="object"||r===null||Array.isArray(r)?e[n]=Ae(r):e[n]=se(r),e},{})),Sn=(t,e)=>Object.keys(e).forEach(n=>t[n].value=e[n]),We=(t,{peek:e=!1}={})=>Object.entries(t).reduce((n,[r,o])=>(o instanceof w?n[r]=e?o.peek():o.value:o instanceof _e&&(n[r]=We(o,{peek:e})),n),{});function $e(t,e){if(typeof e!="object"||Array.isArray(e)||!e)return JSON.parse(JSON.stringify(e));if(typeof e=="object"&&e.toJSON!==void 0&&typeof e.toJSON=="function")return e.toJSON();let n=t;return typeof t!="object"&&(n={...e}),Object.keys(e).forEach(r=>{n.hasOwnProperty(r)||(n[r]=e[r]),e[r]===null?delete n[r]:n[r]=$e(n[r],e[r])}),n}var Gt="0.20.1";var En=t=>t.pluginType===0,vn=t=>t.pluginType===2,Tn=t=>t.pluginType===1,An=t=>t.pluginType===3,we=class{constructor(){this.plugins=[];this.signals=se({});this.macros=new Array;this.actions={};this.watchers=new Array;this.refs={};this.reactivity={signal:Ae,computed:jt,effect:Ve};this.removals=new Map;this.mergeRemovals=new Array;this.lastMarshalledSignals=""}get version(){return Gt}load(...e){let n=new Set(this.plugins);e.forEach(r=>{if(r.requiredPlugins){for(let i of r.requiredPlugins)if(!n.has(i))throw F}let o;if(En(r)){if(this.macros.includes(r))throw X;this.macros.push(r)}else if(vn(r)){if(this.watchers.includes(r))throw X;this.watchers.push(r),o=r.onGlobalInit}else if(An(r)){if(this.actions[r.name])throw X;this.actions[r.name]=r}else if(Tn(r)){if(this.plugins.includes(r))throw X;this.plugins.push(r),o=r.onGlobalInit}else throw W;o&&o({signals:()=>this.signals,upsertSignal:this.upsertSignal.bind(this),mergeSignals:this.mergeSignals.bind(this),removeSignals:this.removeSignals.bind(this),actions:this.actions,reactivity:this.reactivity,applyPlugins:this.applyPlugins.bind(this),cleanup:this.cleanup.bind(this)}),n.add(r)}),this.applyPlugins(document.body)}cleanup(e){let n=this.removals.get(e);if(n){for(let r of n.set)r();this.removals.delete(e)}}mergeSignals(e){this.mergeRemovals.forEach(o=>o()),this.mergeRemovals=this.mergeRemovals.slice(0);let n=$e(this.signals.value,e);this.signals=se(n),JSON.stringify(this.signals.value),this.lastMarshalledSignals}removeSignals(...e){let n={...this.signals.value},r=!1;for(let o of e){let i=o.split("."),s=i[0],a=n;for(let u=1;u{this.walkDownDOM(e,i=>{o||this.cleanup(i);for(let s in i.dataset){let a=`${i.dataset[s]}`||"",u=a;if(!s.startsWith(r.name))continue;if(i.id.length||(i.id=pt(i)),n.clear(),r.allowedTagRegexps){let p=i.tagName.toLowerCase();if(![...r.allowedTagRegexps].some(T=>p.match(T)))throw F}let m=s.slice(r.name.length),[l,...c]=m.split(".");if(r.mustHaveEmptyKey&&l.length>0)throw y;if(r.mustNotEmptyKey&&l.length===0)throw y;l.length&&(l=l[0].toLowerCase()+l.slice(1));let f=c.map(p=>{let[d,...T]=p.split("_");return{label:d,args:T}});if(r.allowedModifiers){for(let p of f)if(!r.allowedModifiers.has(p.label))throw F}let g=new Map;for(let p of f)g.set(p.label,p.args);if(r.mustHaveEmptyExpression&&u.length)throw y;if(r.mustNotEmptyExpression&&!u.length)throw y;let _=/;|\n/;r.removeNewLines&&(u=u.split(` -`).map(p=>p.trim()).join(" "));let E=[...r.macros?.pre||[],...this.macros,...r.macros?.post||[]];for(let p of E){if(n.has(p))continue;n.add(p);let d=u.split(_),T=[];d.forEach(h=>{let x=h,R=[...x.matchAll(p.regexp)];if(R.length)for(let M of R){if(!M.groups)continue;let{groups:N}=M,{whole:P}=N;x=x.replace(P,p.replacer(N))}T.push(x)}),u=T.join("; ")}let S={signals:()=>this.signals,mergeSignals:this.mergeSignals.bind(this),upsertSignal:this.upsertSignal.bind(this),removeSignals:this.removeSignals.bind(this),applyPlugins:this.applyPlugins.bind(this),cleanup:this.cleanup.bind(this),walkSignals:this.walkMySignals.bind(this),actions:this.actions,reactivity:this.reactivity,el:i,rawKey:s,key:l,rawExpression:a,expression:u,expressionFn:()=>{throw ae},modifiers:g};if(!r.bypassExpressionFunctionCreation?.(S)&&!r.mustHaveEmptyExpression&&u.length){let p=u.split(_).map(h=>h.trim()).filter(h=>h.length);p[p.length-1]=`return ${p[p.length-1]}`;let d=p.map(h=>` ${h}`).join(`; -`),T=`try{${d}}catch(e){console.error(\`Error evaluating Datastar expression: -${d.replaceAll("`","\\`")} - -Error: \${e.message} - -Check if the expression is valid before raising an issue.\`.trim());debugger}`;try{let h=r.argumentNames||[],x=new Function("ctx",...h,T);S.expressionFn=x}catch(h){let x=new Error(`${h} -with -${T}`);console.error(x);debugger}}let v=r.onLoad(S);v&&(this.removals.has(i)||this.removals.set(i,{id:i.id,set:new Set}),this.removals.get(i).set.add(v))}})})}walkSignals(e,n){let r=Object.keys(e);for(let o=0;o0;if(a){n(i,s);continue}u&&this.walkSignals(s,n)}}walkMySignals(e){this.walkSignals(this.signals,e)}walkDownDOM(e,n,r=0){if(!e||!(e instanceof HTMLElement||e instanceof SVGElement))return null;for(n(e),r=0,e=e.firstElementChild;e;)this.walkDownDOM(e,n,r++),e=e.nextElementSibling}};var Kt=new we;Kt.load(ot,xt,Rt,rt,nt);var qt=Kt;qt.load();})(); +"use strict";(()=>{var _e="computed",j={type:1,name:_e,keyReq:1,valReq:1,removeOnLoad:!0,onLoad:({key:t,signals:e,genRX:n})=>{let s=n();e.setComputed(t,s)}};var U=t=>t.replace(/(?:^\w|[A-Z]|\b\w)/g,function(e,n){return n==0?e.toLowerCase():e.toUpperCase()}).replace(/\s+/g,""),W=t=>new Function(`return Object.assign({}, ${t})`)();var J={type:1,name:"signals",valReq:1,removeOnLoad:!0,onLoad:t=>{let{key:e,genRX:n,signals:s}=t;if(e!="")s.setValue(e,n()());else{let r=W(t.value);t.value=JSON.stringify(r),s.merge(n()())}}};var z={type:1,name:"star",keyReq:2,valReq:2,onLoad:()=>{alert("YOU ARE PROBABLY OVERCOMPLICATING IT")}};var K={name:"signalValue",type:0,fn:t=>{let e=/(?[\w0-9.]*)((\.value))/gm;return t.replaceAll(e,"ctx.signals.signal('$1').value")}};var H="datastar";var X="0.21.0-beta1";var me={Morph:"morph",Inner:"inner",Outer:"outer",Prepend:"prepend",Append:"append",Before:"before",After:"after",UpsertAttributes:"upsertAttributes"},Le=me.Morph;function Y(t){if(t.id)return t.id;let e=0,n=r=>(e=(e<<5)-e+r,e&e),s=r=>r.split("").forEach(i=>n(i.charCodeAt(0)));for(;t.parentNode;){if(t.id){s(`${t.id}`);break}else if(t===t.ownerDocument.documentElement)s(t.tagName);else{for(let r=1,i=t;i.previousElementSibling;i=i.previousElementSibling,r++)n(r);t=t.parentNode}t=t.parentNode}return H+e}var ve="http://localhost:8080/errors";var u=(t,e)=>{let n=new Error;n.name=`error ${t}`;let s=`${ve}/${t}?${new URLSearchParams(e)}`;return n.message=`for more info see ${s}`,n};var ye=Symbol.for("preact-signals"),g=1,S=2,N=4,b=8,M=16,x=32;function B(){O++}function G(){if(O>1){O--;return}let t,e=!1;for(;T!==void 0;){let n=T;for(T=void 0,L++;n!==void 0;){let s=n._nextBatchedEffect;if(n._nextBatchedEffect=void 0,n._flags&=~S,!(n._flags&b)&&Q(n))try{n._callback()}catch(r){e||(t=r,e=!0)}n=s}}if(L=0,O--,e)throw u("BatchError, error")}var a;var T,O=0,L=0,C=0;function Z(t){if(a===void 0)return;let e=t._node;if(e===void 0||e._target!==a)return e={_version:0,_source:t,_prevSource:a._sources,_nextSource:void 0,_target:a,_prevTarget:void 0,_nextTarget:void 0,_rollbackNode:e},a._sources!==void 0&&(a._sources._nextSource=e),a._sources=e,t._node=e,a._flags&x&&t._subscribe(e),e;if(e._version===-1)return e._version=0,e._nextSource!==void 0&&(e._nextSource._prevSource=e._prevSource,e._prevSource!==void 0&&(e._prevSource._nextSource=e._nextSource),e._prevSource=a._sources,e._nextSource=void 0,a._sources._nextSource=e,a._sources=e),e}function f(t){this._value=t,this._version=0,this._node=void 0,this._targets=void 0}f.prototype.brand=ye;f.prototype._refresh=function(){return!0};f.prototype._subscribe=function(t){this._targets!==t&&t._prevTarget===void 0&&(t._nextTarget=this._targets,this._targets!==void 0&&(this._targets._prevTarget=t),this._targets=t)};f.prototype._unsubscribe=function(t){if(this._targets!==void 0){let e=t._prevTarget,n=t._nextTarget;e!==void 0&&(e._nextTarget=n,t._prevTarget=void 0),n!==void 0&&(n._prevTarget=e,t._nextTarget=void 0),t===this._targets&&(this._targets=n)}};f.prototype.subscribe=function(t){return P(()=>{let e=this.value,n=a;a=void 0;try{t(e)}finally{a=n}})};f.prototype.valueOf=function(){return this.value};f.prototype.toString=function(){return this.value+""};f.prototype.toJSON=function(){return this.value};f.prototype.peek=function(){let t=a;a=void 0;try{return this.value}finally{a=t}};Object.defineProperty(f.prototype,"value",{get(){let t=Z(this);return t!==void 0&&(t._version=this._version),this._value},set(t){if(t!==this._value){if(L>100)throw u("SignalCycleDetected");this._value=t,this._version++,C++,B();try{for(let e=this._targets;e!==void 0;e=e._nextTarget)e._target._notify()}finally{G()}}}});function Q(t){for(let e=t._sources;e!==void 0;e=e._nextSource)if(e._source._version!==e._version||!e._source._refresh()||e._source._version!==e._version)return!0;return!1}function ee(t){for(let e=t._sources;e!==void 0;e=e._nextSource){let n=e._source._node;if(n!==void 0&&(e._rollbackNode=n),e._source._node=e,e._version=-1,e._nextSource===void 0){t._sources=e;break}}}function te(t){let e=t._sources,n;for(;e!==void 0;){let s=e._prevSource;e._version===-1?(e._source._unsubscribe(e),s!==void 0&&(s._nextSource=e._nextSource),e._nextSource!==void 0&&(e._nextSource._prevSource=s)):n=e,e._source._node=e._rollbackNode,e._rollbackNode!==void 0&&(e._rollbackNode=void 0),e=s}t._sources=n}function y(t){f.call(this,void 0),this._fn=t,this._sources=void 0,this._globalVersion=C-1,this._flags=N}y.prototype=new f;y.prototype._refresh=function(){if(this._flags&=~S,this._flags&g)return!1;if((this._flags&(N|x))===x||(this._flags&=~N,this._globalVersion===C))return!0;if(this._globalVersion=C,this._flags|=g,this._version>0&&!Q(this))return this._flags&=~g,!0;let t=a;try{ee(this),a=this;let e=this._fn();(this._flags&M||this._value!==e||this._version===0)&&(this._value=e,this._flags&=~M,this._version++)}catch(e){this._value=e,this._flags|=M,this._version++}return a=t,te(this),this._flags&=~g,!0};y.prototype._subscribe=function(t){if(this._targets===void 0){this._flags|=N|x;for(let e=this._sources;e!==void 0;e=e._nextSource)e._source._subscribe(e)}f.prototype._subscribe.call(this,t)};y.prototype._unsubscribe=function(t){if(this._targets!==void 0&&(f.prototype._unsubscribe.call(this,t),this._targets===void 0)){this._flags&=~x;for(let e=this._sources;e!==void 0;e=e._nextSource)e._source._unsubscribe(e)}};y.prototype._notify=function(){if(!(this._flags&S)){this._flags|=N|S;for(let t=this._targets;t!==void 0;t=t._nextTarget)t._target._notify()}};Object.defineProperty(y.prototype,"value",{get(){if(this._flags&g)throw u("SignalCycleDetected");let t=Z(this);if(this._refresh(),t!==void 0&&(t._version=this._version),this._flags&M)throw u("GetComputedError",{value:this._value});return this._value}});function ne(t){return new y(t)}function se(t){let e=t._cleanup;if(t._cleanup=void 0,typeof e=="function"){B();let n=a;a=void 0;try{e()}catch(s){throw t._flags&=~g,t._flags|=b,$(t),u("CleanupEffectError",{error:s})}finally{a=n,G()}}}function $(t){for(let e=t._sources;e!==void 0;e=e._nextSource)e._source._unsubscribe(e);t._fn=void 0,t._sources=void 0,se(t)}function Se(t){if(a!==this)throw u("EndEffectError");te(this),a=t,this._flags&=~g,this._flags&b&&$(this),G()}function w(t){this._fn=t,this._cleanup=void 0,this._sources=void 0,this._nextBatchedEffect=void 0,this._flags=x}w.prototype._callback=function(){let t=this._start();try{if(this._flags&b||this._fn===void 0)return;let e=this._fn();typeof e=="function"&&(this._cleanup=e)}finally{t()}};w.prototype._start=function(){if(this._flags&g)throw u("SignalCycleDetected");this._flags|=g,this._flags&=~b,se(this),ee(this),B();let t=a;return a=this,Se.bind(this,t)};w.prototype._notify=function(){this._flags&S||(this._flags|=S,this._nextBatchedEffect=T,T=this)};w.prototype._dispose=function(){this._flags|=b,this._flags&g||$(this)};function P(t){let e=new w(t);try{e._callback()}catch(n){throw e._dispose(),u("EffectError",{error:n})}return e._dispose.bind(e)}function re(t,e=!1){let n={};for(let s in t)if(t.hasOwnProperty(s)){let r=t[s];if(r instanceof f){if(e&&s.startsWith("_"))continue;n[s]=r.value}else n[s]=re(r)}return n}function ie(t,e,n=!1){for(let s in e)if(e.hasOwnProperty(s)){let r=e[s];if(r instanceof Object&&!Array.isArray(r))t[s]||(t[s]={}),ie(t[s],r,n);else{if(n&&t[s])continue;t[s]=new f(r)}}}function oe(t,e){for(let n in t)if(t.hasOwnProperty(n)){let s=t[n];s instanceof f?e(n,s):oe(s,e)}}function xe(t,...e){let n={};for(let s of e){let r=s.split("."),i=t,o=n;for(let d=0;dn());this.setSignal(e,s)}value(e){return this.signal(e)?.value}setValue(e,n){let s=this.upsert(e,n);s.value=n}upsert(e,n){let s=e.split("."),r=this._signals;for(let d=0;d{let s;switch(n.type){case 0:this.macros.push(n);break;case 2:let r=n;this.watchers.push(r),s=r.onGlobalInit;break;case 3:this.actions[n.name]=n;break;case 1:let i=n;this.plugins.push(i),s=i.onGlobalInit;break;default:throw u("InvalidPluginType",{name:n.name,type:n.type})}if(s){let r=this;s({get signals(){return r._signals},effect:i=>P(i),actions:this.actions,apply:this.apply.bind(this),cleanup:this.cleanup.bind(this)})}}),this.apply(document.body)}cleanup(e){let n=this.removals.get(e);if(n){for(let s of n.set)s();this.removals.delete(e)}}apply(e){let n=new Set;this.plugins.forEach((s,r)=>{this.walkDownDOM(e,i=>{r||this.cleanup(i);for(let o in i.dataset){if(!o.startsWith(s.name))continue;let c=o.slice(s.name.length),[d,...p]=c.split(":"),h=d.length>0;h&&(d=d[0].toLowerCase()+d.slice(1));let D=`${i.dataset[o]}`||"",_=D,m=_.length>0,l=s.keyReq||0;if(h){if(l===2)throw u(s.name+"KeyNotAllowed")}else if(l===1)throw u(s.name+"KeyRequired");let E=s.valReq||0;if(m){if(E===2)throw u(s.name+"ValueNotAllowed")}else if(E===1)throw u(s.name+"ValueRequired");if(l===3||E===3){if(h&&m)throw u(s.name+"KeyAndValueProvided");if(!h&&!m)throw u(s.name+"KeyOrValueRequired")}i.id.length||(i.id=Y(i)),n.clear();let R=new Map;p.forEach(v=>{let[ge,...he]=v.split("_");R.set(U(ge),new Set(he))});let ue=[...s.macros?.pre||[],...this.macros,...s.macros?.post||[]];for(let v of ue)n.has(v)||(n.add(v),_=v.fn(_));let{actions:ce,apply:fe,cleanup:de}=this,pe=this,F;F={get signals(){return pe._signals},effect:v=>P(v),apply:fe.bind(this),cleanup:de.bind(this),actions:ce,genRX:()=>this.genRX(F,...s.argNames||[]),el:i,rawKey:o,rawValue:D,key:d,value:_,mods:R};let q=s.onLoad(F);q&&(this.removals.has(i)||this.removals.set(i,{id:i.id,set:new Set}),this.removals.get(i).set.add(q)),s?.removeOnLoad&&delete i.dataset[o]}})})}genRX(e,...n){let s=e.value.split(/;|\n/).map(l=>l.trim()).filter(l=>l!=""),r=s.length-1;s[r].startsWith("return")||(s[r]=`return (${s[r]});`);let o=s.join(` +`),c=/(\w*)\(/gm,d=o.matchAll(c),p=new Set;for(let l of d)p.add(l[1]);let h=Object.keys(this.actions).filter(l=>p.has(l)),_=`${h.map(l=>`const ${l} = ctx.actions.${l}.fn;`).join(` +`)}return (()=> {${o}})()`,m=_.trim();h.forEach(l=>{m=m.replaceAll(l+"(",l+"(ctx,")});try{let l=n||[],E=new Function("ctx",...l,m);return(...R)=>E(e,...R)}catch(l){throw u("GeneratingExpressionFailed",{error:l,fnContent:_})}}walkDownDOM(e,n){if(!e||!(e instanceof HTMLElement||e instanceof SVGElement))return null;for(n(e),e=e.firstElementChild;e;)this.walkDownDOM(e,n),e=e.nextElementSibling}};var ae=new V;ae.load(z,K,J,j);var le=ae;le.load();})(); //# sourceMappingURL=datastar-core.js.map diff --git a/bundles/datastar-core.js.map b/bundles/datastar-core.js.map index e851f7323..03cf5e97d 100644 --- a/bundles/datastar-core.js.map +++ b/bundles/datastar-core.js.map @@ -1,7 +1,7 @@ { "version": 3, - "sources": ["../code/ts/library/src/engine/consts.ts", "../code/ts/library/src/engine/errors.ts", "../code/ts/library/src/utils/signals.ts", "../code/ts/library/src/vendored/fetch-event-source/parse.ts", "../code/ts/library/src/vendored/fetch-event-source/fetch.ts", "../code/ts/library/src/plugins/official/watchers/backend/sseShared.ts", "../code/ts/library/src/plugins/official/actions/backend/sseShared.ts", "../code/ts/library/src/plugins/official/actions/backend/sseDelete.ts", "../code/ts/library/src/plugins/official/actions/backend/sseGet.ts", "../code/ts/library/src/plugins/official/actions/backend/ssePatch.ts", "../code/ts/library/src/plugins/official/actions/backend/ssePost.ts", "../code/ts/library/src/plugins/official/actions/backend/ssePut.ts", "../code/ts/library/src/plugins/official/actions/dom/clipboard.ts", "../code/ts/library/src/plugins/official/actions/logic/setAll.ts", "../code/ts/library/src/plugins/official/actions/logic/toggleAll.ts", "../code/ts/library/src/plugins/official/actions/math/clampFit.ts", "../code/ts/library/src/plugins/official/actions/math/clampFitInt.ts", "../code/ts/library/src/plugins/official/actions/math/fit.ts", "../code/ts/library/src/plugins/official/actions/math/fitInt.ts", "../code/ts/library/src/plugins/official/attributes/backend/indicator.ts", "../code/ts/library/src/plugins/official/attributes/core/computed.ts", "../code/ts/library/src/plugins/official/attributes/core/mergeSignals.ts", "../code/ts/library/src/plugins/official/attributes/core/star.ts", "../code/ts/library/src/utils/text.ts", "../code/ts/library/src/plugins/official/attributes/dom/bind.ts", "../code/ts/library/src/plugins/official/attributes/dom/class.ts", "../code/ts/library/src/utils/arguments.ts", "../code/ts/library/src/utils/timing.ts", "../code/ts/library/src/plugins/official/attributes/dom/on.ts", "../code/ts/library/src/plugins/official/attributes/dom/ref.ts", "../code/ts/library/src/plugins/official/attributes/dom/text.ts", "../code/ts/library/src/plugins/official/attributes/storage/persist.ts", "../code/ts/library/src/plugins/official/attributes/url/replaceUrl.ts", "../code/ts/library/src/plugins/official/attributes/visibility/intersects.ts", "../code/ts/library/src/utils/dom.ts", "../code/ts/library/src/plugins/official/attributes/visibility/scrollIntoView.ts", "../code/ts/library/src/plugins/official/attributes/visibility/show.ts", "../code/ts/library/src/utils/view-transitions.ts", "../code/ts/library/src/plugins/official/attributes/visibility/viewTransition.ts", "../code/ts/library/src/utils/regex.ts", "../code/ts/library/src/plugins/official/macros/core/actions.ts", "../code/ts/library/src/plugins/official/macros/core/signals.ts", "../code/ts/library/src/plugins/official/watchers/backend/sseExecuteScript.ts", "../code/ts/library/src/vendored/idiomorph.ts", "../code/ts/library/src/plugins/official/watchers/backend/sseMergeFragment.ts", "../code/ts/library/src/plugins/official/watchers/backend/sseMergeSignals.ts", "../code/ts/library/src/plugins/official/watchers/backend/sseRemoveFragments.ts", "../code/ts/library/src/plugins/official/watchers/backend/sseRemoveSignals.ts", "../code/ts/library/src/vendored/preact-core.ts", "../code/ts/library/src/vendored/deepsignal.ts", "../code/ts/library/src/vendored/ts-merge-patch.ts", "../code/ts/library/src/engine/version.ts", "../code/ts/library/src/engine/engine.ts", "../code/ts/library/src/engine/index.ts", "../code/ts/library/src/bundles/datastar-core.ts"], - "sourcesContent": ["// This is auto-generated by Datastar. DO NOT EDIT.\n\nexport const DATASTAR = \"datastar\";\nexport const DATASTAR_EVENT = \"datastar-event\";\nexport const DATASTAR_REQUEST = \"Datastar-Request\";\nexport const VERSION = \"0.20.1\";\n\n// #region Defaults\n\n// #region Default durations\n\n// The default duration for settling during merges. Allows for CSS transitions to complete.\nexport const DefaultSettleDurationMs = 300;\n// The default duration for retrying SSE on connection reset. This is part of the underlying retry mechanism of SSE.\nexport const DefaultSseRetryDurationMs = 1000;\n\n// #endregion\n\n\n// #region Default strings\n\n// The default attributes for -``` - -If you prefer to host the file yourself, download your own bundle using the [bundler](/bundler), then include it from the appropriate path. - -```html - -``` - -### Using NPM - -You can alternatively install Datastar via [npm](https://www.npmjs.com/package/@starfederation/datastar). We don't recommend this for most use-cases, as it requires a build step, but it can be useful for legacy frontend projects. - -```bash -npm install @starfederation/datastar -``` - -## Data Attributes - -At the core of Datastar are [`data-*`](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes) attributes (hence the name). They allow you to add reactivity to your frontend in a declarative way, and interact with your backend. - -Datastar uses signals to manage state. You can think of signals as reactive variables that automatically track and propagate changes from expressions. They can be created and modified using data attributes on the frontend, and using events sent from the backend. Don't worry if this sounds complicated; it will become clearer as we look at some examples. - -### `data-bind` - -Datastar provides us with a way to set up two-way data binding on an element using the [`data-bind`](/reference/plugins_attributes#model) attribute, which can be placed on any HTML element that users can directly input data or choices from (`input`, `textarea`, `select`, `checkbox` and `radio` elements). - -```html - -``` - -This creates a new signal called `input`, and binds it to the element's value. If either is changed, the other automatically updates. - -### `data-text` - -To see this in action, we can use the [`data-text`](/reference/plugins_attributes#text) attribute. - -```html -
- I will get replaced with the contents of the input signal -
-``` - -
-
-
-
Input:
- -
-
-
Output:
-
-
-
-
- -This sets the text content of an element to the value of the signal `$input`. The `$` is required to denote a signal in the expression. - -The value of the `data-text` attribute is an expression that is evaluated, meaning that we can use JavaScript in it. - -```html -
- Will be replaced with the uppercase contents of the input signal -
-``` - -
-
-
-
Input:
- -
-
-
Output:
-
-
-
-
- -### `data-computed-*` - -The `data-computed-*` attribute creates a new signal that is computed based on an expression. The computed signal is read-only, and its value is automatically updated when any signals in the expression are updated. - -```html -
- -
- Will be replaced with the contents of the repeated signal -
-
-``` - -
-
-
-
Input:
- -
-
-
Output:
-
-
-
-
- -### `data-show` - -The `data-show` attribute can be used to show or hide an element based on whether a JavaScript expression evaluates to `true` or `false`. - -```html - -``` - -This results in the button being visible only when the input is _not_ empty. - -
-
-
-
Input:
- -
-
-
Output:
-
-
-
- -
- -### `data-class` - -The [`data-class`](/reference/plugins_attributes#class) attribute allows us to add or remove classes from an element using a set of key-value pairs that map to the class name and expression. - -```html - -``` - -Since the expression evaluates to `true` or `false`, we can rewrite this as `!$input`. - -```html - -``` - -
-
-
-
Input:
- -
-
-
Output:
-
-
-
- -
- -### `data-bind-*` - -The `data-bind-*` attribute can be used to bind a JavaScript expression to **any** valid HTML attribute. The becomes even more powerful when combined with [Web Components](https://developer.mozilla.org/en-US/docs/Web/Web_Components). - -```html - -``` - -This results in the button being given the `disabled` attribute whenever the input is empty. - -
-
-
-
Input:
- -
-
-
Output:
-
-
-
- -
- -### `data-merge-signals` - -So far, we've created signals on the fly using `data-bind` and `data-computed-*`. All signals are merged into a **signals** that is accessible from anywhere in the DOM. - -We can merge signals into the signals using the [`data-merge-signals`](/reference/plugins_core#signals) attribute. - -```html -
-``` - -The `data-merge-signals` value must be written as a JavaScript object literal _or_ using JSON syntax. - -Adding `data-merge-signals` to multiple elements is allowed, and the signals provided will be _merged_ into the existing signals (values defined later in the DOM tree override those defined earlier). - -Signals are nestable, which can be useful for namespacing. - -```html -
-``` - -### `data-on-*` - -The [`data-on-*`](/reference/plugins_attributes#on) attribute can be used to execute a JavaScript expression whenever an event is triggered on an element. - -```html - -``` - -This results in the `$input` signal being set to an empty string when the button element is clicked. If the `$input` signal is used elsewhere, its value will automatically update. This, like `data-bind` can be used with **any** valid event name (e.g. `data-on-keydown`, `data-on-mouseover`, etc.). - -
-
-
-
Input:
- -
-
-
Output:
-
-
-
- -
- -So what else can we do with these expressions? Anything we want, really. -See if you can follow the code below _before_ trying the demo. - -```html -
-
What do you put in a toaster?
- -
- You answered “”. - That is correct ✅ - - The correct answer is “ - - ” 🤷 - -
-
-``` - -
-
-
- What do you put in a toaster? -
-
- You answered “”. - That is correct ✅ - - The correct answer is “” 🤷 - -
-
- -
- -We've just scratched the surface of frontend reactivity. Now let's take a look at how we can bring the backend into play. - -## Backend Setup - -Datastar uses [Server-Sent Events](https://en.wikipedia.org/wiki/Server-sent_events) or SSE. There's no special backend plumbing required to use SSE, just some special syntax. Fortunately, SSE is straightforward and [provides us with some advantages](/essays/event_streams_all_the_way_down). - -First, set up your backend in the language of your choice. Using one of the helper SDKs (available for Go, PHP and TypeScript) will help you get up and running faster. We're going to use the SDKs in the examples below, which set the appropriate headers and format the events for us, but this is optional. - -The following code would exist in a controller action endpoint in your backend. - -!!!CODE_SNIPPET:getting_started/setup!!! - -The `mergeFragments()` method merges the provided HTML fragment into the DOM, replacing the element with `id="question"`. An element with the ID `question` must already exist in the DOM. - -The `mergeSignals()` method merges the `response` and `answer` signals into the frontend signals. - -With our backend in place, we can now use the `data-on-click` attribute to trigger the `@get()` action, which sends a `GET` request to the `/actions/quiz` endpoint on the server when a button is clicked. - -```html -
-
- - -
- You answered “”. - That is correct ✅ - - The correct answer is “” 🤷 - -
-
-``` - -Now when the `Fetch a question` button is clicked, the server will respond with an event to modify the `question` element in the DOM and an event to modify the `response` and `answer` signals. We're driving state from the backend! - -
-
-
-
- You answered “”. - That is correct ✅ - - The correct answer is “” 🤷 - -
- -
- -
- -### `data-indicator` - -The `data-indicator` attribute sets the value of the provided signal name to `true` while the request is in flight. We can use this signal to show a loading indicator, which may be desirable for slower responses. - -Note that elements using the `data-indicator` attribute **_must_** have a unique ID attribute. - -```html -
-
- -``` - -
-
-
-
- -
-
-
-
- -We're not limited to just `GET` requests. We can also send `GET`, `POST`, `PUT`, `PATCH` and `DELETE` requests, using the `@get()`, `@post()`, `@put()`, `@patch()` and `@delete()` actions, respectively. - -Here's how we could send an answer to the server for processing, using a `POST` request. - -```html - -``` - -One of the benefits of using SSE is that we can send multiple events (HTML fragments, signal updates, etc.) in a single response. - -!!!CODE_SNIPPET:getting_started/multiple_events!!! - -## Actions - -Actions in Datastar are helper functions that are available in `data-*` attributes and have the syntax `$actionName()`. We already saw the `@get` action above. Here are a few other common actions. - -### `@setAll()` - -The `@setAll()` action sets the values of multiple signals at once. It takes a regular expression that is used to match against signals, and a value to set them to, as arguments. - -```html - -``` - -This sets the values of all signals containing `form_` to `true`, which could be useful for enabling input fields in a form. - -```html - Checkbox 1 - Checkbox 2 - Checkbox 3 - -``` - -
-
- -
-
- -
-
- -
- -
- -### `@toggleAll()` - -The `@toggleAll()` action toggles the values of multiple signals at once. It takes a regular expression that is used to match against signals, as an argument. - -```html - -``` - -This toggles the values of all signals containing `form_` (to either `true` or `false`), which could be useful for toggling input fields in a form. - -```html - Checkbox 1 - Checkbox 2 - Checkbox 3 - -``` - -
-
- -
-
- -
-
- -
- -
- -## A Quick Overview - -Using [`data-*`](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes) attributes, you can introduce reactive state to your frontend and access it anywhere in the DOM and in your backend. You can set up events that trigger requests to backend endpoints that respond with HTML fragment and signal updates. - -- Bind element values to signals: `data-bind="foo"` -- Set the text content of an element to an expression.: `data-text="$foo"` -- Create a computed signal: `data-computed-foo="$bar + 1"` -- Show or hide an element using an expression: `data-show="$foo" -- Modify the classes on an element: `data-class="{'font-bold': $foo}"` -- Bind an expression to an HTML attribute: `data-bind-disabled="$foo == ''"` -- Merge signals into the signals: `data-merge-signals="{foo: ''}"` -- Execute an expression on an event: `data-on-click="@get(/endpoint)"` -- Use signals to track in flight backend requests: `data-indicator="fetching"` -- Replace the URL: `data-replace-url="'/page1'"` -- Persist all signals in local storage: `data-persist` -- Create a reference to an element: `data-ref="alert"` -- Check for intersection with the viewport: `data-intersect="alert('visible')"` -- Scroll programmatically: `data-scroll-into-view` -- Interact with the [View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API): `data-transition="slide"` diff --git a/code/go/site/static/md/reference/expressions.md b/code/go/site/static/md/reference/expressions.md deleted file mode 100644 index 74b4e528e..000000000 --- a/code/go/site/static/md/reference/expressions.md +++ /dev/null @@ -1,9 +0,0 @@ -# Expressions - -Expressions are the building blocks of Datastar. In fact, Datastar started as just a way to take `data-*` attributes and turn them into expressions. For exact `data-*` attribute found - -1. All `macro` plugins are run. This allows for a custom DSL. The included plugins use `$` for signals, `@` for actions. The plugins check for regular expressions and replace them with the appropriate value. Some plugins will setup extra state on load like adding CSS classes or setting up event listeners. -2. All Datastar `AttributePlugin` plugins are run in order. Most of the time these plugins are creating `effect()` signals so that that changes to the expression with automatically update the DOM and other parts of the system. -3. Check for any element removals and cancel any effects that are no longer needed. - -Each expression is evaluated in a new Function declaration and not in a call to `eval()`. This is done to prevent access to the global scope and to prevent access to the `Function` constructor. This is done to prevent XSS attacks. Its also why all expressions take a `ctx` which has access to the signals, actions, and refs, but not the global scope. This was gleamed from how Alpine.js works but with a different reactive model. diff --git a/code/go/site/static/md/reference/plugins_helpers.md b/code/go/site/static/md/reference/plugins_helpers.md deleted file mode 100644 index 64352bf31..000000000 --- a/code/go/site/static/md/reference/plugins_helpers.md +++ /dev/null @@ -1,44 +0,0 @@ -# Action Plugins - -[Source](https://github.com/starfederation/datastar/blob/main/packages/library/src/lib/plugins/official/helpers.ts) - -## `@setAll(regexp: string, value: any)` - -```html -
-``` - -Sets all the signals that start with the prefix to the value of the second argument. This is useful for setting all the values of a form at once. - -## `@toggleAll(regexp: string)` - -```html -
-``` - -Toggles all the signals that start with the prefix. This is useful for toggling all the values of a form at once. - -## `@clipboard(text: string)` - -```html -
-``` - -Copies the text to the clipboard. This is useful for copying text to the clipboard. - -## `@fit(v: number, oldMin:number, oldMax:number, newMin, newMax)` - -Make a value linear interpolate from an original range to new one. - - -## `@fitInt(v: number, oldMin:number, oldMax:number, newMin, newMax)` - -Same as `@fit` but rounded to nearest integer - -## `@clampFit(v: number, oldMin:number, oldMax:number, newMin, newMax)` - -Same as `@fit` but clamped to `newMin` -> `newMax` range - -## `@clampFitInt(v: number, oldMin:number, oldMax:number, newMin, newMax)` - -Same as `@clampFit` but rounded to nearest integer diff --git a/code/ts/library/pnpm-lock.yaml b/code/ts/library/pnpm-lock.yaml deleted file mode 100644 index 3e3d1cd4a..000000000 --- a/code/ts/library/pnpm-lock.yaml +++ /dev/null @@ -1,24 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - devDependencies: - typescript: - specifier: ^5.6.3 - version: 5.6.3 - -packages: - - typescript@5.6.3: - resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} - engines: {node: '>=14.17'} - hasBin: true - -snapshots: - - typescript@5.6.3: {} diff --git a/code/ts/library/src/bundles/datastar.ts b/code/ts/library/src/bundles/datastar.ts deleted file mode 100644 index e0b381290..000000000 --- a/code/ts/library/src/bundles/datastar.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Datastar } from "../engine"; -import { DeleteSSE } from "../plugins/official/actions/backend/sseDelete"; -import { GetSSE } from "../plugins/official/actions/backend/sseGet"; -import { PatchSSE } from "../plugins/official/actions/backend/ssePatch"; -import { PostSSE } from "../plugins/official/actions/backend/ssePost"; -import { PutSSE } from "../plugins/official/actions/backend/ssePut"; -import { Clipboard } from "../plugins/official/actions/dom/clipboard"; -import { SetAll } from "../plugins/official/actions/logic/setAll"; -import { ToggleAll } from "../plugins/official/actions/logic/toggleAll"; -import { ClampFit } from "../plugins/official/actions/math/clampFit"; -import { ClampFitInt } from "../plugins/official/actions/math/clampFitInt"; -import { Fit } from "../plugins/official/actions/math/fit"; -import { FitInt } from "../plugins/official/actions/math/fitInt"; -import { Indicator } from "../plugins/official/attributes/backend/indicator"; -import { Bind } from "../plugins/official/attributes/dom/bind"; -import { Class } from "../plugins/official/attributes/dom/class"; -import { On } from "../plugins/official/attributes/dom/on"; -import { Ref } from "../plugins/official/attributes/dom/ref"; -import { Text } from "../plugins/official/attributes/dom/text"; -import { Persist } from "../plugins/official/attributes/storage/persist"; -import { ReplaceUrl } from "../plugins/official/attributes/url/replaceUrl"; -import { Intersection } from "../plugins/official/attributes/visibility/intersects"; -import { ScrollIntoView } from "../plugins/official/attributes/visibility/scrollIntoView"; -import { Show } from "../plugins/official/attributes/visibility/show"; -import { ViewTransition } from "../plugins/official/attributes/visibility/viewTransition"; -import { ExecuteScript } from "../plugins/official/watchers/backend/sseExecuteScript"; -import { MergeFragments } from "../plugins/official/watchers/backend/sseMergeFragment"; -import { MergeSignals } from "../plugins/official/watchers/backend/sseMergeSignals"; -import { RemoveFragments } from "../plugins/official/watchers/backend/sseRemoveFragments"; -import { RemoveSignals } from "../plugins/official/watchers/backend/sseRemoveSignals"; - -Datastar.load( - // attributes - Bind, - Ref, - Indicator, - ReplaceUrl, - Class, - On, - Text, - Persist, - Intersection, - ScrollIntoView, - Show, - ViewTransition, - // actions - DeleteSSE, - GetSSE, - PatchSSE, - PostSSE, - PutSSE, - Clipboard, - SetAll, - ToggleAll, - ClampFit, - ClampFitInt, - Fit, - FitInt, - // effects - MergeFragments, - MergeSignals, - RemoveFragments, - RemoveSignals, - ExecuteScript -); diff --git a/code/ts/library/src/engine/engine.ts b/code/ts/library/src/engine/engine.ts deleted file mode 100644 index e8938d848..000000000 --- a/code/ts/library/src/engine/engine.ts +++ /dev/null @@ -1,408 +0,0 @@ -import { consistentUniqID } from "../utils/dom"; -import { DeepSignal, deepSignal, DeepState } from "../vendored/deepsignal"; -import { computed, effect, Signal, signal } from "../vendored/preact-core"; -import { apply } from "../vendored/ts-merge-patch"; -import { PluginType } from "./enums"; - -import { - ERR_ALREADY_EXISTS, - ERR_BAD_ARGS, - ERR_METHOD_NOT_ALLOWED, - ERR_NOT_ALLOWED, - ERR_NOT_FOUND, -} from "./errors"; -import { - ActionPlugin, - ActionPlugins, - AttribtueExpressionFunction, - AttributeContext, - AttributePlugin, - DatastarPlugin, - HTMLorSVGElement, - InitContext, - MacroPlugin, - OnRemovalFn, - Reactivity, - WatcherPlugin, -} from "./types"; -import { VERSION } from "./version"; - -const isMacroPlugin = (p: DatastarPlugin): p is MacroPlugin => - p.pluginType === PluginType.Macro; -const isWatcherPlugin = (p: DatastarPlugin): p is WatcherPlugin => - p.pluginType === PluginType.Watcher; -const isAttributePlugin = (p: DatastarPlugin): p is AttributePlugin => - p.pluginType === PluginType.Attribute; -const isActionPlugin = (p: DatastarPlugin): p is ActionPlugin => - p.pluginType === PluginType.Action; - -export * from "./enums"; - -export class Engine { - plugins: AttributePlugin[] = []; - signals: DeepSignal = deepSignal({}); - macros = new Array(); - actions: ActionPlugins = {}; - watchers = new Array(); - refs: Record = {}; - reactivity: Reactivity = { - signal, - computed, - effect, - }; - removals = new Map }>(); - mergeRemovals = new Array(); - - get version() { - return VERSION; - } - - load(...pluginsToLoad: DatastarPlugin[]) { - const allLoadedPlugins = new Set(this.plugins); - - pluginsToLoad.forEach((plugin) => { - if (plugin.requiredPlugins) { - for (const requiredPluginType of plugin.requiredPlugins) { - if (!allLoadedPlugins.has(requiredPluginType)) { - // requires other plugin to be loaded - throw ERR_NOT_ALLOWED; - } - } - } - - let globalInitializer: ((ctx: InitContext) => void) | undefined; - if (isMacroPlugin(plugin)) { - if (this.macros.includes(plugin)) { - throw ERR_ALREADY_EXISTS; - } - this.macros.push(plugin); - } else if (isWatcherPlugin(plugin)) { - if (this.watchers.includes(plugin)) { - throw ERR_ALREADY_EXISTS; - } - this.watchers.push(plugin); - globalInitializer = plugin.onGlobalInit; - } else if (isActionPlugin(plugin)) { - if (!!this.actions[plugin.name]) { - throw ERR_ALREADY_EXISTS; - } - this.actions[plugin.name] = plugin; - } else if (isAttributePlugin(plugin)) { - if (this.plugins.includes(plugin)) { - throw ERR_ALREADY_EXISTS; - } - this.plugins.push(plugin); - globalInitializer = plugin.onGlobalInit; - } else { - throw ERR_NOT_FOUND; - } - - if (globalInitializer) { - globalInitializer({ - signals: () => this.signals, - upsertSignal: this.upsertSignal.bind(this), - mergeSignals: this.mergeSignals.bind(this), - removeSignals: this.removeSignals.bind(this), - actions: this.actions, - reactivity: this.reactivity, - applyPlugins: this.applyPlugins.bind(this), - cleanup: this.cleanup.bind(this), - }); - } - - allLoadedPlugins.add(plugin); - }); - - this.applyPlugins(document.body); - } - - private cleanup(element: Element) { - const removalSet = this.removals.get(element); - if (removalSet) { - for (const removal of removalSet.set) { - removal(); - } - this.removals.delete(element); - } - } - - lastMarshalledSignals = ""; - private mergeSignals(mergeSignals: T) { - this.mergeRemovals.forEach((removal) => removal()); - this.mergeRemovals = this.mergeRemovals.slice(0); - - const revisedSignals = apply(this.signals.value, mergeSignals) as DeepState; - this.signals = deepSignal(revisedSignals); - - const marshalledSignals = JSON.stringify(this.signals.value); - if (marshalledSignals === this.lastMarshalledSignals) return; - } - - private removeSignals(...keys: string[]) { - const revisedSignals = { ...this.signals.value }; - let found = false; - for (const key of keys) { - const parts = key.split("."); - let currentID = parts[0]; - let subSignals = revisedSignals; - for (let i = 1; i < parts.length; i++) { - const part = parts[i]; - if (!subSignals[currentID]) { - subSignals[currentID] = {}; - } - subSignals = subSignals[currentID]; - currentID = part; - } - delete subSignals[currentID]; - found = true; - } - if (!found) return; - this.signals = deepSignal(revisedSignals); - this.applyPlugins(document.body); - } - - private upsertSignal(path: string, value: T) { - const parts = path.split("."); - let subSignals = this.signals as any; - for (let i = 0; i < parts.length - 1; i++) { - const part = parts[i]; - if (!subSignals[part]) { - subSignals[part] = {}; - } - subSignals = subSignals[part]; - } - const last = parts[parts.length - 1]; - - const current = subSignals[last]; - if (!!current) return current; - - const signal = this.reactivity.signal(value); - subSignals[last] = signal; - - return signal; - } - - private applyPlugins(rootElement: Element) { - const appliedProcessors = new Set(); - - this.plugins.forEach((p, pi) => { - this.walkDownDOM(rootElement, (el) => { - if (!pi) this.cleanup(el); - - for (const rawKey in el.dataset) { - const rawExpression = `${el.dataset[rawKey]}` || ""; - let expression = rawExpression; - - if (!rawKey.startsWith(p.name)) continue; - - if (!el.id.length) { - el.id = consistentUniqID(el); - } - - appliedProcessors.clear(); - - if (p.allowedTagRegexps) { - const lowerCaseTag = el.tagName.toLowerCase(); - const allowed = [...p.allowedTagRegexps].some((r) => - lowerCaseTag.match(r) - ); - if (!allowed) { - throw ERR_NOT_ALLOWED; - } - } - - let keyRaw = rawKey.slice(p.name.length); - let [key, ...modifiersWithArgsArr] = keyRaw.split("."); - if (p.mustHaveEmptyKey && key.length > 0) { - // must have empty key - throw ERR_BAD_ARGS; - } - if (p.mustNotEmptyKey && key.length === 0) { - // must have non-empty key - throw ERR_BAD_ARGS; - } - if (key.length) { - key = key[0].toLowerCase() + key.slice(1); - } - - const modifiersArr = modifiersWithArgsArr.map((m) => { - const [label, ...args] = m.split("_"); - return { label, args }; - }); - if (p.allowedModifiers) { - for (const modifier of modifiersArr) { - if (!p.allowedModifiers.has(modifier.label)) { - // modifier not allowed - throw ERR_NOT_ALLOWED; - } - } - } - const modifiers = new Map(); - for (const modifier of modifiersArr) { - modifiers.set(modifier.label, modifier.args); - } - - if (p.mustHaveEmptyExpression && expression.length) { - // must have empty expression - throw ERR_BAD_ARGS; - } - if (p.mustNotEmptyExpression && !expression.length) { - // must have non-empty expression - throw ERR_BAD_ARGS; - } - - const splitRegex = /;|\n/; - - if (p.removeNewLines) { - expression = expression - .split("\n") - .map((p: string) => p.trim()) - .join(" "); - } - - const processors = [ - ...(p.macros?.pre || []), - ...this.macros, - ...(p.macros?.post || []), - ]; - for (const processor of processors) { - if (appliedProcessors.has(processor)) continue; - appliedProcessors.add(processor); - - const expressionParts = expression.split(splitRegex); - const revisedParts: string[] = []; - - expressionParts.forEach((exp) => { - let revised = exp; - const matches = [...revised.matchAll(processor.regexp)]; - if (matches.length) { - for (const match of matches) { - if (!match.groups) continue; - const { groups } = match; - const { whole } = groups; - revised = revised.replace(whole, processor.replacer(groups)); - } - } - revisedParts.push(revised); - }); - // }) - - expression = revisedParts.join("; "); - } - - const ctx: AttributeContext = { - signals: () => this.signals, - mergeSignals: this.mergeSignals.bind(this), - upsertSignal: this.upsertSignal.bind(this), - removeSignals: this.removeSignals.bind(this), - applyPlugins: this.applyPlugins.bind(this), - cleanup: this.cleanup.bind(this), - walkSignals: this.walkMySignals.bind(this), - actions: this.actions, - reactivity: this.reactivity, - el, - rawKey, - key, - rawExpression, - expression, - expressionFn: () => { - throw ERR_METHOD_NOT_ALLOWED; - }, - modifiers, - }; - - if ( - !p.bypassExpressionFunctionCreation?.(ctx) && - !p.mustHaveEmptyExpression && - expression.length - ) { - const statements = expression - .split(splitRegex) - .map((s) => s.trim()) - .filter((s) => s.length); - statements[statements.length - 1] = `return ${ - statements[statements.length - 1] - }`; - const j = statements.map((s) => ` ${s}`).join(";\n"); - const fnContent = `try{${j}}catch(e){console.error(\`Error evaluating Datastar expression:\n${j.replaceAll( - "`", - "\\`" - )}\n\nError: \${e.message}\n\nCheck if the expression is valid before raising an issue.\`.trim());debugger}`; - try { - const argumentNames = p.argumentNames || []; - const fn = new Function( - "ctx", - ...argumentNames, - fnContent - ) as AttribtueExpressionFunction; - ctx.expressionFn = fn; - } catch (e) { - const err = new Error(`${e}\nwith\n${fnContent}`); - console.error(err); - debugger; - } - } - - const removal = p.onLoad(ctx); - if (removal) { - if (!this.removals.has(el)) { - this.removals.set(el, { - id: el.id, - set: new Set(), - }); - } - this.removals.get(el)!.set.add(removal); - } - } - }); - }); - } - - private walkSignals( - signals: any, - callback: (name: string, signal: Signal) => void - ) { - const keys = Object.keys(signals); - for (let i = 0; i < keys.length; i++) { - const key = keys[i]; - const value = signals[key]; - const isSignal = value instanceof Signal; - const hasChildren = - typeof value === "object" && Object.keys(value).length > 0; - - if (isSignal) { - callback(key, value); - continue; - } - - if (!hasChildren) continue; - - this.walkSignals(value, callback); - } - } - - private walkMySignals(callback: (name: string, signal: Signal) => void) { - this.walkSignals(this.signals, callback); - } - - private walkDownDOM( - element: Element | null, - callback: (el: HTMLorSVGElement) => void, - siblingOffset = 0 - ) { - if ( - !element || - !(element instanceof HTMLElement || element instanceof SVGElement) - ) - return null; - - callback(element); - - siblingOffset = 0; - element = element.firstElementChild; - while (element) { - this.walkDownDOM(element, callback, siblingOffset++); - element = element.nextElementSibling; - } - } -} diff --git a/code/ts/library/src/engine/enums.ts b/code/ts/library/src/engine/enums.ts deleted file mode 100644 index dd6f23866..000000000 --- a/code/ts/library/src/engine/enums.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum PluginType { - Macro, - Attribute, - Watcher, - Action, -} diff --git a/code/ts/library/src/engine/errors.ts b/code/ts/library/src/engine/errors.ts deleted file mode 100644 index 2c1ada71b..000000000 --- a/code/ts/library/src/engine/errors.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { DATASTAR } from "./consts"; - -const err = (code: number) => { - const e = new Error(); - e.name = `${DATASTAR}${code}`; - return e; -}; - -export const ERR_BAD_ARGS = err(400); -export const ERR_ALREADY_EXISTS = err(409); -export const ERR_NOT_FOUND = err(404); -export const ERR_NOT_ALLOWED = err(403); -export const ERR_METHOD_NOT_ALLOWED = err(405); -export const ERR_SERVICE_UNAVAILABLE = err(503); diff --git a/code/ts/library/src/engine/index.ts b/code/ts/library/src/engine/index.ts deleted file mode 100644 index 3be7257f7..000000000 --- a/code/ts/library/src/engine/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ActionsMacro, SignalsMacro } from "../plugins"; -import { Computed } from "../plugins/official/attributes/core/computed"; -import { MergeSignals } from "../plugins/official/attributes/core/mergeSignals"; -import { Star } from "../plugins/official/attributes/core/star"; -import { Engine } from "./engine"; - -export { VERSION } from "./consts"; - -export type * from "./types"; - -const ds = new Engine(); -ds.load(Star, ActionsMacro, SignalsMacro, MergeSignals, Computed); - -export const Datastar = ds; diff --git a/code/ts/library/src/engine/types.ts b/code/ts/library/src/engine/types.ts deleted file mode 100644 index 1bafaf854..000000000 --- a/code/ts/library/src/engine/types.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { DeepState } from "../vendored/deepsignal"; -import { ReadonlySignal, Signal } from "../vendored/preact-core"; -import { PluginType } from "./enums"; - -export type HTMLorSVGElement = Element & (HTMLElement | SVGElement); - -export type InitExpressionFunction = ( - ctx: InitContext, - ...args: any -) => any; -export type AttribtueExpressionFunction = ( - ctx: AttributeContext, - ...args: any -) => any; -export type Reactivity = { - signal: (value: T) => Signal; - computed: (fn: () => T) => ReadonlySignal; - effect: (cb: () => void) => OnRemovalFn; -}; - -export type InitContext = { - signals: () => any; - upsertSignal: (path: string, value: any) => Signal; - mergeSignals: (signals: DeepState) => void; - removeSignals: (...paths: string[]) => void; - actions: Readonly; - reactivity: Reactivity; - applyPlugins: (target: Element) => void; - cleanup: (el: Element) => void; -}; - -export type AttributeContext = InitContext & { - walkSignals: (cb: (name: string, signal: Signal) => void) => void; - el: Readonly; // The element the attribute is on - key: Readonly; // data-* key without the prefix or modifiers - rawKey: Readonly; // raw data-* key - rawExpression: Readonly; // before any macros run, what the user wrote - expression: Readonly; // what the user wrote after any macros run - expressionFn: AttribtueExpressionFunction; // the function constructed from the expression - modifiers: Map; // the modifiers and their arguments -}; - -export type OnRemovalFn = () => void; - -export interface DatastarPlugin { - pluginType: PluginType; // The type of plugin - name: string; // The name of the plugin - requiredPlugins?: Set; // If not provided, no plugins are required -} - -// A plugin accesible via a `data-${name}` attribute on an element -export interface AttributePlugin extends DatastarPlugin { - pluginType: PluginType; - onGlobalInit?: (ctx: InitContext) => void; // Called once on registration of the plugin - onLoad: (ctx: AttributeContext) => OnRemovalFn | void; // Return a function to be called on removal - allowedModifiers?: Set; // If not provided, all modifiers are allowed - mustHaveEmptyExpression?: boolean; // The contents of the data-* attribute must be empty - mustNotEmptyExpression?: boolean; // The contents of the data-* attribute must not be empty - mustHaveEmptyKey?: boolean; // The key of the data-* attribute must be empty after the prefix - mustNotEmptyKey?: boolean; // The key of the data-* attribute must not be empty after the prefix - allowedTagRegexps?: Set; // If not provided, all tags are allowed - disallowedTags?: Set; // If not provided, no tags are disallowed - macros?: { - pre?: MacroPlugin[]; - post?: MacroPlugin[]; - }; - removeNewLines?: boolean; // If true, the expression is not split by commas - bypassExpressionFunctionCreation?: (ctx: AttributeContext) => boolean; // If true, the expression function is not created - argumentNames?: Readonly; // The names of the arguments passed to the expression function -} - -export type RegexpGroups = Record; - -// A plugin that runs on the global scope that can effect the contents of a Datastar expression -export interface MacroPlugin extends DatastarPlugin { - pluginType: PluginType.Macro; - regexp: RegExp; - replacer: (groups: RegexpGroups) => string; -} - -export type MacrosPlugins = Record; - -export type ActionMethod = (ctx: AttributeContext, ...args: any[]) => any; - -export interface ActionPlugin extends DatastarPlugin { - pluginType: PluginType.Action; - method: ActionMethod; -} - -export type ActionPlugins = Record; - -// A plugin that runs on the global scope of the DastaStar instance -export interface WatcherPlugin extends DatastarPlugin { - pluginType: PluginType.Watcher; - onGlobalInit?: (ctx: InitContext) => void; -} diff --git a/code/ts/library/src/engine/version.ts b/code/ts/library/src/engine/version.ts deleted file mode 100644 index 3eb3d2fd8..000000000 --- a/code/ts/library/src/engine/version.ts +++ /dev/null @@ -1 +0,0 @@ -export const VERSION = '0.20.1'; diff --git a/code/ts/library/src/index.ts b/code/ts/library/src/index.ts deleted file mode 100644 index 4a7e4ee42..000000000 --- a/code/ts/library/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// We don't use these exports, they are purely for access via package managers like NPM -export * from "./engine"; -export * from "./plugins"; -export * from "./utils"; -export * from "./vendored"; diff --git a/code/ts/library/src/plugins/index.ts b/code/ts/library/src/plugins/index.ts deleted file mode 100644 index 97fff8130..000000000 --- a/code/ts/library/src/plugins/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// We don't use these exports, they are purely for access via package managers like NPM - -export * from "./official"; diff --git a/code/ts/library/src/plugins/official/actions/backend/index.ts b/code/ts/library/src/plugins/official/actions/backend/index.ts deleted file mode 100644 index 2108172bf..000000000 --- a/code/ts/library/src/plugins/official/actions/backend/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -// We don't use these exports, they are purely for access via package managers like NPM - -export * from "./sseDelete"; -export * from "./sseGet"; -export * from "./ssePatch"; -export * from "./ssePost"; -export * from "./ssePut"; -export * from "./sseShared"; diff --git a/code/ts/library/src/plugins/official/actions/backend/sseDelete.ts b/code/ts/library/src/plugins/official/actions/backend/sseDelete.ts deleted file mode 100644 index 5202113e1..000000000 --- a/code/ts/library/src/plugins/official/actions/backend/sseDelete.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: material-symbols:delete -// Slug: Use a DELETE request to fetch data from a server using Server-Sent Events matching the Datastar SDK interface -// Description: Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. - -import { ActionPlugin } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; -import { sendSSERequest } from "./sseShared"; - -export const DeleteSSE: ActionPlugin = { - pluginType: PluginType.Action, - name: "delete", - method: sendSSERequest("delete"), -}; diff --git a/code/ts/library/src/plugins/official/actions/backend/sseGet.ts b/code/ts/library/src/plugins/official/actions/backend/sseGet.ts deleted file mode 100644 index df972f5b8..000000000 --- a/code/ts/library/src/plugins/official/actions/backend/sseGet.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: ic:baseline-get-app -// Slug: Use a GET request to fetch data from a server using Server-Sent Events matching the Datastar SDK interface -// Description: Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. - -import { ActionPlugin } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; -import { sendSSERequest } from "./sseShared"; - -export const GetSSE: ActionPlugin = { - pluginType: PluginType.Action, - name: "get", - method: sendSSERequest("get"), -}; diff --git a/code/ts/library/src/plugins/official/actions/backend/ssePatch.ts b/code/ts/library/src/plugins/official/actions/backend/ssePatch.ts deleted file mode 100644 index 3c9f830aa..000000000 --- a/code/ts/library/src/plugins/official/actions/backend/ssePatch.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: mdi:bandage -// Slug: Use a PATCH request to fetch data from a server using Server-Sent Events matching the Datastar SDK interface -// Description: Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. - -import { ActionPlugin } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; -import { sendSSERequest } from "./sseShared"; - -export const PatchSSE: ActionPlugin = { - pluginType: PluginType.Action, - name: "patch", - method: sendSSERequest("patch"), -}; diff --git a/code/ts/library/src/plugins/official/actions/backend/ssePost.ts b/code/ts/library/src/plugins/official/actions/backend/ssePost.ts deleted file mode 100644 index 005519e26..000000000 --- a/code/ts/library/src/plugins/official/actions/backend/ssePost.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: material-symbols:add -// Slug: Use a POST request to fetch data from a server using Server-Sent Events matching the Datastar SDK interface -// Description: Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. - -import { ActionPlugin } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; -import { sendSSERequest } from "./sseShared"; - -export const PostSSE: ActionPlugin = { - pluginType: PluginType.Action, - name: "post", - method: sendSSERequest("post"), -}; diff --git a/code/ts/library/src/plugins/official/actions/backend/ssePut.ts b/code/ts/library/src/plugins/official/actions/backend/ssePut.ts deleted file mode 100644 index 91504a810..000000000 --- a/code/ts/library/src/plugins/official/actions/backend/ssePut.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: material-symbols:find-replace -// Slug: Use a PUT request to fetch data from a server using Server-Sent Events matching the Datastar SDK interface -// Description: Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. - -import { ActionPlugin } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; -import { sendSSERequest } from "./sseShared"; - -export const PutSSE: ActionPlugin = { - pluginType: PluginType.Action, - name: "put", - method: sendSSERequest("put"), -}; diff --git a/code/ts/library/src/plugins/official/actions/backend/sseShared.ts b/code/ts/library/src/plugins/official/actions/backend/sseShared.ts deleted file mode 100644 index e66d8d4e5..000000000 --- a/code/ts/library/src/plugins/official/actions/backend/sseShared.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { ActionMethod } from "../../../../engine"; -import { DATASTAR, DATASTAR_REQUEST } from "../../../../engine/consts"; -import { ERR_BAD_ARGS } from "../../../../engine/errors"; -import { remoteSignals } from "../../../../utils/signals"; -import { - fetchEventSource, - FetchEventSourceInit, -} from "../../../../vendored/fetch-event-source"; -import { - DATASTAR_SSE_EVENT, - DatastarSSEEvent, - FINISHED, - STARTED, -} from "../../watchers/backend/sseShared"; - -export type IndicatorReference = { el: HTMLElement; count: number }; - -const isWrongContent = (err: any) => `${err}`.includes(`text/event-stream`); - -export type SSERequestArgs = { - onlyRemoteSignals?: boolean; - headers?: Record; -}; - -function dispatchSSE(type: string, argsRaw: Record) { - document.dispatchEvent( - new CustomEvent(DATASTAR_SSE_EVENT, { - detail: { type, argsRaw }, - }), - ); -} - -export function sendSSERequest( - method: string, -): ActionMethod { - return async ( - ctx, - url, - args?: SSERequestArgs, - ) => { - if (!!!url?.length) throw ERR_BAD_ARGS; - - const onlyRemoteSignals = args?.onlyRemoteSignals ?? true; - const headers = Object.assign({ - "Content-Type": "application/json", - [DATASTAR_REQUEST]: true, - }, args?.headers); - const currentSignals = ctx.signals().value; - let signals = Object.assign({}, currentSignals); - if (onlyRemoteSignals) { - signals = remoteSignals(signals); - } - const signalsJSON = JSON.stringify(signals); - - const { el: { id: elID } } = ctx; - dispatchSSE(STARTED, { elID }); - - const urlInstance = new URL(url, window.location.origin); - - // https://fetch.spec.whatwg.org/#concept-method-normalize - method = method.toUpperCase(); - - const req: FetchEventSourceInit = { - method, - headers, - onmessage: (evt) => { - if (!evt.event.startsWith(DATASTAR)) { - return; - } - const type = evt.event; - const argsRawLines: Record = {}; - - const lines = evt.data.split("\n"); - for (const line of lines) { - const colonIndex = line.indexOf(" "); - const key = line.slice(0, colonIndex); - let argLines = argsRawLines[key]; - if (!argLines) { - argLines = []; - argsRawLines[key] = argLines; - } - const value = line.slice(colonIndex + 1).trim(); - argLines.push(value); - } - - const argsRaw: Record = {}; - for (const [key, lines] of Object.entries(argsRawLines)) { - argsRaw[key] = lines.join("\n"); - } - - // if you aren't seeing your event you can debug by using this line in the console - // document.addEventListener("datastar-sse",(e) => console.log(e)); - dispatchSSE(type, argsRaw); - }, - onerror: (err) => { - if (isWrongContent(err)) { - // don't retry if the content-type is wrong - throw err; - } - // do nothing and it will retry - if (err) { - console.error(err.message); - } - }, - onclose: () => { - dispatchSSE(FINISHED, { elID }); - }, - }; - - if (method === "GET") { - const queryParams = new URLSearchParams(urlInstance.search); - queryParams.append(DATASTAR, signalsJSON); - urlInstance.search = queryParams.toString(); - } else { - req.body = signalsJSON; - } - - try { - const revisedURL = urlInstance.toString(); - await fetchEventSource(revisedURL, req); - } catch (err) { - if (!isWrongContent(err)) { - throw err; - } - - // exit gracefully and do nothing if the content-type is wrong - // this can happen if the client is sending a request - // where no response is expected, and they haven't - // set the content-type to text/event-stream - } - }; -} diff --git a/code/ts/library/src/plugins/official/actions/dom/index.ts b/code/ts/library/src/plugins/official/actions/dom/index.ts deleted file mode 100644 index 59e4e3cac..000000000 --- a/code/ts/library/src/plugins/official/actions/dom/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// We don't use these exports, they are purely for access via package managers like NPM - -export * from "./clipboard"; diff --git a/code/ts/library/src/plugins/official/actions/index.ts b/code/ts/library/src/plugins/official/actions/index.ts deleted file mode 100644 index a66102467..000000000 --- a/code/ts/library/src/plugins/official/actions/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// We don't use these exports, they are purely for access via package managers like NPM - -export * from "./backend"; -export * from "./dom"; -export * from "./logic"; -export * from "./math"; diff --git a/code/ts/library/src/plugins/official/actions/logic/index.ts b/code/ts/library/src/plugins/official/actions/logic/index.ts deleted file mode 100644 index 8df31da7d..000000000 --- a/code/ts/library/src/plugins/official/actions/logic/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// We don't use these exports, they are purely for access via package managers like NPM - -export * from "./setAll"; -export * from "./toggleAll"; diff --git a/code/ts/library/src/plugins/official/actions/logic/setAll.ts b/code/ts/library/src/plugins/official/actions/logic/setAll.ts deleted file mode 100644 index 3562bc11c..000000000 --- a/code/ts/library/src/plugins/official/actions/logic/setAll.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: ion:checkmark-round -// Slug: Set all signals that match a regular expression - -import { ActionPlugin } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; - -export const SetAll: ActionPlugin = { - pluginType: PluginType.Action, - name: "setAll", - method: (ctx, regexp, newValue) => { - const re = new RegExp(regexp); - ctx.walkSignals((name, signal) => - re.test(name) && (signal.value = newValue) - ); - }, -}; diff --git a/code/ts/library/src/plugins/official/actions/logic/toggleAll.ts b/code/ts/library/src/plugins/official/actions/logic/toggleAll.ts deleted file mode 100644 index bb41f9202..000000000 --- a/code/ts/library/src/plugins/official/actions/logic/toggleAll.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: material-symbols:toggle-off -// Slug: Toggle all signals that match a regular expression - -import { ActionPlugin } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; - -export const ToggleAll: ActionPlugin = { - pluginType: PluginType.Action, - name: "toggleAll", - method: (ctx, regexp) => { - const re = new RegExp(regexp); - ctx.walkSignals((name, signal) => - re.test(name) && (signal.value = !signal.value) - ); - }, -}; diff --git a/code/ts/library/src/plugins/official/actions/math/clampFit.ts b/code/ts/library/src/plugins/official/actions/math/clampFit.ts deleted file mode 100644 index a75f4c049..000000000 --- a/code/ts/library/src/plugins/official/actions/math/clampFit.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: material-symbols:fit-screen-outline -// Slug: Clamp a value to a new range -// Description: This action clamps a value to a new range. The value is first scaled to the new range, then clamped to the new range. This is useful for scaling a value to a new range, then clamping it to that range. - -import { ActionPlugin, AttributeContext } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; - -export const ClampFit: ActionPlugin = { - pluginType: PluginType.Action, - name: "clampFit", - method: ( - _: AttributeContext, - v: number, - oldMin: number, - oldMax: number, - newMin: number, - newMax: number, - ) => { - return Math.max( - newMin, - Math.min( - newMax, - ((v - oldMin) / (oldMax - oldMin)) * (newMax - newMin) + newMin, - ), - ); - }, -}; diff --git a/code/ts/library/src/plugins/official/actions/math/clampFitInt.ts b/code/ts/library/src/plugins/official/actions/math/clampFitInt.ts deleted file mode 100644 index 998156ac9..000000000 --- a/code/ts/library/src/plugins/official/actions/math/clampFitInt.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: material-symbols:fit-screen -// Slug: Clamp a value to a new range and round to the nearest integer -// Description: This action clamps a value to a new range. The value is first scaled to the new range, then clamped to the new range. This is useful for scaling a value to a new range, then clamping it to that range. The result is then rounded to the nearest integer. - -import { ActionPlugin, AttributeContext } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; - -export const ClampFitInt: ActionPlugin = { - pluginType: PluginType.Action, - name: "clampFitInt", - method: ( - _: AttributeContext, - v: number, - oldMin: number, - oldMax: number, - newMin: number, - newMax: number, - ) => { - return Math.round( - Math.max( - newMin, - Math.min( - newMax, - ((v - oldMin) / (oldMax - oldMin)) * (newMax - newMin) + - newMin, - ), - ), - ); - }, -}; diff --git a/code/ts/library/src/plugins/official/actions/math/fit.ts b/code/ts/library/src/plugins/official/actions/math/fit.ts deleted file mode 100644 index b9d20c92d..000000000 --- a/code/ts/library/src/plugins/official/actions/math/fit.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: material-symbols-light:fit-width -// Slug: Linearly fit a value to a new range -// Description: This action linearly fits a value to a new range. The value is first scaled to the new range. Note it is not clamped to the new range. - -import { ActionPlugin, AttributeContext } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; - -export const Fit: ActionPlugin = { - pluginType: PluginType.Action, - name: "fit", - method: ( - _: AttributeContext, - v: number, - oldMin: number, - oldMax: number, - newMin: number, - newMax: number, - ) => { - return ((v - oldMin) / (oldMax - oldMin)) * (newMax - newMin) + newMin; - }, -}; diff --git a/code/ts/library/src/plugins/official/actions/math/fitInt.ts b/code/ts/library/src/plugins/official/actions/math/fitInt.ts deleted file mode 100644 index 7bd12e680..000000000 --- a/code/ts/library/src/plugins/official/actions/math/fitInt.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: material-symbols:fit-width -// Slug: Linearly fit a value to a new range and round to the nearest integer -// Description: This action linearly fits a value to a new range. The value is first scaled to the new range. Note it is not clamped to the new range. - -import { ActionPlugin, AttributeContext } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; - -export const FitInt: ActionPlugin = { - pluginType: PluginType.Action, - name: "fitInt", - method: ( - _: AttributeContext, - v: number, - oldMin: number, - oldMax: number, - newMin: number, - newMax: number, - ) => { - return Math.round( - ((v - oldMin) / (oldMax - oldMin)) * (newMax - newMin) + newMin, - ); - }, -}; diff --git a/code/ts/library/src/plugins/official/actions/math/index.ts b/code/ts/library/src/plugins/official/actions/math/index.ts deleted file mode 100644 index 15e5b1202..000000000 --- a/code/ts/library/src/plugins/official/actions/math/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// We don't use these exports, they are purely for access via package managers like NPM - -export * from "./clampFit"; -export * from "./clampFitInt"; -export * from "./fit"; -export * from "./fitInt"; diff --git a/code/ts/library/src/plugins/official/attributes/backend/index.ts b/code/ts/library/src/plugins/official/attributes/backend/index.ts deleted file mode 100644 index d9c4a161d..000000000 --- a/code/ts/library/src/plugins/official/attributes/backend/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// We don't use these exports, they are purely for access via package managers like NPM - -export * from "./indicator"; diff --git a/code/ts/library/src/plugins/official/attributes/core/computed.ts b/code/ts/library/src/plugins/official/attributes/core/computed.ts deleted file mode 100644 index 536ae725b..000000000 --- a/code/ts/library/src/plugins/official/attributes/core/computed.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: fluent:draw-text-24-filled -// Slug: Create a computed signal -// Description: This attribute creates a computed signal that updates when its dependencies change. - -import { AttributePlugin } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; - -export const Computed: AttributePlugin = { - pluginType: PluginType.Attribute, - name: "computed", - mustNotEmptyKey: true, - onLoad: (ctx) => { - const signals = ctx.signals(); - signals[ctx.key] = ctx.reactivity.computed(() => { - return ctx.expressionFn(ctx); - }); - - return () => { - const signals = ctx.signals(); - delete signals[ctx.key]; - }; - }, -}; diff --git a/code/ts/library/src/plugins/official/attributes/core/index.ts b/code/ts/library/src/plugins/official/attributes/core/index.ts deleted file mode 100644 index 010fcff50..000000000 --- a/code/ts/library/src/plugins/official/attributes/core/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// We don't use these exports, they are purely for access via package managers like NPM - -export * from "./computed"; -export * from "./mergeSignals"; -export * from "./star"; - diff --git a/code/ts/library/src/plugins/official/attributes/core/mergeSignals.ts b/code/ts/library/src/plugins/official/attributes/core/mergeSignals.ts deleted file mode 100644 index 5c50f4332..000000000 --- a/code/ts/library/src/plugins/official/attributes/core/mergeSignals.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: material-symbols:home-storage -// Slug: Merge signals into a singleton per page -// Description: This action signalss signals into a singleton per page. This is useful for storing signals that are used across multiple components. - -import { - AttributeContext, - AttributePlugin, - RegexpGroups, -} from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; -import { signalsFromPossibleContents } from "../../../../utils/signals"; - -// Merge into singleton signals -export const MergeSignals: AttributePlugin = { - pluginType: PluginType.Attribute, - name: "mergeSignals", - removeNewLines: true, - macros: { - pre: [ - { - pluginType: PluginType.Macro, - name: "signals", - regexp: /(?.+)/g, - replacer: (groups: RegexpGroups) => { - const { whole } = groups; - return `Object.assign({...ctx.signals()}, ${whole})`; - }, - }, - ], - }, - allowedModifiers: new Set(["ifmissing"]), - onLoad: (ctx: AttributeContext) => { - const possibleMergeSignals = ctx.expressionFn(ctx); - const actualMergeSignals = signalsFromPossibleContents( - ctx.signals(), - possibleMergeSignals, - ctx.modifiers.has("ifmissing") - ); - ctx.mergeSignals(actualMergeSignals); - - delete ctx.el.dataset[ctx.rawKey]; - }, -}; diff --git a/code/ts/library/src/plugins/official/attributes/core/star.ts b/code/ts/library/src/plugins/official/attributes/core/star.ts deleted file mode 100644 index 28c89c917..000000000 --- a/code/ts/library/src/plugins/official/attributes/core/star.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: material-symbols:rocket -// Slug: Star -// Description: Sage advice for the weary traveler - -import { AttributePlugin } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; - -export const Star: AttributePlugin = { - pluginType:PluginType.Attribute, - name: "star", - onLoad: () => { - alert("YOU ARE PROBABLY OVERCOMPLICATING IT"); - }, -}; diff --git a/code/ts/library/src/plugins/official/attributes/dom/bind.ts b/code/ts/library/src/plugins/official/attributes/dom/bind.ts deleted file mode 100644 index c0155c5db..000000000 --- a/code/ts/library/src/plugins/official/attributes/dom/bind.ts +++ /dev/null @@ -1,218 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: akar-icons:link-chain -// Slug: Bind attributes to expressions -// Description: Any attribute can be bound to an expression. The attribute will be updated reactively whenever the expression signal changes. - -import { AttributePlugin } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; -import { - ERR_BAD_ARGS, - ERR_METHOD_NOT_ALLOWED, -} from "../../../../engine/errors"; -import { kebabize } from "../../../../utils/text"; - -const dataURIRegex = /^data:(?[^;]+);base64,(?.*)$/; -const updateModelEvents = ["change", "input", "keydown"]; - -export const Bind: AttributePlugin = { - pluginType: PluginType.Attribute, - name: "bind", - onLoad: (ctx) => { - const { - el, - expression, - expressionFn, - key, - upsertSignal, - reactivity: { effect }, - } = ctx; - - let setFromSignal = () => {}; - let fromElementToSignal = () => {}; - - const isTwoWayBinding = key === ""; - - if (isTwoWayBinding) { - // I better be tied to a signal - if (typeof expression !== "string") { - throw new Error("Invalid expression"); - } - if (expression.includes("$")) { - throw new Error("Not an expression"); - } - - const tnl = el.tagName.toLowerCase(); - let signalDefault: string | boolean | File = ""; - const isInput = tnl.includes("input"); - const type = el.getAttribute("type"); - const isCheckbox = - tnl.includes("checkbox") || (isInput && type === "checkbox"); - if (isCheckbox) { - signalDefault = false; - } - const isSelect = tnl.includes("select"); - const isRadio = tnl.includes("radio") || (isInput && type === "radio"); - const isFile = isInput && type === "file"; - if (isFile) { - // can't set a default value for a file input, yet - } - if (isRadio) { - const name = el.getAttribute("name"); - if (!name?.length) { - el.setAttribute("name", expression); - } - } - - const signal = upsertSignal(expression, signalDefault); - - setFromSignal = () => { - const hasValue = "value" in el; - const v = signal.value; - const vStr = `${v}`; - if (isCheckbox || isRadio) { - const input = el as HTMLInputElement; - if (isCheckbox) { - input.checked = v; - } else if (isRadio) { - // evaluate the value as string to handle any type casting - // automatically since the attribute has to be a string anyways - input.checked = vStr === input.value; - } - } else if (isFile) { - // File input reading from a signal is not supported yet - } else if (isSelect) { - const select = el as HTMLSelectElement; - if (select.multiple) { - Array.from(select.options).forEach((opt) => { - if (opt?.disabled) return; - opt.selected = v.includes(opt.value); - }); - } else { - select.value = vStr; - } - } else if (hasValue) { - el.value = vStr; - } else { - el.setAttribute("value", vStr); - } - }; - - fromElementToSignal = async () => { - if (isFile) { - const files = [...((el as HTMLInputElement)?.files || [])], - allContents: string[] = [], - allMimes: string[] = [], - allNames: string[] = []; - - await Promise.all( - files.map((f) => { - return new Promise((resolve) => { - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result !== "string") { - // console.error(`Invalid result type: ${typeof reader.result}`); - throw ERR_BAD_ARGS; - } - const match = reader.result.match(dataURIRegex); - if (!match?.groups) { - // console.error(`Invalid data URI: ${reader.result}`); - throw ERR_BAD_ARGS; - } - allContents.push(match.groups.contents); - allMimes.push(match.groups.mime); - allNames.push(f.name); - }; - reader.onloadend = () => resolve(void 0); - reader.readAsDataURL(f); - }); - }) - ); - - signal.value = allContents; - const s = ctx.signals(); - const mimeName = `${expression}Mimes`, - nameName = `${expression}Names`; - if (mimeName in s) { - s[`${mimeName}`].value = allMimes; - } - if (nameName in s) { - s[`${nameName}`].value = allNames; - } - return; - } - - const current = signal.value; - const input = (el as HTMLInputElement) || (el as HTMLElement); - - if (typeof current === "number") { - signal.value = Number(input.value || input.getAttribute("value")); - } else if (typeof current === "string") { - signal.value = input.value || input.getAttribute("value") || ""; - } else if (typeof current === "boolean") { - if (isCheckbox) { - signal.value = - input.checked || input.getAttribute("checked") === "true"; - } else { - signal.value = Boolean(input.value || input.getAttribute("value")); - } - } else if (typeof current === "undefined") { - } else if (typeof current === "bigint") { - signal.value = BigInt( - input.value || input.getAttribute("value") || "0" - ); - } else if (Array.isArray(current)) { - // check if the input is a select element - if (isSelect) { - const select = el as HTMLSelectElement; - const selectedOptions = [...select.selectedOptions]; - const selectedValues = selectedOptions.map((opt) => opt.value); - signal.value = selectedValues; - } else { - signal.value = JSON.parse(input.value).split(","); - } - console.log(input.value); - } else { - // console.log(`Unsupported type ${typeof current}`); - throw ERR_METHOD_NOT_ALLOWED; - } - }; - } else { - // tied to an attribute - const kebabKey = kebabize(key); - setFromSignal = () => { - const value = expressionFn(ctx); - let v: string; - if (typeof value === "string") { - v = value; - } else { - v = JSON.stringify(value); - } - if (!v || v === "false" || v === "null" || v === "undefined") { - el.removeAttribute(kebabKey); - } else { - el.setAttribute(kebabKey, v); - } - }; - } - - if (isTwoWayBinding) { - updateModelEvents.forEach((event) => { - el.addEventListener(event, fromElementToSignal); - }); - } - - const setElementFromSignalDisposer = effect(async () => { - setFromSignal(); - }); - - return () => { - setElementFromSignalDisposer(); - - if (isTwoWayBinding) { - updateModelEvents.forEach((event) => { - el.removeEventListener(event, fromElementToSignal); - }); - } - }; - }, -}; diff --git a/code/ts/library/src/plugins/official/attributes/dom/class.ts b/code/ts/library/src/plugins/official/attributes/dom/class.ts deleted file mode 100644 index 38ecbbea7..000000000 --- a/code/ts/library/src/plugins/official/attributes/dom/class.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: ic:baseline-format-paint -// Slug: Add or remove classes from an element reactively -// Description: This action adds or removes classes from an element reactively based on the expression provided. The expression should be an object where the keys are the class names and the values are booleans. If the value is true, the class is added. If the value is false, the class is removed. - -import { AttributePlugin } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; - -export const Class: AttributePlugin = { - pluginType: PluginType.Attribute, - name: "class", - mustHaveEmptyKey: true, - mustNotEmptyExpression: true, - - onLoad: (ctx) => { - return ctx.reactivity.effect(() => { - const classes: Object = ctx.expressionFn(ctx); - for (const [k, v] of Object.entries(classes)) { - const classNames = k.split(" "); - if (v) { - ctx.el.classList.add(...classNames); - } else { - ctx.el.classList.remove(...classNames); - } - } - }); - }, -}; diff --git a/code/ts/library/src/plugins/official/attributes/dom/index.ts b/code/ts/library/src/plugins/official/attributes/dom/index.ts deleted file mode 100644 index 90dc8a09c..000000000 --- a/code/ts/library/src/plugins/official/attributes/dom/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -// We don't use these exports, they are purely for access via package managers like NPM - -export * from "./bind"; -export * from "./class"; -export * from "./on"; -export * from "./ref"; -export * from "./text"; diff --git a/code/ts/library/src/plugins/official/attributes/dom/ref.ts b/code/ts/library/src/plugins/official/attributes/dom/ref.ts deleted file mode 100644 index 5907b2fc6..000000000 --- a/code/ts/library/src/plugins/official/attributes/dom/ref.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: mdi:cursor-pointer -// Slug: Create a reference to an element -// Description: This attribute creates a reference to an element that can be used in other expressions. - -import { AttributePlugin } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; - -// Sets the value of the element -export const Ref: AttributePlugin = { - pluginType: PluginType.Attribute, - name: "ref", - mustHaveEmptyKey: true, - mustNotEmptyExpression: true, - bypassExpressionFunctionCreation: () => true, - onLoad: (ctx) => { - const signalName = ctx.expression; - ctx.upsertSignal(signalName, ctx.el); - - return () => { - ctx.removeSignals(signalName); - }; - }, -}; diff --git a/code/ts/library/src/plugins/official/attributes/dom/text.ts b/code/ts/library/src/plugins/official/attributes/dom/text.ts deleted file mode 100644 index 59603fd0c..000000000 --- a/code/ts/library/src/plugins/official/attributes/dom/text.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: tabler:typography -// Slug: Set the text content of an element -// Description: This attribute sets the text content of an element to the result of the expression. - -import { AttributePlugin } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; -import { ERR_BAD_ARGS } from "../../../../engine/errors"; - -export const Text: AttributePlugin = { - pluginType: PluginType.Attribute, - name: "text", - mustHaveEmptyKey: true, - onLoad: (ctx) => { - const { el, expressionFn } = ctx; - if (!(el instanceof HTMLElement)) { - // Element is not HTMLElement - throw ERR_BAD_ARGS; - } - return ctx.reactivity.effect(() => { - const res = expressionFn(ctx); - el.textContent = `${res}`; - }); - }, -}; diff --git a/code/ts/library/src/plugins/official/attributes/index.ts b/code/ts/library/src/plugins/official/attributes/index.ts deleted file mode 100644 index 7505f603f..000000000 --- a/code/ts/library/src/plugins/official/attributes/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -// We don't use these exports, they are purely for access via package managers like NPM - -export * from "./backend"; -export * from "./core"; -export * from "./dom"; -export * from "./storage"; -export * from "./url"; -export * from "./visibility"; diff --git a/code/ts/library/src/plugins/official/attributes/storage/index.ts b/code/ts/library/src/plugins/official/attributes/storage/index.ts deleted file mode 100644 index cc1367889..000000000 --- a/code/ts/library/src/plugins/official/attributes/storage/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// We don't use these exports, they are purely for access via package managers like NPM - -export * from "./persist"; diff --git a/code/ts/library/src/plugins/official/attributes/storage/persist.ts b/code/ts/library/src/plugins/official/attributes/storage/persist.ts deleted file mode 100644 index 3d359d022..000000000 --- a/code/ts/library/src/plugins/official/attributes/storage/persist.ts +++ /dev/null @@ -1,95 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: mdi:floppy-variant -// Slug: Persist data to local storage or session storage -// Description: This plugin allows you to persist data to local storage or session storage. Once you add this attribute the data will be persisted to local storage or session storage. - -import { AttributePlugin } from "../../../../engine"; -import { DATASTAR, DATASTAR_EVENT } from "../../../../engine/consts"; -import { PluginType } from "../../../../engine/enums"; -import { remoteSignals } from "../../../../utils/signals"; -import { DatastarSSEEvent } from "../../watchers/backend/sseShared"; - -export const Persist: AttributePlugin = { - pluginType:PluginType.Attribute, - name: "persist", - allowedModifiers: new Set(["local", "session", "remote"]), - onLoad: (ctx) => { - const key = ctx.key || DATASTAR; - const expression = ctx.expression; - const keys = new Set(); - - if (expression.trim() !== "") { - const value = ctx.expressionFn(ctx); - const parts = value.split(" "); - for (const part of parts) { - keys.add(part); - } - } - - let lastMarshalled = ""; - const storageType = ctx.modifiers.has("session") ? "session" : "local"; - const useRemote = ctx.modifiers.has("remote"); - - const signalsUpdateHandler = ((_: CustomEvent) => { - let signals = ctx.signals(); - if (useRemote) { - signals = remoteSignals(signals); - } - if (keys.size > 0) { - const newSignals: Record = {}; - for (const key of keys) { - const parts = key.split("."); - let newSubSignals = newSignals; - let subSignals = signals; - for (let i = 0; i < parts.length - 1; i++) { - const part = parts[i]; - if (!newSubSignals[part]) { - newSubSignals[part] = {}; - } - newSubSignals = newSubSignals[part]; - subSignals = subSignals[part]; - } - - const lastPart = parts[parts.length - 1]; - newSubSignals[lastPart] = subSignals[lastPart]; - } - signals = newSignals; - } - - const marshalledSignals = JSON.stringify(signals); - - if (marshalledSignals === lastMarshalled) { - return; - } - - if (storageType === "session") { - window.sessionStorage.setItem(key, marshalledSignals); - } else { - window.localStorage.setItem(key, marshalledSignals); - } - - lastMarshalled = marshalledSignals; - }) as EventListener; - - window.addEventListener(DATASTAR_EVENT, signalsUpdateHandler); - - let marshalledSignals: string | null; - - if (storageType === "session") { - marshalledSignals = window.sessionStorage.getItem(key); - } else { - marshalledSignals = window.localStorage.getItem(key); - } - - if (!!marshalledSignals) { - const signals = JSON.parse(marshalledSignals); - for (const key in signals) { - ctx.upsertSignal(key, signals[key]); - } - } - - return () => { - window.removeEventListener(DATASTAR_EVENT, signalsUpdateHandler); - }; - }, -}; diff --git a/code/ts/library/src/plugins/official/attributes/url/index.ts b/code/ts/library/src/plugins/official/attributes/url/index.ts deleted file mode 100644 index e71763b2e..000000000 --- a/code/ts/library/src/plugins/official/attributes/url/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// We don't use these exports, they are purely for access via package managers like NPM - -export * from "./replaceUrl"; diff --git a/code/ts/library/src/plugins/official/attributes/url/replaceUrl.ts b/code/ts/library/src/plugins/official/attributes/url/replaceUrl.ts deleted file mode 100644 index faac92290..000000000 --- a/code/ts/library/src/plugins/official/attributes/url/replaceUrl.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: carbon:url -// Slug: Replace the current URL with a new URL -// Description: This plugin allows you to replace the current URL with a new URL. Once you add this attribute the current URL will be replaced with the new URL. - -import { AttributePlugin } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; - -export const ReplaceUrl: AttributePlugin = { - pluginType: PluginType.Attribute, - name: "replaceUrl", - mustHaveEmptyKey: true, - mustNotEmptyExpression: true, - - onLoad: (ctx) => { - return ctx.reactivity.effect(() => { - const value = ctx.expressionFn(ctx); - const baseUrl = window.location.href; - const url = new URL(value, baseUrl).toString(); - - window.history.replaceState({}, "", url); - }); - }, -}; diff --git a/code/ts/library/src/plugins/official/attributes/visibility/index.ts b/code/ts/library/src/plugins/official/attributes/visibility/index.ts deleted file mode 100644 index 1853832cb..000000000 --- a/code/ts/library/src/plugins/official/attributes/visibility/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -// We don't use these exports, they are purely for access via package managers like NPM - -export * from "./intersects"; -export * from "./scrollIntoView"; -export * from "./show"; -export * from "./viewTransition"; diff --git a/code/ts/library/src/plugins/official/attributes/visibility/scrollIntoView.ts b/code/ts/library/src/plugins/official/attributes/visibility/scrollIntoView.ts deleted file mode 100644 index da42dbaae..000000000 --- a/code/ts/library/src/plugins/official/attributes/visibility/scrollIntoView.ts +++ /dev/null @@ -1,72 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: hugeicons:mouse-scroll-01 -// Slug: Scroll an element into view -// Description: This attribute scrolls the element into view. - -import { AttributeContext, AttributePlugin } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; -import { scrollIntoView } from "../../../../utils/dom"; - -const SMOOTH = "smooth"; -const INSTANT = "instant"; -const AUTO = "auto"; -const HSTART = "hstart"; -const HCENTER = "hcenter"; -const HEND = "hend"; -const HNEAREST = "hnearest"; -const VSTART = "vstart"; -const VCENTER = "vcenter"; -const VEND = "vend"; -const VNEAREST = "vnearest"; -const FOCUS = "focus"; - -const CENTER = "center"; -const START = "start"; -const END = "end"; -const NEAREST = "nearest"; - -// Scrolls the element into view -export const ScrollIntoView: AttributePlugin = { - pluginType: PluginType.Attribute, - name: "scrollIntoView", - mustHaveEmptyKey: true, - mustHaveEmptyExpression: true, - allowedModifiers: new Set([ - SMOOTH, - INSTANT, - AUTO, - HSTART, - HCENTER, - HEND, - HNEAREST, - VSTART, - VCENTER, - VEND, - VNEAREST, - FOCUS, - ]), - - onLoad: ({ el, modifiers, rawKey }: AttributeContext) => { - if (!el.tabIndex) el.setAttribute("tabindex", "0"); - const opts: ScrollIntoViewOptions = { - behavior: SMOOTH, - block: CENTER, - inline: CENTER, - }; - if (modifiers.has(SMOOTH)) opts.behavior = SMOOTH; - if (modifiers.has(INSTANT)) opts.behavior = INSTANT; - if (modifiers.has(AUTO)) opts.behavior = AUTO; - if (modifiers.has(HSTART)) opts.inline = START; - if (modifiers.has(HCENTER)) opts.inline = CENTER; - if (modifiers.has(HEND)) opts.inline = END; - if (modifiers.has(HNEAREST)) opts.inline = NEAREST; - if (modifiers.has(VSTART)) opts.block = START; - if (modifiers.has(VCENTER)) opts.block = CENTER; - if (modifiers.has(VEND)) opts.block = END; - if (modifiers.has(VNEAREST)) opts.block = NEAREST; - - scrollIntoView(el, opts, modifiers.has("focus")); - delete el.dataset[rawKey]; - return () => {}; - }, -}; diff --git a/code/ts/library/src/plugins/official/attributes/visibility/show.ts b/code/ts/library/src/plugins/official/attributes/visibility/show.ts deleted file mode 100644 index edcc5354b..000000000 --- a/code/ts/library/src/plugins/official/attributes/visibility/show.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: streamline:interface-edit-view-eye-eyeball-open-view -// Slug: Show or hide an element -// Description: This attribute shows or hides an element based on the value of the expression. If the expression is true, the element is shown. If the expression is false, the element is hidden. The element is hidden by setting the display property to none. - -import { AttributePlugin } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; - -export const Show: AttributePlugin = { - pluginType: PluginType.Attribute, - name: "show", - mustHaveEmptyKey: true, - mustNotEmptyExpression: true, - - onLoad: (ctx) => { - return ctx.reactivity.effect(async () => { - const shouldShow: boolean = ctx.expressionFn(ctx); - - if (shouldShow) { - if (ctx.el.style.display === "none") { - ctx.el.style.removeProperty("display"); - } - } else { - ctx.el.style.setProperty("display", "none"); - } - }); - }, -}; diff --git a/code/ts/library/src/plugins/official/index.ts b/code/ts/library/src/plugins/official/index.ts deleted file mode 100644 index 0837ac1b0..000000000 --- a/code/ts/library/src/plugins/official/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -// We don't use these exports, they are purely for access via package managers like NPM - -export * from "./actions"; -export * from "./attributes"; -export * from "./macros"; -export * from "./watchers"; - diff --git a/code/ts/library/src/plugins/official/macros/core/actions.ts b/code/ts/library/src/plugins/official/macros/core/actions.ts deleted file mode 100644 index c54d1cf42..000000000 --- a/code/ts/library/src/plugins/official/macros/core/actions.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { MacroPlugin, RegexpGroups } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; -import { wholePrefixSuffix } from "../../../../utils/regex"; - -// Replacing $action(args) with ctx.actions.action(ctx, args) -export const ActionsMacro: MacroPlugin = { - name: "action", - pluginType: PluginType.Macro, - regexp: wholePrefixSuffix( - "@", - "action", - "(?\\((?.*)\\))", - false, - ), - replacer: ({ action, args }: RegexpGroups) => { - const withCtx = [`ctx`]; - if (args) { - withCtx.push(...args.split(",").map((x) => x.trim())); - } - const argsJoined = withCtx.join(","); - return `ctx.actions.${action}.method(${argsJoined})`; - }, -}; diff --git a/code/ts/library/src/plugins/official/macros/core/index.ts b/code/ts/library/src/plugins/official/macros/core/index.ts deleted file mode 100644 index ad8bc6e10..000000000 --- a/code/ts/library/src/plugins/official/macros/core/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// We don't use these exports, they are purely for access via package managers like NPM - -export * from "./actions"; -export * from "./signals"; diff --git a/code/ts/library/src/plugins/official/macros/core/signals.ts b/code/ts/library/src/plugins/official/macros/core/signals.ts deleted file mode 100644 index a72cf6140..000000000 --- a/code/ts/library/src/plugins/official/macros/core/signals.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { MacroPlugin, RegexpGroups } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; -import { wholePrefixSuffix } from "../../../../utils/regex"; - -// Replacing $signal with ctx.store.signal.value` -export const SignalsMacro: MacroPlugin = { - name: "signal", - pluginType: PluginType.Macro, - regexp: wholePrefixSuffix("\\$", "signal", "(?\\([^\\)]*\\))?"), - replacer: (groups: RegexpGroups) => { - const { signal, method } = groups; - const prefix = `ctx.signals()`; - if (!method?.length) { - return `${prefix}.${signal}.value`; - } - const parts = signal.split("."); - const methodName = parts.pop(); - const nestedSignal = parts.join("."); - return `${prefix}.${nestedSignal}.value.${methodName}${method}`; - }, -}; diff --git a/code/ts/library/src/plugins/official/watchers/backend/index.ts b/code/ts/library/src/plugins/official/watchers/backend/index.ts deleted file mode 100644 index 30109665c..000000000 --- a/code/ts/library/src/plugins/official/watchers/backend/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -// We don't use these exports, they are purely for access via package managers like NPM - -export * from "./sseExecuteScript"; -export * from "./sseMergeFragment"; -export * from "./sseMergeSignals"; -export * from "./sseRemoveFragments"; -export * from "./sseRemoveSignals"; -export * from "./sseShared"; diff --git a/code/ts/library/src/plugins/official/watchers/backend/sseExecuteScript.ts b/code/ts/library/src/plugins/official/watchers/backend/sseExecuteScript.ts deleted file mode 100644 index 3f52704bf..000000000 --- a/code/ts/library/src/plugins/official/watchers/backend/sseExecuteScript.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: tabler:file-type-js -// Slug: Execute JavaScript using a Server-Sent Event -// Description: Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. - -import { WatcherPlugin } from "../../../../engine/types"; - -import { - DefaultExecuteScriptAttributes, - DefaultExecuteScriptAutoRemove, - EventTypes, -} from "../../../../engine/consts"; -import { PluginType } from "../../../../engine/enums"; -import { ERR_BAD_ARGS } from "../../../../engine/errors"; -import { isBoolString } from "../../../../utils/text"; -import { datastarSSEEventWatcher } from "./sseShared"; - -export const ExecuteScript: WatcherPlugin = { - pluginType: PluginType.Watcher, - name: EventTypes.ExecuteScript, - onGlobalInit: async () => { - datastarSSEEventWatcher( - EventTypes.ExecuteScript, - ({ - autoRemove: autoRemoveRaw = `${DefaultExecuteScriptAutoRemove}`, - attributes: attributesRaw = DefaultExecuteScriptAttributes, - script, - }) => { - const autoRemove = isBoolString(autoRemoveRaw); - if (!script?.length) { - // No script provided - throw ERR_BAD_ARGS; - } - const scriptEl = document.createElement("script"); - attributesRaw.split("\n").forEach((attr) => { - const pivot = attr.indexOf(" "); - const key = pivot ? attr.slice(0, pivot) : attr; - const value = pivot ? attr.slice(pivot) : ""; - scriptEl.setAttribute(key.trim(), value.trim()); - }); - scriptEl.text = script; - document.head.appendChild(scriptEl); - if (autoRemove) { - scriptEl.remove(); - } - } - ); - }, -}; diff --git a/code/ts/library/src/plugins/official/watchers/backend/sseMergeFragment.ts b/code/ts/library/src/plugins/official/watchers/backend/sseMergeFragment.ts deleted file mode 100644 index c3f552bd8..000000000 --- a/code/ts/library/src/plugins/official/watchers/backend/sseMergeFragment.ts +++ /dev/null @@ -1,168 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: material-symbols:cloud-download -// Slug: Merge fragments into the DOM using a Server-Sent Event -// Description: Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. - -import { InitContext, WatcherPlugin } from "../../../../engine"; -import { - DefaultFragmentMergeMode, - DefaultFragmentsUseViewTransitions, - DefaultSettleDurationMs, - EventTypes, - FragmentMergeModes, -} from "../../../../engine/consts"; -import { PluginType } from "../../../../engine/enums"; -import { ERR_BAD_ARGS } from "../../../../engine/errors"; -import { isBoolString } from "../../../../utils/text"; -import { - docWithViewTransitionAPI, - supportsViewTransitions, -} from "../../../../utils/view-transitions"; -import { idiomorph } from "../../../../vendored/idiomorph"; -import { - datastarSSEEventWatcher, - SETTLING_CLASS, - SWAPPING_CLASS, -} from "./sseShared"; - -export const MergeFragments: WatcherPlugin = { - pluginType: PluginType.Watcher, - name: EventTypes.MergeFragments, - onGlobalInit: async (ctx) => { - const fragmentContainer = document.createElement("template"); - datastarSSEEventWatcher( - EventTypes.MergeFragments, - ({ - fragments: fragmentsRaw = "
", - selector = "", - mergeMode = DefaultFragmentMergeMode, - settleDuration: settleDurationRaw = `${DefaultSettleDurationMs}`, - useViewTransition: - useViewTransitionRaw = `${DefaultFragmentsUseViewTransitions}`, - }) => { - const settleDuration = parseInt(settleDurationRaw); - const useViewTransition = isBoolString(useViewTransitionRaw); - - fragmentContainer.innerHTML = fragmentsRaw.trim(); - const fragments = [...fragmentContainer.content.children]; - fragments.forEach((fragment) => { - if (!(fragment instanceof Element)) { - // No fragments found - throw ERR_BAD_ARGS; - } - - const selectorOrID = selector || `#${fragment.getAttribute("id")}`; - const targets = document.querySelectorAll(selectorOrID) || []; - const allTargets = [...targets]; - if (!allTargets.length) { - // No targets found - throw ERR_BAD_ARGS; - } - - if (supportsViewTransitions && useViewTransition) { - docWithViewTransitionAPI.startViewTransition(() => - applyToTargets( - ctx, - mergeMode, - settleDuration, - fragment, - allTargets - ) - ); - } else { - applyToTargets( - ctx, - mergeMode, - settleDuration, - fragment, - allTargets - ); - } - }); - } - ); - }, -}; - -function applyToTargets( - ctx: InitContext, - mergeMode: string, - settleDuration: number, - fragment: Element, - capturedTargets: Element[] -) { - for (const initialTarget of capturedTargets) { - initialTarget.classList.add(SWAPPING_CLASS); - const originalHTML = initialTarget.outerHTML; - let modifiedTarget = initialTarget; - switch (mergeMode) { - case FragmentMergeModes.Morph: - const result = idiomorph(modifiedTarget, fragment, { - callbacks: { - beforeNodeRemoved: (oldNode: Element, _: Element) => { - ctx.cleanup(oldNode); - return true; - }, - }, - }); - if (!result?.length) { - // No morph result - throw ERR_BAD_ARGS; - } - modifiedTarget = result[0] as Element; - break; - case FragmentMergeModes.Inner: - // Replace the contents of the target element with the response - modifiedTarget.innerHTML = fragment.innerHTML; - break; - case FragmentMergeModes.Outer: - // Replace the entire target element with the response - modifiedTarget.replaceWith(fragment); - break; - case FragmentMergeModes.Prepend: - // Insert the response before the first child of the target element - modifiedTarget.prepend(fragment); - break; - case FragmentMergeModes.Append: - // Insert the response after the last child of the target element - modifiedTarget.append(fragment); - break; - case FragmentMergeModes.Before: - // Insert the response before the target element - modifiedTarget.before(fragment); - break; - case FragmentMergeModes.After: - // Insert the response after the target element - modifiedTarget.after(fragment); - break; - case FragmentMergeModes.UpsertAttributes: - // Upsert the attributes of the target element - fragment.getAttributeNames().forEach((attrName) => { - const value = fragment.getAttribute(attrName)!; - modifiedTarget.setAttribute(attrName, value); - }); - break; - default: - // Unknown merge type - throw ERR_BAD_ARGS; - } - ctx.cleanup(modifiedTarget); - modifiedTarget.classList.add(SWAPPING_CLASS); - - ctx.applyPlugins(document.body); - - setTimeout(() => { - initialTarget.classList.remove(SWAPPING_CLASS); - modifiedTarget.classList.remove(SWAPPING_CLASS); - }, settleDuration); - - const revisedHTML = modifiedTarget.outerHTML; - - if (originalHTML !== revisedHTML) { - modifiedTarget.classList.add(SETTLING_CLASS); - setTimeout(() => { - modifiedTarget.classList.remove(SETTLING_CLASS); - }, settleDuration); - } - } -} diff --git a/code/ts/library/src/plugins/official/watchers/backend/sseMergeSignals.ts b/code/ts/library/src/plugins/official/watchers/backend/sseMergeSignals.ts deleted file mode 100644 index c95394cda..000000000 --- a/code/ts/library/src/plugins/official/watchers/backend/sseMergeSignals.ts +++ /dev/null @@ -1,46 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: material-symbols:settings-input-antenna -// Slug: Merge signals using a Server-Sent Event -// // Description: Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. - -import { InitExpressionFunction, WatcherPlugin } from "../../../../engine"; -import { - DefaultMergeSignalsOnlyIfMissing, - EventTypes, -} from "../../../../engine/consts"; -import { PluginType } from "../../../../engine/enums"; -import { signalsFromPossibleContents } from "../../../../utils/signals"; -import { isBoolString } from "../../../../utils/text"; -import { datastarSSEEventWatcher } from "./sseShared"; - -export const MergeSignals: WatcherPlugin = { - pluginType: PluginType.Watcher, - name: EventTypes.MergeSignals, - onGlobalInit: async (ctx) => { - datastarSSEEventWatcher( - EventTypes.MergeSignals, - ({ - signals = "{}", - onlyIfMissing: onlyIfMissingRaw = `${DefaultMergeSignalsOnlyIfMissing}`, - }) => { - const onlyIfMissing = isBoolString(onlyIfMissingRaw); - const fnContents = ` return Object.assign({...ctx.signals()}, ${signals})`; - try { - const fn = new Function("ctx", fnContents) as InitExpressionFunction; - const possibleMergeSignals = fn(ctx); - const actualMergeSignals = signalsFromPossibleContents( - ctx.signals(), - possibleMergeSignals, - onlyIfMissing - ); - ctx.mergeSignals(actualMergeSignals); - ctx.applyPlugins(document.body); - } catch (e) { - console.log(fnContents); - console.error(e); - debugger; - } - } - ); - }, -}; diff --git a/code/ts/library/src/plugins/official/watchers/backend/sseRemoveFragments.ts b/code/ts/library/src/plugins/official/watchers/backend/sseRemoveFragments.ts deleted file mode 100644 index 8468c182f..000000000 --- a/code/ts/library/src/plugins/official/watchers/backend/sseRemoveFragments.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: material-symbols:settings-input-antenna -// Slug: Remove fragments from the DOM using a Server-Sent Event -// Description: Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. - -import { WatcherPlugin } from "../../../../engine"; -import { - DefaultFragmentsUseViewTransitions, - DefaultSettleDurationMs, - EventTypes, -} from "../../../../engine/consts"; -import { PluginType } from "../../../../engine/enums"; -import { ERR_BAD_ARGS } from "../../../../engine/errors"; -import { isBoolString } from "../../../../utils/text"; -import { - docWithViewTransitionAPI, - supportsViewTransitions, -} from "../../../../utils/view-transitions"; -import { datastarSSEEventWatcher, SWAPPING_CLASS } from "./sseShared"; - -export const RemoveFragments: WatcherPlugin = { - pluginType: PluginType.Watcher, - name: EventTypes.RemoveFragments, - onGlobalInit: async () => { - datastarSSEEventWatcher( - EventTypes.RemoveFragments, - ({ - selector, - settleDuration: settleDurationRaw = `${DefaultSettleDurationMs}`, - useViewTransition: - useViewTransitionRaw = `${DefaultFragmentsUseViewTransitions}`, - }) => { - if (!!!selector.length) { - // No selector provided for remove-fragments - throw ERR_BAD_ARGS; - } - - const settleDuration = parseInt(settleDurationRaw); - const useViewTransition = isBoolString(useViewTransitionRaw); - const removeTargets = document.querySelectorAll(selector); - - const applyToTargets = () => { - for (const target of removeTargets) { - target.classList.add(SWAPPING_CLASS); - } - - setTimeout(() => { - for (const target of removeTargets) { - target.remove(); - } - }, settleDuration); - }; - - if (supportsViewTransitions && useViewTransition) { - docWithViewTransitionAPI.startViewTransition(() => applyToTargets()); - } else { - applyToTargets(); - } - } - ); - }, -}; diff --git a/code/ts/library/src/plugins/official/watchers/backend/sseRemoveSignals.ts b/code/ts/library/src/plugins/official/watchers/backend/sseRemoveSignals.ts deleted file mode 100644 index 014a1a88f..000000000 --- a/code/ts/library/src/plugins/official/watchers/backend/sseRemoveSignals.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Authors: Delaney Gillilan -// Icon: material-symbols:settings-input-antenna -// Slug: Remove signals using a Server-Sent Event -// Description: Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. - -import { EventTypes } from "../../../../engine/consts"; -import { PluginType } from "../../../../engine/enums"; -import { ERR_BAD_ARGS } from "../../../../engine/errors"; -import { WatcherPlugin } from "../../../../engine/types"; -import { datastarSSEEventWatcher } from "./sseShared"; - -export const RemoveSignals: WatcherPlugin = { - pluginType: PluginType.Watcher, - name: EventTypes.RemoveSignals, - onGlobalInit: async (ctx) => { - datastarSSEEventWatcher( - EventTypes.RemoveSignals, - ({ paths: pathsRaw = "" }) => { - const paths = pathsRaw.split("\n").map((p) => p.trim()); - if (!!!paths?.length) { - // No paths provided for remove-signals - throw ERR_BAD_ARGS; - } - ctx.removeSignals(...paths); - } - ); - }, -}; diff --git a/code/ts/library/src/plugins/official/watchers/index.ts b/code/ts/library/src/plugins/official/watchers/index.ts deleted file mode 100644 index 7fb1dba63..000000000 --- a/code/ts/library/src/plugins/official/watchers/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// We don't use these exports, they are purely for access via package managers like NPM - -export * from "./backend"; diff --git a/code/ts/library/src/utils/index.ts b/code/ts/library/src/utils/index.ts deleted file mode 100644 index 04dd27350..000000000 --- a/code/ts/library/src/utils/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// We don't use these exports, they are purely for access via package managers like NPM - -export * from "./arguments"; -export * from "./dom"; -export * from "./regex"; -export * from "./signals"; -export * from "./text"; -export * from "./timing"; -export * from "./types"; -export * from "./view-transitions"; diff --git a/code/ts/library/src/utils/regex.ts b/code/ts/library/src/utils/regex.ts deleted file mode 100644 index 22d7a1124..000000000 --- a/code/ts/library/src/utils/regex.ts +++ /dev/null @@ -1,15 +0,0 @@ -export const validJSIdentifier = `[a-zA-Z_$]+`; -export const validNestedJSIdentifier = validJSIdentifier + `[0-9a-zA-Z_$.]*`; - -export function wholePrefixSuffix( - rune: string, - prefix: string, - suffix: string, - nestable = true, -) { - const identifier = nestable ? validNestedJSIdentifier : validJSIdentifier; - return new RegExp( - `(?${rune}(?<${prefix}>${identifier})${suffix})`, - `g`, - ); -} diff --git a/code/ts/library/src/utils/signals.ts b/code/ts/library/src/utils/signals.ts deleted file mode 100644 index cbfe4362f..000000000 --- a/code/ts/library/src/utils/signals.ts +++ /dev/null @@ -1,36 +0,0 @@ -export function remoteSignals(obj: Object): Object { - const res: Record = {}; - - for (const [k, v] of Object.entries(obj)) { - if (k.startsWith("_")) { - continue; - } else if (typeof v === "object" && !Array.isArray(v)) { - res[k] = remoteSignals(v); // recurse - } else { - res[k] = v; - } - } - - return res; -} - -export function signalsFromPossibleContents( - currentSignals: any, - contents: any, - hasIfMissing: boolean, -) { - const actual: any = {}; - - if (!hasIfMissing) { - Object.assign(actual, contents); - } else { - for (const key in contents) { - const currentValue = currentSignals[key]?.value; - if (currentValue === undefined || currentValue === null) { - actual[key] = contents[key]; - } - } - } - - return actual; -} diff --git a/code/ts/library/src/utils/text.ts b/code/ts/library/src/utils/text.ts deleted file mode 100644 index 898227bf9..000000000 --- a/code/ts/library/src/utils/text.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const kebabize = (str: string) => - str.replace( - /[A-Z]+(?![a-z])|[A-Z]/g, - ($, ofs) => (ofs ? "-" : "") + $.toLowerCase(), - ); - -export const isBoolString = (str: string) => str.trim() === "true"; diff --git a/code/ts/library/src/vendored/deepsignal.ts b/code/ts/library/src/vendored/deepsignal.ts deleted file mode 100644 index bed8a7907..000000000 --- a/code/ts/library/src/vendored/deepsignal.ts +++ /dev/null @@ -1,96 +0,0 @@ -// From https://github.com/EthanStandel/deepsignal/blob/main/packages/core/src/core.ts -import { ERR_NOT_ALLOWED } from "../engine/errors"; -import { batch, Signal, signal } from "./preact-core"; - -export type AtomicState = - | Array - | ((...args: unknown[]) => unknown) - | string - | boolean - | number - | bigint - | symbol - | undefined - | null; - -export type DeepState = { - [key: string]: (() => unknown) | AtomicState | DeepState; -}; - -export type ReadOnlyDeep = { - readonly [P in keyof T]: ReadOnlyDeep; -}; - -export interface DeepSignalAccessors { - value: ReadOnlyDeep; - peek: () => ReadOnlyDeep; -} - -export type DeepSignalType = - & DeepSignalAccessors - & { - [K in keyof T]: T[K] extends AtomicState ? Signal - : T[K] extends DeepState ? DeepSignalType - : Signal; - }; - -export class DeepSignal implements DeepSignalAccessors { - get value(): ReadOnlyDeep { - return getValue(this as DeepSignalType); - } - - set value(payload: ReadOnlyDeep) { - batch(() => setValue(this as DeepSignalType, payload)); - } - - peek(): ReadOnlyDeep { - return getValue(this as DeepSignalType, { peek: true }); - } -} - -export const deepSignal = ( - initialValue: T, -): DeepSignalType => - Object.assign( - new DeepSignal(), - Object.entries(initialValue).reduce( - (acc, [key, value]) => { - if (["value", "peek"].some((iKey) => iKey === key)) { - // console.error(`${key} is a reserved property name`) - throw ERR_NOT_ALLOWED; - } else if ( - typeof value !== "object" || value === null || Array.isArray(value) - ) { - acc[key] = signal(value); - } else { - acc[key] = deepSignal(value); - } - return acc; - }, - {} as { [key: string]: unknown }, - ), - ) as DeepSignalType; - -const setValue = >( - deepSignal: T, - payload: U, -): void => - Object.keys(payload).forEach(( - key: keyof U, - ) => (deepSignal[key].value = payload[key])); - -const getValue = >( - deepSignal: T, - { peek = false }: { peek?: boolean } = {}, -): ReadOnlyDeep => - Object.entries(deepSignal).reduce( - (acc, [key, value]) => { - if (value instanceof Signal) { - acc[key] = peek ? value.peek() : value.value; - } else if (value instanceof DeepSignal) { - acc[key] = getValue(value as DeepSignalType, { peek }); - } - return acc; - }, - {} as { [key: string]: unknown }, - ) as ReadOnlyDeep; diff --git a/code/ts/library/src/vendored/fetch-event-source/fetch.ts b/code/ts/library/src/vendored/fetch-event-source/fetch.ts deleted file mode 100644 index 13caf1922..000000000 --- a/code/ts/library/src/vendored/fetch-event-source/fetch.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { ERR_SERVICE_UNAVAILABLE } from "../../engine/errors"; -import { EventSourceMessage, getBytes, getLines, getMessages } from "./parse"; - -export const EventStreamContentType = "text/event-stream"; - -const DefaultRetryInterval = 1000; -const LastEventId = "last-event-id"; - -export interface FetchEventSourceInit extends RequestInit { - /** - * The request headers. FetchEventSource only supports the Record format. - */ - headers?: Record; - - /** - * Called when a response is received. Use this to validate that the response - * actually matches what you expect (and throw if it doesn't.) If not provided, - * will default to a basic validation to ensure the content-type is text/event-stream. - */ - onopen?: (response: Response) => Promise; - - /** - * Called when a message is received. NOTE: Unlike the default browser - * EventSource.onmessage, this callback is called for _all_ events, - * even ones with a custom `event` field. - */ - onmessage?: (ev: EventSourceMessage) => void; - - /** - * Called when a response finishes. If you don't expect the server to kill - * the connection, you can throw an exception here and retry using onerror. - */ - onclose?: () => void; - - /** - * Called when there is any error making the request / processing messages / - * handling callbacks etc. Use this to control the retry strategy: if the - * error is fatal, rethrow the error inside the callback to stop the entire - * operation. Otherwise, you can return an interval (in milliseconds) after - * which the request will automatically retry (with the last-event-id). - * If this callback is not specified, or it returns undefined, fetchEventSource - * will treat every error as retriable and will try again after 1 second. - */ - onerror?: (err: any) => number | null | undefined | void; - - /** - * If true, will keep the request open even if the document is hidden. - * By default, fetchEventSource will close the request and reopen it - * automatically when the document becomes visible again. - */ - openWhenHidden?: boolean; - - /** The Fetch function to use. Defaults to window.fetch */ - fetch?: typeof fetch; - - /** The scaler for the retry interval. Defaults to 2 */ - retryScaler?: number; - - /** The maximum retry interval in milliseconds. Defaults to 30_000 */ - retryMaxWaitMs?: number; - - /** The maximum number of retries before giving up. Defaults to 10 */ - retryMaxCount?: number; -} - -export function fetchEventSource(input: RequestInfo, { - signal: inputSignal, - headers: inputHeaders, - onopen: inputOnOpen, - onmessage, - onclose, - onerror, - openWhenHidden, - fetch: inputFetch, - retryScaler = 2, - retryMaxWaitMs = 30_000, - retryMaxCount = 10, - ...rest -}: FetchEventSourceInit) { - return new Promise((resolve, reject) => { - let retries = 0; - - // make a copy of the input headers since we may modify it below: - const headers = { ...inputHeaders }; - if (!headers.accept) { - headers.accept = EventStreamContentType; - } - - let curRequestController: AbortController; - function onVisibilityChange() { - curRequestController.abort(); // close existing request on every visibility change - if (!document.hidden) { - create(); // page is now visible again, recreate request. - } - } - - if (!openWhenHidden) { - document.addEventListener("visibilitychange", onVisibilityChange); - } - - let retryInterval = DefaultRetryInterval; - let retryTimer = 0; - function dispose() { - document.removeEventListener( - "visibilitychange", - onVisibilityChange, - ); - window.clearTimeout(retryTimer); - curRequestController.abort(); - } - - // if the incoming signal aborts, dispose resources and resolve: - inputSignal?.addEventListener("abort", () => { - dispose(); - resolve(); // don't waste time constructing/logging errors - }); - - const fetch = inputFetch ?? window.fetch; - const onopen = inputOnOpen ?? - function defaultOnOpen( - // response: Response - ) {}; - - async function create() { - curRequestController = new AbortController(); - try { - const response = await fetch(input, { - ...rest, - headers, - signal: curRequestController.signal, - }); - - await onopen(response); - - await getBytes( - response.body!, - getLines(getMessages((id) => { - if (id) { - // signals the id and send it back on the next retry: - headers[LastEventId] = id; - } else { - // don't send the last-event-id header anymore: - delete headers[LastEventId]; - } - }, (retry) => { - retryInterval = retry; - }, onmessage)), - ); - - onclose?.(); - dispose(); - resolve(); - } catch (err) { - if (!curRequestController.signal.aborted) { - // if we haven't aborted the request ourselves: - try { - // check if we need to retry: - const interval: any = onerror?.(err) ?? retryInterval; - window.clearTimeout(retryTimer); - retryTimer = window.setTimeout(create, interval); - retryInterval *= retryScaler; // exponential backoff - retryInterval = Math.min(retryInterval, retryMaxWaitMs); - retries++; - if (retries >= retryMaxCount) { - // we should not retry anymore: - dispose(); - // Max retries hit, check your server or network connection - reject(ERR_SERVICE_UNAVAILABLE); - } else { - console.error( - `Datastar failed to reach ${rest.method}:${input.toString()} retry in ${interval}ms`, - ); - } - } catch (innerErr) { - // we should not retry anymore: - dispose(); - reject(innerErr); - } - } - } - } - - create(); - }); -} diff --git a/code/ts/library/src/vendored/fetch-event-source/index.ts b/code/ts/library/src/vendored/fetch-event-source/index.ts deleted file mode 100644 index 31efe27ed..000000000 --- a/code/ts/library/src/vendored/fetch-event-source/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { EventStreamContentType, fetchEventSource } from "./fetch"; -export type { FetchEventSourceInit } from "./fetch"; -export type { EventSourceMessage } from "./parse"; diff --git a/code/ts/library/src/vendored/index.ts b/code/ts/library/src/vendored/index.ts deleted file mode 100644 index 758453563..000000000 --- a/code/ts/library/src/vendored/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -// We don't use these exports, they are purely for access via package managers like NPM - -export * from "./deepsignal"; -export * from "./fetch-event-source"; -export * from "./idiomorph"; -export * from "./preact-core"; -export * from "./ts-merge-patch"; diff --git a/code/ts/library/src/vendored/preact-core.ts b/code/ts/library/src/vendored/preact-core.ts deleted file mode 100644 index ee437e201..000000000 --- a/code/ts/library/src/vendored/preact-core.ts +++ /dev/null @@ -1,841 +0,0 @@ -// An named symbol/brand for detecting Signal instances even when they weren't - -import { ERR_BAD_ARGS } from "../engine/errors"; - -// created using the same signals library version. -const BRAND_SYMBOL = Symbol.for("preact-signals"); - -// Flags for Computed and Effect. -const RUNNING = 1 << 0; -const NOTIFIED = 1 << 1; -const OUTDATED = 1 << 2; -const DISPOSED = 1 << 3; -const HAS_ERROR = 1 << 4; -const TRACKING = 1 << 5; - -// A linked list node used to track dependencies (sources) and dependents (targets). -// Also used to remember the source's last version number that the target saw. -type Node = { - // A source whose value the target depends on. - _source: Signal; - _prevSource?: Node; - _nextSource?: Node; - - // A target that depends on the source and should be notified when the source changes. - _target: Computed | Effect; - _prevTarget?: Node; - _nextTarget?: Node; - - // The version number of the source that target has last seen. We use version numbers - // instead of storing the source value, because source values can take arbitrary amount - // of memory, and computeds could hang on to them forever because they're lazily evaluated. - // Use the special value -1 to mark potentially unused but recyclable nodes. - _version: number; - - // Used to remember & roll back the source's previous `._node` value when entering & - // exiting a new evaluation context. - _rollbackNode?: Node; -}; - -function startBatch() { - batchDepth++; -} - -function endBatch() { - if (batchDepth > 1) { - batchDepth--; - return; - } - - let error: unknown; - let hasError = false; - - while (batchedEffect !== undefined) { - let effect: Effect | undefined = batchedEffect; - batchedEffect = undefined; - - batchIteration++; - - while (effect !== undefined) { - const next: Effect | undefined = effect._nextBatchedEffect; - effect._nextBatchedEffect = undefined; - effect._flags &= ~NOTIFIED; - - if (!(effect._flags & DISPOSED) && needsToRecompute(effect)) { - try { - effect._callback(); - } catch (err) { - if (!hasError) { - error = err; - hasError = true; - } - } - } - effect = next; - } - } - batchIteration = 0; - batchDepth--; - - if (hasError) { - throw error; - } -} - -/** - * Combine multiple value updates into one "commit" at the end of the provided callback. - * - * Batches can be nested and changes are only flushed once the outermost batch callback - * completes. - * - * Accessing a signal that has been modified within a batch will reflect its updated - * value. - * - * @param fn The callback function. - * @returns The value returned by the callback. - */ -function batch(fn: () => T): T { - if (batchDepth > 0) { - return fn(); - } - /*@__INLINE__**/ startBatch(); - try { - return fn(); - } finally { - endBatch(); - } -} - -// Currently evaluated computed or effect. -let evalContext: Computed | Effect | undefined = undefined; - -/** - * Run a callback function that can access signal values without - * subscribing to the signal updates. - * - * @param fn The callback function. - * @returns The value returned by the callback. - */ -function untracked(fn: () => T): T { - const prevContext = evalContext; - evalContext = undefined; - try { - return fn(); - } finally { - evalContext = prevContext; - } -} - -// Effects collected into a batch. -let batchedEffect: Effect | undefined = undefined; -let batchDepth = 0; -let batchIteration = 0; - -// A global version number for signals, used for fast-pathing repeated -// computed.peek()/computed.value calls when nothing has changed globally. -let globalVersion = 0; - -function addDependency(signal: Signal): Node | undefined { - if (evalContext === undefined) { - return undefined; - } - - let node = signal._node; - if (node === undefined || node._target !== evalContext) { - /** - * `signal` is a new dependency. Create a new dependency node, and set it - * as the tail of the current context's dependency list. e.g: - * - * { A <-> B } - * ↑ ↑ - * tail node (new) - * ↓ - * { A <-> B <-> C } - * ↑ - * tail (evalContext._sources) - */ - node = { - _version: 0, - _source: signal, - _prevSource: evalContext._sources, - _nextSource: undefined, - _target: evalContext, - _prevTarget: undefined, - _nextTarget: undefined, - _rollbackNode: node, - }; - - if (evalContext._sources !== undefined) { - evalContext._sources._nextSource = node; - } - evalContext._sources = node; - signal._node = node; - - // Subscribe to change notifications from this dependency if we're in an effect - // OR evaluating a computed signal that in turn has subscribers. - if (evalContext._flags & TRACKING) { - signal._subscribe(node); - } - return node; - } else if (node._version === -1) { - // `signal` is an existing dependency from a previous evaluation. Reuse it. - node._version = 0; - - /** - * If `node` is not already the current tail of the dependency list (i.e. - * there is a next node in the list), then make the `node` the new tail. e.g: - * - * { A <-> B <-> C <-> D } - * ↑ ↑ - * node ┌─── tail (evalContext._sources) - * └─────│─────┐ - * ↓ ↓ - * { A <-> C <-> D <-> B } - * ↑ - * tail (evalContext._sources) - */ - if (node._nextSource !== undefined) { - node._nextSource._prevSource = node._prevSource; - - if (node._prevSource !== undefined) { - node._prevSource._nextSource = node._nextSource; - } - - node._prevSource = evalContext._sources; - node._nextSource = undefined; - - evalContext._sources!._nextSource = node; - evalContext._sources = node; - } - - // We can assume that the currently evaluated effect / computed signal is already - // subscribed to change notifications from `signal` if needed. - return node; - } - return undefined; -} - -/** - * The base class for plain and computed signals. - */ -// @ts-ignore: "Cannot redeclare exported variable 'Signal'." -// -// A function with the same name is defined later, so we need to ignore TypeScript's -// warning about a redeclared variable. -// -// The class is declared here, but later implemented with ES5-style protoTYPEOF_ -// This enables better control of the transpiled output size. -declare class Signal { - /** @internal */ - _value: unknown; - - /** - * @internal - * Version numbers should always be >= 0, because the special value -1 is used - * by Nodes to signify potentially unused but recyclable nodes. - */ - _version: number; - - /** @internal */ - _node?: Node; - - /** @internal */ - _targets?: Node; - - constructor(value?: T); - - /** @internal */ - _refresh(): boolean; - - /** @internal */ - _subscribe(node: Node): void; - - /** @internal */ - _unsubscribe(node: Node): void; - - subscribe(fn: (value: T) => void): () => void; - - valueOf(): T; - - toString(): string; - - toJSON(): T; - - peek(): T; - - brand: typeof BRAND_SYMBOL; - - get value(): T; - set value(value: T); -} - -/** @internal */ -// @ts-ignore: "Cannot redeclare exported variable 'Signal'." -// -// A class with the same name has already been declared, so we need to ignore -// TypeScript's warning about a redeclared variable. -// -// The previously declared class is implemented here with ES5-style protoTYPEOF_ -// This enables better control of the transpiled output size. -function Signal(this: Signal, value?: unknown) { - this._value = value; - this._version = 0; - this._node = undefined; - this._targets = undefined; -} - -Signal.prototype.brand = BRAND_SYMBOL; - -Signal.prototype._refresh = function () { - return true; -}; - -Signal.prototype._subscribe = function (node) { - if (this._targets !== node && node._prevTarget === undefined) { - node._nextTarget = this._targets; - if (this._targets !== undefined) { - this._targets._prevTarget = node; - } - this._targets = node; - } -}; - -Signal.prototype._unsubscribe = function (node) { - // Only run the unsubscribe step if the signal has any subscribers to begin with. - if (this._targets !== undefined) { - const prev = node._prevTarget; - const next = node._nextTarget; - if (prev !== undefined) { - prev._nextTarget = next; - node._prevTarget = undefined; - } - if (next !== undefined) { - next._prevTarget = prev; - node._nextTarget = undefined; - } - if (node === this._targets) { - this._targets = next; - } - } -}; - -Signal.prototype.subscribe = function (fn) { - return effect(() => { - const value = this.value; - - const prevContext = evalContext; - evalContext = undefined; - try { - fn(value); - } finally { - evalContext = prevContext; - } - }); -}; - -Signal.prototype.valueOf = function () { - return this.value; -}; - -Signal.prototype.toString = function () { - return this.value + ""; -}; - -Signal.prototype.toJSON = function () { - return this.value; -}; - -Signal.prototype.peek = function () { - const prevContext = evalContext; - evalContext = undefined; - try { - return this.value; - } finally { - evalContext = prevContext; - } -}; - -Object.defineProperty(Signal.prototype, "value", { - get(this: Signal) { - const node = addDependency(this); - if (node !== undefined) { - node._version = this._version; - } - return this._value; - }, - set(this: Signal, value) { - if (value !== this._value) { - if (batchIteration > 100) { - // Cycle detected - throw ERR_BAD_ARGS; - } - - this._value = value; - this._version++; - globalVersion++; - - /**@__INLINE__*/ startBatch(); - try { - for ( - let node = this._targets; - node !== undefined; - node = node._nextTarget - ) { - node._target._notify(); - } - } finally { - endBatch(); - } - } - }, -}); - -/** - * Create a new plain signal. - * - * @param value The initial value for the signal. - * @returns A new signal. - */ -export function signal(value: T): Signal; -export function signal(): Signal; -export function signal(value?: T): Signal { - return new Signal(value); -} - -function needsToRecompute(target: Computed | Effect): boolean { - // Check the dependencies for changed values. The dependency list is already - // in order of use. Therefore if multiple dependencies have changed values, only - // the first used dependency is re-evaluated at this point. - for ( - let node = target._sources; - node !== undefined; - node = node._nextSource - ) { - // If there's a new version of the dependency before or after refreshing, - // or the dependency has something blocking it from refreshing at all (e.g. a - // dependency cycle), then we need to recompute. - if ( - node._source._version !== node._version || - !node._source._refresh() || - node._source._version !== node._version - ) { - return true; - } - } - // If none of the dependencies have changed values since last recompute then - // there's no need to recompute. - return false; -} - -function prepareSources(target: Computed | Effect) { - /** - * 1. Mark all current sources as re-usable nodes (version: -1) - * 2. Set a rollback node if the current node is being used in a different context - * 3. Point 'target._sources' to the tail of the doubly-linked list, e.g: - * - * { undefined <- A <-> B <-> C -> undefined } - * ↑ ↑ - * │ └──────┐ - * target._sources = A; (node is head) │ - * ↓ │ - * target._sources = C; (node is tail) ─┘ - */ - for ( - let node = target._sources; - node !== undefined; - node = node._nextSource - ) { - const rollbackNode = node._source._node; - if (rollbackNode !== undefined) { - node._rollbackNode = rollbackNode; - } - node._source._node = node; - node._version = -1; - - if (node._nextSource === undefined) { - target._sources = node; - break; - } - } -} - -function cleanupSources(target: Computed | Effect) { - let node = target._sources; - let head = undefined; - - /** - * At this point 'target._sources' points to the tail of the doubly-linked list. - * It contains all existing sources + new sources in order of use. - * Iterate backwards until we find the head node while dropping old dependencies. - */ - while (node !== undefined) { - const prev = node._prevSource; - - /** - * The node was not re-used, unsubscribe from its change notifications and remove itself - * from the doubly-linked list. e.g: - * - * { A <-> B <-> C } - * ↓ - * { A <-> C } - */ - if (node._version === -1) { - node._source._unsubscribe(node); - - if (prev !== undefined) { - prev._nextSource = node._nextSource; - } - if (node._nextSource !== undefined) { - node._nextSource._prevSource = prev; - } - } else { - /** - * The new head is the last node seen which wasn't removed/unsubscribed - * from the doubly-linked list. e.g: - * - * { A <-> B <-> C } - * ↑ ↑ ↑ - * │ │ └ head = node - * │ └ head = node - * └ head = node - */ - head = node; - } - - node._source._node = node._rollbackNode; - if (node._rollbackNode !== undefined) { - node._rollbackNode = undefined; - } - - node = prev; - } - - target._sources = head; -} - -declare class Computed extends Signal { - _fn: () => T; - _sources?: Node; - _globalVersion: number; - _flags: number; - - constructor(fn: () => T); - - _notify(): void; - get value(): T; -} - -function Computed(this: Computed, fn: () => unknown) { - Signal.call(this, undefined); - - this._fn = fn; - this._sources = undefined; - this._globalVersion = globalVersion - 1; - this._flags = OUTDATED; -} - -Computed.prototype = new Signal() as Computed; - -Computed.prototype._refresh = function () { - this._flags &= ~NOTIFIED; - - if (this._flags & RUNNING) { - return false; - } - - // If this computed signal has subscribed to updates from its dependencies - // (TRACKING flag set) and none of them have notified about changes (OUTDATED - // flag not set), then the computed value can't have changed. - if ((this._flags & (OUTDATED | TRACKING)) === TRACKING) { - return true; - } - this._flags &= ~OUTDATED; - - if (this._globalVersion === globalVersion) { - return true; - } - this._globalVersion = globalVersion; - - // Mark this computed signal running before checking the dependencies for value - // changes, so that the RUNNING flag can be used to notice cyclical dependencies. - this._flags |= RUNNING; - if (this._version > 0 && !needsToRecompute(this)) { - this._flags &= ~RUNNING; - return true; - } - - const prevContext = evalContext; - try { - prepareSources(this); - evalContext = this; - const value = this._fn(); - if ( - this._flags & HAS_ERROR || - this._value !== value || - this._version === 0 - ) { - this._value = value; - this._flags &= ~HAS_ERROR; - this._version++; - } - } catch (err) { - this._value = err; - this._flags |= HAS_ERROR; - this._version++; - } - evalContext = prevContext; - cleanupSources(this); - this._flags &= ~RUNNING; - return true; -}; - -Computed.prototype._subscribe = function (node) { - if (this._targets === undefined) { - this._flags |= OUTDATED | TRACKING; - - // A computed signal subscribes lazily to its dependencies when it - // gets its first subscriber. - for ( - let node = this._sources; - node !== undefined; - node = node._nextSource - ) { - node._source._subscribe(node); - } - } - Signal.prototype._subscribe.call(this, node); -}; - -Computed.prototype._unsubscribe = function (node) { - // Only run the unsubscribe step if the computed signal has any subscribers. - if (this._targets !== undefined) { - Signal.prototype._unsubscribe.call(this, node); - - // Computed signal unsubscribes from its dependencies when it loses its last subscriber. - // This makes it possible for unreferences subgraphs of computed signals to get garbage collected. - if (this._targets === undefined) { - this._flags &= ~TRACKING; - - for ( - let node = this._sources; - node !== undefined; - node = node._nextSource - ) { - node._source._unsubscribe(node); - } - } - } -}; - -Computed.prototype._notify = function () { - if (!(this._flags & NOTIFIED)) { - this._flags |= OUTDATED | NOTIFIED; - - for ( - let node = this._targets; - node !== undefined; - node = node._nextTarget - ) { - node._target._notify(); - } - } -}; - -Object.defineProperty(Computed.prototype, "value", { - get(this: Computed) { - if (this._flags & RUNNING) { - // Cycle detected - throw ERR_BAD_ARGS; - } - const node = addDependency(this); - this._refresh(); - if (node !== undefined) { - node._version = this._version; - } - if (this._flags & HAS_ERROR) { - throw this._value; - } - return this._value; - }, -}); - -/** - * An interface for read-only signals. - */ -interface ReadonlySignal { - readonly value: T; - peek(): T; - - subscribe(fn: (value: T) => void): () => void; - valueOf(): T; - toString(): string; - toJSON(): T; - brand: typeof BRAND_SYMBOL; -} - -/** - * Create a new signal that is computed based on the values of other signals. - * - * The returned computed signal is read-only, and its value is automatically - * updated when any signals accessed from within the callback function change. - * - * @param fn The effect callback. - * @returns A new read-only signal. - */ -function computed(fn: () => T): ReadonlySignal { - return new Computed(fn); -} - -function cleanupEffect(effect: Effect) { - const cleanup = effect._cleanup; - effect._cleanup = undefined; - - if (typeof cleanup === "function") { - /*@__INLINE__**/ startBatch(); - - // Run cleanup functions always outside of any context. - const prevContext = evalContext; - evalContext = undefined; - try { - cleanup!(); - } catch (err) { - effect._flags &= ~RUNNING; - effect._flags |= DISPOSED; - disposeEffect(effect); - throw err; - } finally { - evalContext = prevContext; - endBatch(); - } - } -} - -function disposeEffect(effect: Effect) { - for ( - let node = effect._sources; - node !== undefined; - node = node._nextSource - ) { - node._source._unsubscribe(node); - } - effect._fn = undefined; - effect._sources = undefined; - - cleanupEffect(effect); -} - -function endEffect(this: Effect, prevContext?: Computed | Effect) { - if (evalContext !== this) { - // Out-of-order effect - throw ERR_BAD_ARGS; - } - cleanupSources(this); - evalContext = prevContext; - - this._flags &= ~RUNNING; - if (this._flags & DISPOSED) { - disposeEffect(this); - } - endBatch(); -} - -type EffectFn = () => void | (() => void); - -declare class Effect { - _fn?: EffectFn; - _cleanup?: () => void; - _sources?: Node; - _nextBatchedEffect?: Effect; - _flags: number; - - constructor(fn: EffectFn); - - _callback(): void; - _start(): () => void; - _notify(): void; - _dispose(): void; -} - -function Effect(this: Effect, fn: EffectFn) { - this._fn = fn; - this._cleanup = undefined; - this._sources = undefined; - this._nextBatchedEffect = undefined; - this._flags = TRACKING; -} - -Effect.prototype._callback = function () { - const finish = this._start(); - try { - if (this._flags & DISPOSED) return; - if (this._fn === undefined) return; - - const cleanup = this._fn(); - if (typeof cleanup === "function") { - this._cleanup = cleanup!; - } - } finally { - finish(); - } -}; - -Effect.prototype._start = function () { - if (this._flags & RUNNING) { - // Cycle detected - throw ERR_BAD_ARGS; - } - this._flags |= RUNNING; - this._flags &= ~DISPOSED; - cleanupEffect(this); - prepareSources(this); - - /*@__INLINE__**/ startBatch(); - const prevContext = evalContext; - evalContext = this; - return endEffect.bind(this, prevContext); -}; - -Effect.prototype._notify = function () { - if (!(this._flags & NOTIFIED)) { - this._flags |= NOTIFIED; - this._nextBatchedEffect = batchedEffect; - batchedEffect = this; - } -}; - -Effect.prototype._dispose = function () { - this._flags |= DISPOSED; - - if (!(this._flags & RUNNING)) { - disposeEffect(this); - } -}; - -/** - * Create an effect to run arbitrary code in response to signal changes. - * - * An effect tracks which signals are accessed within the given callback - * function `fn`, and re-runs the callback when those signals change. - * - * The callback may return a cleanup function. The cleanup function gets - * run once, either when the callback is next called or when the effect - * gets disposed, whichever happens first. - * - * @param fn The effect callback. - * @returns A function for disposing the effect. - */ -function effect(fn: EffectFn): () => void { - const effect = new Effect(fn); - try { - effect._callback(); - } catch (err) { - effect._dispose(); - throw err; - } - // Return a bound function instead of a wrapper like `() => effect._dispose()`, - // because bound functions seem to be just as fast and take up a lot less memory. - return effect._dispose.bind(effect); -} - -export { batch, computed, effect, Signal, untracked }; -export type { ReadonlySignal }; diff --git a/code/ts/library/src/vendored/ts-merge-patch.ts b/code/ts/library/src/vendored/ts-merge-patch.ts deleted file mode 100644 index f56c79fc1..000000000 --- a/code/ts/library/src/vendored/ts-merge-patch.ts +++ /dev/null @@ -1,54 +0,0 @@ -// From https://github.com/riagominota/ts-merge-patch/blob/main/src/index.ts - -type mpObj = { [k in keyof T | string | number | symbol]: any }; -export function apply( - target: mpObj, - patchItem: mpObj, -): Partial & Partial; -export function apply(target: mpObj, patchItem: mpObj): R; -export function apply(target: mpObj, patchItem: mpObj): {}; -export function apply(target: mpObj, patchItem: null): null; -export function apply(target: mpObj, patchItem: string): string; -export function apply(target: mpObj, patchItem: number): number; -export function apply(target: mpObj, patchItem: undefined): undefined; -export function apply(target: mpObj, patchItem: R[]): R[]; - -export function apply(target: any, patchItem: any): any { - /** - * If the patch is anything other than an object, - * the result will always be to replace - * the entire target with the entire patch. - */ - if ( - typeof patchItem !== "object" || Array.isArray(patchItem) || !patchItem - ) { - return JSON.parse(JSON.stringify(patchItem)); //return new instance of variable - } - - if ( - typeof patchItem === "object" && - patchItem.toJSON !== undefined && - typeof patchItem.toJSON === "function" - ) { - return patchItem.toJSON(); - } - /** Also, it is not possible to - * patch part of a target that is not an object, - * such as to replace just some of the values in an array. - */ - let targetResult = target; - if (typeof target !== "object") { - //Target is empty/not an object, so basically becomes patch, minus any null valued sections (becomes {} + patch) - targetResult = { ...patchItem }; - } - - Object.keys(patchItem).forEach((k) => { - if (!targetResult.hasOwnProperty(k)) targetResult[k] = patchItem[k]; //This ensure the key exists and TS can't throw a wobbly over an undefined key - if (patchItem[k] === null) { - delete targetResult[k]; - } else { - targetResult[k] = apply(targetResult[k], patchItem[k]); - } - }); - return targetResult; -} diff --git a/code/dotnet/samples/CsharpAspServer/CsharpAspServer.csproj b/examples/dotnet/CsharpAspServer/CsharpAspServer.csproj similarity index 100% rename from code/dotnet/samples/CsharpAspServer/CsharpAspServer.csproj rename to examples/dotnet/CsharpAspServer/CsharpAspServer.csproj diff --git a/code/dotnet/samples/CsharpAspServer/Program.cs b/examples/dotnet/CsharpAspServer/Program.cs similarity index 100% rename from code/dotnet/samples/CsharpAspServer/Program.cs rename to examples/dotnet/CsharpAspServer/Program.cs diff --git a/code/dotnet/samples/CsharpAspServer/Properties/launchSettings.json b/examples/dotnet/CsharpAspServer/Properties/launchSettings.json similarity index 100% rename from code/dotnet/samples/CsharpAspServer/Properties/launchSettings.json rename to examples/dotnet/CsharpAspServer/Properties/launchSettings.json diff --git a/code/dotnet/samples/CsharpAspServer/appsettings.Development.json b/examples/dotnet/CsharpAspServer/appsettings.Development.json similarity index 100% rename from code/dotnet/samples/CsharpAspServer/appsettings.Development.json rename to examples/dotnet/CsharpAspServer/appsettings.Development.json diff --git a/code/dotnet/samples/CsharpAspServer/appsettings.json b/examples/dotnet/CsharpAspServer/appsettings.json similarity index 100% rename from code/dotnet/samples/CsharpAspServer/appsettings.json rename to examples/dotnet/CsharpAspServer/appsettings.json diff --git a/code/dotnet/samples/FalcoServer/FalcoServer.fsproj b/examples/dotnet/FalcoServer/FalcoServer.fsproj similarity index 100% rename from code/dotnet/samples/FalcoServer/FalcoServer.fsproj rename to examples/dotnet/FalcoServer/FalcoServer.fsproj diff --git a/code/dotnet/samples/FalcoServer/Program.fs b/examples/dotnet/FalcoServer/Program.fs similarity index 100% rename from code/dotnet/samples/FalcoServer/Program.fs rename to examples/dotnet/FalcoServer/Program.fs diff --git a/code/dotnet/samples/FalcoServer/Properties/launchSettings.json b/examples/dotnet/FalcoServer/Properties/launchSettings.json similarity index 100% rename from code/dotnet/samples/FalcoServer/Properties/launchSettings.json rename to examples/dotnet/FalcoServer/Properties/launchSettings.json diff --git a/code/dotnet/samples/FalcoServer/appsettings.Development.json b/examples/dotnet/FalcoServer/appsettings.Development.json similarity index 100% rename from code/dotnet/samples/FalcoServer/appsettings.Development.json rename to examples/dotnet/FalcoServer/appsettings.Development.json diff --git a/code/dotnet/samples/FalcoServer/appsettings.json b/examples/dotnet/FalcoServer/appsettings.json similarity index 100% rename from code/dotnet/samples/FalcoServer/appsettings.json rename to examples/dotnet/FalcoServer/appsettings.json diff --git a/code/dotnet/samples/Samples.sln b/examples/dotnet/Samples.sln similarity index 97% rename from code/dotnet/samples/Samples.sln rename to examples/dotnet/Samples.sln index b20269491..4c6f7cfad 100644 --- a/code/dotnet/samples/Samples.sln +++ b/examples/dotnet/Samples.sln @@ -6,7 +6,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CsharpAspServer", "CsharpAs EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "BuildFiles", "BuildFiles", "{01B23A4D-E3AD-4C90-A321-F8FE8D60F1BE}" ProjectSection(SolutionItems) = preProject - ..\..\go\tsbuild\consts_dotnet.qtpl = ..\..\go\tsbuild\consts_dotnet.qtpl + ..\..\go\build\consts_dotnet.qtpl = ..\..\go\build\consts_dotnet.qtpl ..\Build.ps1 = ..\Build.ps1 EndProjectSection EndProject diff --git a/code/dotnet/samples/Shared/wwwroot/index.html b/examples/dotnet/Shared/wwwroot/index.html similarity index 92% rename from code/dotnet/samples/Shared/wwwroot/index.html rename to examples/dotnet/Shared/wwwroot/index.html index 41eff197a..d45fa060f 100644 --- a/code/dotnet/samples/Shared/wwwroot/index.html +++ b/examples/dotnet/Shared/wwwroot/index.html @@ -13,7 +13,7 @@
@@ -51,7 +51,7 @@

-

+          

         
diff --git a/code/dotnet/samples/Shared/wwwroot/style.css b/examples/dotnet/Shared/wwwroot/style.css similarity index 100% rename from code/dotnet/samples/Shared/wwwroot/style.css rename to examples/dotnet/Shared/wwwroot/style.css diff --git a/code/dotnet/assets/datastar_icon.png b/examples/dotnet/assets/datastar_icon.png similarity index 100% rename from code/dotnet/assets/datastar_icon.png rename to examples/dotnet/assets/datastar_icon.png diff --git a/code/ts/library/.gitignore b/library/.gitignore similarity index 100% rename from code/ts/library/.gitignore rename to library/.gitignore diff --git a/code/ts/library/README.md b/library/README.md similarity index 59% rename from code/ts/library/README.md rename to library/README.md index f4c44d875..2ca5d2027 100644 --- a/code/ts/library/README.md +++ b/library/README.md @@ -4,20 +4,29 @@ ![Discord](https://img.shields.io/discord/1296224603642925098) ![GitHub Repo stars](https://img.shields.io/github/stars/starfederation/datastar?style=flat) -

+

# Datastar -### A real-time hypermedia framework. +### The hypermedia framework. -Datastar helps you build real-time web applications with the simplicity of server-side rendering and the power of a full-stack SPA framework. +Datastar helps you build reactive web applications with the simplicity of server-side rendering and the power of a full-stack SPA framework. + +Getting started is as easy as adding a single script tag to your HTML. + +```html + +``` + +Then start adding frontend reactivity using declarative `data-*` attributes. +Datastar helps you build reactive web applications with the simplicity of server-side rendering and the power of a full-stack SPA framework. Here’s what frontend reactivity looks like using Datastar: ```html - -
- + +
+ ``` Visit the [Datastar Website »](https://data-star.dev/) diff --git a/code/ts/library/package.json b/library/package.json similarity index 95% rename from code/ts/library/package.json rename to library/package.json index 0432e60a9..a8983bd5e 100644 --- a/code/ts/library/package.json +++ b/library/package.json @@ -2,7 +2,7 @@ "name": "@starfederation/datastar", "author": "Delaney Gillilan", "description": "Hypermedia first SPA replacement framework", - "version": "0.20.1", + "version": "0.21.0-beta1", "license": "MIT", "private": false, "homepage": "https://data-star.dev", diff --git a/code/ts/library/src/bundles/datastar-core.ts b/library/src/bundles/datastar-core.ts similarity index 100% rename from code/ts/library/src/bundles/datastar-core.ts rename to library/src/bundles/datastar-core.ts diff --git a/library/src/bundles/datastar.ts b/library/src/bundles/datastar.ts new file mode 100644 index 000000000..66bd3440c --- /dev/null +++ b/library/src/bundles/datastar.ts @@ -0,0 +1,55 @@ +import { Datastar } from "../engine"; +import { ServerSentEvents as SSE } from "../plugins/official/backend/actions/sse"; +import { Indicator } from "../plugins/official/backend/attributes/indicator"; +import { ExecuteScript } from "../plugins/official/backend/watchers/executeScript"; +import { MergeFragments } from "../plugins/official/backend/watchers/mergeFragments"; +import { MergeSignals } from "../plugins/official/backend/watchers/mergeSignals"; +import { RemoveFragments } from "../plugins/official/backend/watchers/removeFragments"; +import { RemoveSignals } from "../plugins/official/backend/watchers/removeSignals"; +import { Clipboard } from "../plugins/official/browser/actions/clipboard"; +import { Intersects } from "../plugins/official/browser/attributes/intersects"; +import { Persist } from "../plugins/official/browser/attributes/persist"; +import { ReplaceUrl } from "../plugins/official/browser/attributes/replaceUrl"; +import { ScrollIntoView } from "../plugins/official/browser/attributes/scrollIntoView"; +import { Show } from "../plugins/official/browser/attributes/show"; +import { ViewTransition } from "../plugins/official/browser/attributes/viewTransition"; +import { Attributes } from "../plugins/official/dom/attributes/attributes"; +import { Bind } from "../plugins/official/dom/attributes/bind"; +import { Class } from "../plugins/official/dom/attributes/class"; +import { On } from "../plugins/official/dom/attributes/on"; +import { Ref } from "../plugins/official/dom/attributes/ref"; +import { Text } from "../plugins/official/dom/attributes/text"; +import { Fit } from "../plugins/official/logic/actions/fit"; +import { SetAll } from "../plugins/official/logic/actions/setAll"; +import { ToggleAll } from "../plugins/official/logic/actions/toggleAll"; + +Datastar.load( + // Plugins that can create signals must be loaded first + Attributes, + Bind, + Indicator, + Ref, + // DOM + Class, + On, + Show, + Text, + // Backend + SSE, + MergeFragments, + MergeSignals, + RemoveFragments, + RemoveSignals, + ExecuteScript, + // Browser + Clipboard, + Intersects, + Persist, + ReplaceUrl, + ScrollIntoView, + ViewTransition, + // Logic + Fit, + SetAll, + ToggleAll, +); diff --git a/code/ts/library/src/engine/consts.ts b/library/src/engine/consts.ts similarity index 98% rename from code/ts/library/src/engine/consts.ts rename to library/src/engine/consts.ts index 785eaf66b..ba26b4dfd 100644 --- a/code/ts/library/src/engine/consts.ts +++ b/library/src/engine/consts.ts @@ -3,7 +3,7 @@ export const DATASTAR = "datastar"; export const DATASTAR_EVENT = "datastar-event"; export const DATASTAR_REQUEST = "Datastar-Request"; -export const VERSION = "0.20.1"; +export const VERSION = "0.21.0-beta1"; // #region Defaults diff --git a/library/src/engine/engine.ts b/library/src/engine/engine.ts new file mode 100644 index 000000000..4f30eaba8 --- /dev/null +++ b/library/src/engine/engine.ts @@ -0,0 +1,263 @@ +import { elUniqId } from "../utils/dom"; +import { camelize } from "../utils/text"; +import { effect } from "../vendored/preact-core"; +import { VERSION } from "./consts"; +import { dsErr } from "./errors"; +import { SignalsRoot } from "./nestedSignals"; +import { + ActionPlugin, + ActionPlugins, + AttributePlugin, + DatastarPlugin, + GlobalInitializer, + HTMLorSVGElement, + MacroPlugin, + Modifiers, + OnRemovalFn, + PluginType, + RemovalEntry, + Requirement, + RuntimeContext, + RuntimeExpressionFunction, + WatcherPlugin, +} from "./types"; + +export class Engine { + private _signals = new SignalsRoot(); + private plugins: AttributePlugin[] = []; + private macros: MacroPlugin[] = []; + private actions: ActionPlugins = {}; + private watchers: WatcherPlugin[] = []; + private removals = new Map(); + + get version() { + return VERSION; + } + + public load(...pluginsToLoad: DatastarPlugin[]) { + pluginsToLoad.forEach((plugin) => { + let globalInitializer: GlobalInitializer | undefined; + switch (plugin.type) { + case PluginType.Macro: + this.macros.push(plugin as MacroPlugin); + break; + case PluginType.Watcher: + const wp = plugin as WatcherPlugin; + this.watchers.push(wp); + globalInitializer = wp.onGlobalInit; + break; + case PluginType.Action: + this.actions[plugin.name] = plugin as ActionPlugin; + break; + case PluginType.Attribute: + const ap = plugin as AttributePlugin; + this.plugins.push(ap); + globalInitializer = ap.onGlobalInit; + break; + default: + throw dsErr("InvalidPluginType", { + name: plugin.name, + type: plugin.type, + }); + } + if (globalInitializer) { + const that = this; // I hate javascript + globalInitializer({ + get signals() { + return that._signals; + }, + effect: (cb: () => void): OnRemovalFn => effect(cb), + actions: this.actions, + apply: this.apply.bind(this), + cleanup: this.cleanup.bind(this), + }); + } + }); + this.apply(document.body); + } + + // Clenup all plugins associated with the element + private cleanup(element: Element) { + const removalSet = this.removals.get(element); + if (removalSet) { + for (const removal of removalSet.set) { + removal(); + } + this.removals.delete(element); + } + } + + // Apply all plugins to the element and its children + private apply(rootElement: Element) { + const appliedMacros = new Set(); + this.plugins.forEach((p, pi) => { + this.walkDownDOM(rootElement, (el) => { + // Cleanup if not first plugin + if (!pi) this.cleanup(el); + + for (const rawKey in el.dataset) { + // Check if the key is relevant to the plugin + if (!rawKey.startsWith(p.name)) continue; + + // Extract the key and value from the dataset + const keyRaw = rawKey.slice(p.name.length); + let [key, ...rawModifiers] = keyRaw.split(":"); + + const hasKey = key.length > 0; + if (hasKey) { + key = key[0].toLowerCase() + key.slice(1); + } + const rawValue = `${el.dataset[rawKey]}` || ""; + let value = rawValue; + const hasValue = value.length > 0; + + // Check the requirements + const keyReq = p.keyReq || Requirement.Allowed; + if (hasKey) { + if (keyReq === Requirement.Denied) { + throw dsErr(p.name + "KeyNotAllowed"); + } + } else if (keyReq === Requirement.Must) { + throw dsErr(p.name + "KeyRequired"); + } + const valReq = p.valReq || Requirement.Allowed; + if (hasValue) { + if (valReq === Requirement.Denied) { + throw dsErr(p.name + "ValueNotAllowed"); + } + } else if (valReq === Requirement.Must) { + throw dsErr(p.name + "ValueRequired"); + } + + // Check for exclusive requirements + if (keyReq === Requirement.Exclusive || valReq === Requirement.Exclusive) { + if (hasKey && hasValue) { + throw dsErr(p.name + "KeyAndValueProvided"); + } else if (!hasKey && !hasValue) { + throw dsErr(p.name + "KeyOrValueRequired"); + } + } + + // Ensure the element has an id + if (!el.id.length) el.id = elUniqId(el); + + // Apply the macros + appliedMacros.clear(); + const mods: Modifiers = new Map>(); + rawModifiers.forEach((m) => { + const [label, ...args] = m.split("_"); + mods.set(camelize(label), new Set(args)); + }); + const macros = [ + ...(p.macros?.pre || []), + ...this.macros, + ...(p.macros?.post || []), + ]; + for (const macro of macros) { + if (appliedMacros.has(macro)) continue; + appliedMacros.add(macro); + value = macro.fn(value); + } + + // Create the runtime context + const { actions, apply, cleanup } = this; + const that = this; // I hate javascript + let ctx: RuntimeContext; + ctx = { + get signals() { + return that._signals; + }, + effect: (cb: () => void): OnRemovalFn => effect(cb), + apply: apply.bind(this), + cleanup: cleanup.bind(this), + actions, + genRX: () => this.genRX(ctx, ...p.argNames || []), + el, + rawKey, + rawValue, + key, + value, + mods, + }; + + // Load the plugin and store any cleanup functions + const removal = p.onLoad(ctx); + if (removal) { + if (!this.removals.has(el)) { + this.removals.set(el, { + id: el.id, + set: new Set(), + }); + } + this.removals.get(el)!.set.add(removal); + } + + // Remove the attribute if required + if (!!p?.removeOnLoad) delete el.dataset[rawKey]; + } + }); + }); + } + + private genRX( + ctx: RuntimeContext, + ...argNames: string[] + ): RuntimeExpressionFunction { + const stmts = ctx.value.split(/;|\n/).map((s) => s.trim()).filter((s) => + s != "" + ); + const lastIdx = stmts.length - 1; + const last = stmts[lastIdx]; + if (!last.startsWith("return")) { + stmts[lastIdx] = `return (${stmts[lastIdx]});`; + } + const userExpression = stmts.join("\n"); + + const fnCall = /(\w*)\(/gm; + const matches = userExpression.matchAll(fnCall); + const methodsCalled = new Set(); + for (const match of matches) { + methodsCalled.add(match[1]); + } + // Action names + const an = Object.keys(this.actions).filter((i) => + methodsCalled.has(i) + ); + // Action lines + const al = an.map((a) => `const ${a} = ctx.actions.${a}.fn;`); + const fnContent = `${al.join("\n")}return (()=> {${userExpression}})()`; + + // Add ctx to action calls + let fnWithCtx = fnContent.trim(); + an.forEach((a) => { + fnWithCtx = fnWithCtx.replaceAll(a + "(", a + "(ctx,"); + }); + + try { + const argumentNames = argNames || []; + const fn = new Function("ctx", ...argumentNames, fnWithCtx); + return (...args: any[]) => fn(ctx, ...args); + } catch (error) { + throw dsErr("GeneratingExpressionFailed", { + error, + fnContent, + }); + } + } + + private walkDownDOM( + element: Element | null, + callback: (el: HTMLorSVGElement) => void, + ) { + if ( + !element || + !(element instanceof HTMLElement || element instanceof SVGElement) + ) return null; + callback(element); + element = element.firstElementChild; + while (element) { + this.walkDownDOM(element, callback); + element = element.nextElementSibling; + } + } +} diff --git a/library/src/engine/errors.ts b/library/src/engine/errors.ts new file mode 100644 index 000000000..4073cb11c --- /dev/null +++ b/library/src/engine/errors.ts @@ -0,0 +1,12 @@ +// TODO: set to https://data-star.dev/ +const url = `http://localhost:8080/errors`; + +export const hasValNonExpr = /([\w0-9.]+)\.value/gm; + +export const dsErr = (code: string, args?: any) => { + const e = new Error(); + e.name = `error ${code}`; + const fullURL = `${url}/${code}?${new URLSearchParams(args)}`; + e.message = `for more info see ${fullURL}`; + return e; +}; diff --git a/library/src/engine/index.ts b/library/src/engine/index.ts new file mode 100644 index 000000000..cc3537f9c --- /dev/null +++ b/library/src/engine/index.ts @@ -0,0 +1,14 @@ +import { Computed } from "../plugins/official/core/attributes/computed"; +import { Signals } from "../plugins/official/core/attributes/signals"; +import { Star } from "../plugins/official/core/attributes/star"; +import { SignalValueMacro } from "../plugins/official/core/macros/signals"; +import { Engine } from "./engine"; + +const ds = new Engine(); +ds.load( + Star, + SignalValueMacro, + Signals, + Computed, +); +export const Datastar = ds; diff --git a/library/src/engine/nestedSignals.ts b/library/src/engine/nestedSignals.ts new file mode 100644 index 000000000..d7fbb7c55 --- /dev/null +++ b/library/src/engine/nestedSignals.ts @@ -0,0 +1,231 @@ +import { Computed, computed, Signal } from "../vendored/preact-core"; +import { dsErr } from "./errors"; +import { NestedSignal, NestedValues } from "./types"; + +// If onlyPublic is true, only signals not starting with an underscore are included +function nestedValues( + signal: NestedSignal, + onlyPublic = false, +): Record { + const kv: Record = {}; + for (const key in signal) { + if (signal.hasOwnProperty(key)) { + const value = signal[key]; + if (value instanceof Signal) { + if (onlyPublic && key.startsWith("_")) { + continue; + } + kv[key] = value.value; + } else { + kv[key] = nestedValues(value); + } + } + } + return kv; +} + +function mergeNested( + target: NestedValues, + values: NestedValues, + onlyIfMissing = false, +): void { + for (const key in values) { + if (values.hasOwnProperty(key)) { + const value = values[key]; + if (value instanceof Object && !Array.isArray(value)) { + if (!target[key]) { + target[key] = {}; + } + mergeNested( + target[key] as NestedValues, + value as NestedValues, + onlyIfMissing, + ); + } else { + if (onlyIfMissing && target[key]) { + continue; + } + target[key] = new Signal(value); + } + } + } +} + +function walkNestedSignal( + signal: NestedSignal, + cb: (dotDeliminatedB: string, signal: Signal) => void, +): void { + for (const key in signal) { + if (signal.hasOwnProperty(key)) { + const value = signal[key]; + if (value instanceof Signal) { + cb(key, value); + } else { + walkNestedSignal(value as NestedSignal, cb); + } + } + } +} + +// Recursive function to subset a nested object, each key is a dot-delimited path +function nestedSubset(original: NestedValues, ...keys: string[]): NestedValues { + const subset: NestedValues = {}; + for (const key of keys) { + const parts = key.split("."); + let subOriginal = original; + let subSubset = subset; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!subOriginal[part]) { + return {}; + } + if (!subSubset[part]) { + subSubset[part] = {}; + } + subOriginal = subOriginal[part] as NestedValues; + subSubset = subSubset[part] as NestedValues; + } + const last = parts[parts.length - 1]; + subSubset[last] = subOriginal[last]; + } + return subset; +} + +// Recursively walk a NestedValue with a callback and dot-delimited path +export function walkNestedValues( + nv: NestedValues, + cb: (path: string, value: any) => void, +) { + for (const key in nv) { + if (nv.hasOwnProperty(key)) { + const value = nv[key]; + if (value instanceof Object && !Array.isArray(value)) { + walkNestedValues(value, (path, value) => { + cb(`${key}.${path}`, value); + }); + } else { + cb(key, value); + } + } + } +} + +export class SignalsRoot { + private _signals: NestedSignal = {}; + + constructor() {} + + exists(dotDelimitedPath: string): boolean { + return !!this.signal(dotDelimitedPath); + } + + signal(dotDelimitedPath: string): Signal | null { + const parts = dotDelimitedPath.split("."); + let subSignals = this._signals; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!subSignals[part]) { + return null; + } + subSignals = subSignals[part] as NestedSignal; + } + const last = parts[parts.length - 1]; + const signal = subSignals[last]; + if (!signal) throw dsErr("SignalNotFound", { path: dotDelimitedPath }); + return signal as Signal; + } + + setSignal>(dotDelimitedPath: string, signal: T) { + const parts = dotDelimitedPath.split("."); + let subSignals = this._signals; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!subSignals[part]) { + subSignals[part] = {}; + } + subSignals = subSignals[part] as NestedSignal; + } + const last = parts[parts.length - 1]; + subSignals[last] = signal; + } + + setComputed(dotDelimitedPath: string, fn: () => T) { + const signal = computed(() => fn()) as Computed; + this.setSignal(dotDelimitedPath, signal); + } + + value(dotDelimitedPath: string): T { + const signal = this.signal(dotDelimitedPath); + return signal?.value; + } + + setValue(dotDelimitedPath: string, value: T) { + const s = this.upsert(dotDelimitedPath, value); + s.value = value; + } + + upsert(dotDelimitedPath: string, value: T) { + const parts = dotDelimitedPath.split("."); + let subSignals = this._signals; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!subSignals[part]) { + subSignals[part] = {}; + } + subSignals = subSignals[part] as NestedSignal; + } + const last = parts[parts.length - 1]; + + const current = subSignals[last]; + if (!!current) return current as Signal; + + const signal = new Signal(value); + subSignals[last] = signal; + + return signal; + } + + remove(...dotDelimitedPaths: string[]) { + for (const path of dotDelimitedPaths) { + const parts = path.split("."); + let subSignals = this._signals; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!subSignals[part]) { + return; + } + subSignals = subSignals[part] as NestedSignal; + } + const last = parts[parts.length - 1]; + delete subSignals[last]; + } + } + + merge(other: NestedValues, onlyIfMissing = false) { + mergeNested(this._signals, other, onlyIfMissing); + } + + subset(...keys: string[]): NestedValues { + return nestedSubset(this.values(), ...keys); + } + + walk(cb: (name: string, signal: Signal) => void) { + walkNestedSignal(this._signals, cb); + } + + values(onlyPublic = false): NestedValues { + return nestedValues(this._signals, onlyPublic); + } + + JSON(shouldIndent = true, onlyPublic = false) { + const values = this.values(onlyPublic); + if (!shouldIndent) { + return JSON.stringify(values); + } + return JSON.stringify(values, null, 2); + } + + public toString() { + return this.JSON(); + } +} diff --git a/library/src/engine/types.ts b/library/src/engine/types.ts new file mode 100644 index 000000000..773885290 --- /dev/null +++ b/library/src/engine/types.ts @@ -0,0 +1,94 @@ +import { EffectFn, Signal } from "../vendored/preact-core"; +import { SignalsRoot } from "./nestedSignals"; + +export type OnRemovalFn = () => void; + +export enum PluginType { + Macro, + Attribute, + Watcher, + Action, +} + +export interface DatastarPlugin { + type: PluginType; // The type of plugin + name: string; // The name of the plugin +} + +export interface MacroPlugin extends DatastarPlugin { + type: PluginType.Macro; + fn: (original: string) => string; +} + +export type AllowedModifiers = Set; + +export enum Requirement { + Allowed = 0, + Must = 1, + Denied = 2, + Exclusive = 3, +} + +// A plugin accesible via a `data-${name}` attribute on an element +export interface AttributePlugin extends DatastarPlugin { + type: PluginType.Attribute; + onGlobalInit?: (ctx: InitContext) => void; // Called once on registration of the plugin + onLoad: (ctx: RuntimeContext) => OnRemovalFn | void; // Return a function to be called on removal + mods?: AllowedModifiers; // If not provided, all modifiers are allowed + keyReq?: Requirement; // The rules for the key requirements + valReq?: Requirement; // The rules for the value requirements + removeOnLoad?: boolean; // If true, the attribute is removed after onLoad (useful for plugins you don’t want reapplied) + macros?: { + pre?: MacroPlugin[]; + post?: MacroPlugin[]; + }; + argNames?: string[]; // argument names for the reactive expression +} + +// A plugin that runs on the global scope of the DastaStar instance +export interface WatcherPlugin extends DatastarPlugin { + type: PluginType.Watcher; + onGlobalInit?: (ctx: InitContext) => void; +} + +export type ActionPlugins = Record; +export type ActionMethod = (ctx: RuntimeContext, ...args: any[]) => any; + +export interface ActionPlugin extends DatastarPlugin { + type: PluginType.Action; + fn: ActionMethod; +} + +export type GlobalInitializer = (ctx: InitContext) => void; +export type RemovalEntry = { id: string; set: Set }; + +export type InitContext = { + signals: SignalsRoot; + effect: (fn: EffectFn) => OnRemovalFn; + actions: Readonly; + apply: (target: Element) => void; + cleanup: (el: Element) => void; +}; + +export type HTMLorSVGElement = Element & (HTMLElement | SVGElement); +export type Modifiers = Map>; + +export type RuntimeContext = InitContext & { + el: HTMLorSVGElement; // The element the attribute is on + rawKey: Readonly; // no parsing data-* key + rawValue: Readonly; // no parsing data-* value + value: Readonly; // what the user wrote after any macros run + key: Readonly; // data-* key without the prefix or modifiers + mods: Modifiers; // the modifiers and their arguments + genRX: () => (...args: any[]) => T; // a reactive expression +}; + +export type NestedValues = { [key: string]: NestedValues | any }; +export type NestedSignal = { + [key: string]: NestedSignal | Signal; +}; + +export type RuntimeExpressionFunction = ( + ctx: RuntimeContext, + ...args: any[] +) => any; diff --git a/library/src/engine/version.ts b/library/src/engine/version.ts new file mode 100644 index 000000000..888685af4 --- /dev/null +++ b/library/src/engine/version.ts @@ -0,0 +1 @@ +export const VERSION = '0.21.0-beta1'; diff --git a/code/ts/library/src/plugins/official/macros/index.ts b/library/src/index.ts similarity index 77% rename from code/ts/library/src/plugins/official/macros/index.ts rename to library/src/index.ts index 3b585cb9d..0fa964523 100644 --- a/code/ts/library/src/plugins/official/macros/index.ts +++ b/library/src/index.ts @@ -1,3 +1,2 @@ // We don't use these exports, they are purely for access via package managers like NPM - -export * from "./core"; +export * from "./engine"; diff --git a/library/src/plugins/official/backend/actions/sse.ts b/library/src/plugins/official/backend/actions/sse.ts new file mode 100644 index 000000000..462200c05 --- /dev/null +++ b/library/src/plugins/official/backend/actions/sse.ts @@ -0,0 +1,128 @@ +// Icon: ic:baseline-get-app +// Slug: Use a GET request to fetch data from a server using Server-Sent Events matching the Datastar SDK interface +// Description: Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. + +import { DATASTAR, DATASTAR_REQUEST } from "../../../../engine/consts"; +import { dsErr } from "../../../../engine/errors"; +import { ActionPlugin, PluginType } from "../../../../engine/types"; +import { + fetchEventSource, + FetchEventSourceInit, +} from "../../../../vendored/fetch-event-source"; +import { + DATASTAR_SSE_EVENT, + DatastarSSEEvent, + FINISHED, + STARTED, +} from "../shared"; + +type METHOD = "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; + +function dispatchSSE(type: string, argsRaw: Record) { + document.dispatchEvent( + new CustomEvent(DATASTAR_SSE_EVENT, { + detail: { type, argsRaw }, + }), + ); +} + +const isWrongContent = (err: any) => `${err}`.includes(`text/event-stream`); + +export type SSEArgs = { + method: METHOD; + headers?: Record; + onlyRemote?: boolean; +}; + +export const ServerSentEvents: ActionPlugin = { + type: PluginType.Action, + name: "sse", + fn: async ( + ctx, + url: string, + args: SSEArgs = { method: "GET", headers: {}, onlyRemote: true }, + ) => { + const { el: { id: elId }, signals } = ctx; + const { headers: userHeaders, onlyRemote } = args; + const method = args.method.toUpperCase(); + try { + dispatchSSE(STARTED, { elId }); + if (!!!url?.length) { + throw dsErr("NoUrlProvided"); + } + + const headers = Object.assign({ + "Content-Type": "application/json", + [DATASTAR_REQUEST]: true, + }, userHeaders); + + const req: FetchEventSourceInit = { + method, + headers, + onmessage: (evt) => { + if (!evt.event.startsWith(DATASTAR)) { + return; + } + const type = evt.event; + const argsRawLines: Record = {}; + + const lines = evt.data.split("\n"); + for (const line of lines) { + const colonIndex = line.indexOf(" "); + const key = line.slice(0, colonIndex); + let argLines = argsRawLines[key]; + if (!argLines) { + argLines = []; + argsRawLines[key] = argLines; + } + const value = line.slice(colonIndex + 1).trim(); + argLines.push(value); + } + + const argsRaw: Record = {}; + for (const [key, lines] of Object.entries(argsRawLines)) { + argsRaw[key] = lines.join("\n"); + } + + // if you aren't seeing your event you can debug by using this line in the console + // document.addEventListener("datastar-sse",(e) => console.log(e)); + dispatchSSE(type, argsRaw); + }, + onerror: (error) => { + if (isWrongContent(error)) { + // don't retry if the content-type is wrong + throw dsErr("InvalidContentType", { url, error }); + } + // do nothing and it will retry + if (error) { + console.error(error.message); + } + }, + }; + + const urlInstance = new URL(url, window.location.origin); + const json = signals.JSON(false, onlyRemote); + if (method === "GET") { + const queryParams = new URLSearchParams(urlInstance.search); + queryParams.set(DATASTAR, json); + urlInstance.search = queryParams.toString(); + } else { + req.body = json; + } + + try { + await fetchEventSource(urlInstance.toString(), req); + } catch (error) { + if (!isWrongContent(error)) { + throw dsErr("SseFetchFailed", { method, url, error }); + } + // exit gracefully and do nothing if the content-type is wrong + // this can happen if the client is sending a request + // where no response is expected, and they haven't + // set the content-type to text/event-stream + } + } finally { + dispatchSSE(FINISHED, { elId }); + } + }, +}; diff --git a/code/ts/library/src/plugins/official/attributes/backend/indicator.ts b/library/src/plugins/official/backend/attributes/indicator.ts similarity index 65% rename from code/ts/library/src/plugins/official/attributes/backend/indicator.ts rename to library/src/plugins/official/backend/attributes/indicator.ts index c4f1a79ba..d37cdd2ea 100644 --- a/code/ts/library/src/plugins/official/attributes/backend/indicator.ts +++ b/library/src/plugins/official/backend/attributes/indicator.ts @@ -1,33 +1,37 @@ -// Authors: Delaney Gillilan // Icon: material-symbols:network-wifi // Slug: Sets the indicator signal used when fetching data via SSE // Description: must be a valid signal name -import { AttributePlugin } from "../../../../engine"; import { DATASTAR } from "../../../../engine/consts"; -import { PluginType } from "../../../../engine/enums"; +import { + AttributePlugin, + PluginType, + Requirement, +} from "../../../../engine/types"; import { DATASTAR_SSE_EVENT, DatastarSSEEvent, FINISHED, STARTED, -} from "../../watchers/backend/sseShared"; +} from "../shared"; export const INDICATOR_CLASS = `${DATASTAR}-indicator`; export const INDICATOR_LOADING_CLASS = `${INDICATOR_CLASS}-loading`; export const Indicator: AttributePlugin = { - pluginType: PluginType.Attribute, + type: PluginType.Attribute, name: "indicator", - mustHaveEmptyKey: true, - onLoad: (ctx) => { - const { expression, upsertSignal, el } = ctx; - const signalName = expression; - const signal = upsertSignal(signalName, false); - + keyReq: Requirement.Exclusive, + valReq: Requirement.Exclusive, + onLoad: ({ value, signals, el, key }) => { + const signalName = !!key ? key : value; + const signal = signals.upsert(signalName, false); const watcher = (event: CustomEvent) => { - const { type, argsRaw: { elID } } = event.detail; - if (elID !== el.id) return; + const { + type, + argsRaw: { elId }, + } = event.detail; + if (elId !== el.id) return; switch (type) { case STARTED: signal.value = true; @@ -37,7 +41,6 @@ export const Indicator: AttributePlugin = { break; } }; - document.addEventListener(DATASTAR_SSE_EVENT, watcher); return () => { diff --git a/code/ts/library/src/plugins/official/watchers/backend/sseShared.ts b/library/src/plugins/official/backend/shared.ts similarity index 96% rename from code/ts/library/src/plugins/official/watchers/backend/sseShared.ts rename to library/src/plugins/official/backend/shared.ts index 389bc01ae..6c4814a50 100644 --- a/code/ts/library/src/plugins/official/watchers/backend/sseShared.ts +++ b/library/src/plugins/official/backend/shared.ts @@ -1,4 +1,4 @@ -import { DATASTAR } from "../../../../engine/consts"; +import { DATASTAR } from "../../../engine/consts"; export const DATASTAR_SSE_EVENT = `${DATASTAR}-sse`; export const SETTLING_CLASS = `${DATASTAR}-settling`; diff --git a/library/src/plugins/official/backend/watchers/executeScript.ts b/library/src/plugins/official/backend/watchers/executeScript.ts new file mode 100644 index 000000000..f0e3eef3b --- /dev/null +++ b/library/src/plugins/official/backend/watchers/executeScript.ts @@ -0,0 +1,45 @@ +// Icon: tabler:file-type-js +// Slug: Execute JavaScript using a Server-Sent Event +// Description: Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. + +import { + DefaultExecuteScriptAttributes, + DefaultExecuteScriptAutoRemove, + EventTypes, +} from "../../../../engine/consts"; +import { dsErr } from "../../../../engine/errors"; +import { PluginType, WatcherPlugin } from "../../../../engine/types"; +import { isBoolString } from "../../../../utils/text"; +import { datastarSSEEventWatcher } from "../shared"; + +export const ExecuteScript: WatcherPlugin = { + type: PluginType.Watcher, + name: EventTypes.ExecuteScript, + onGlobalInit: async () => { + datastarSSEEventWatcher( + EventTypes.ExecuteScript, + ({ + autoRemove: autoRemoveRaw = `${DefaultExecuteScriptAutoRemove}`, + attributes: attributesRaw = DefaultExecuteScriptAttributes, + script, + }) => { + const autoRemove = isBoolString(autoRemoveRaw); + if (!script?.length) { + throw dsErr("NoScriptProvided"); + } + const scriptEl = document.createElement("script"); + attributesRaw.split("\n").forEach((attr) => { + const pivot = attr.indexOf(" "); + const key = pivot ? attr.slice(0, pivot) : attr; + const value = pivot ? attr.slice(pivot) : ""; + scriptEl.setAttribute(key.trim(), value.trim()); + }); + scriptEl.text = script; + document.head.appendChild(scriptEl); + if (autoRemove) { + scriptEl.remove(); + } + }, + ); + }, +}; diff --git a/library/src/plugins/official/backend/watchers/mergeFragments.ts b/library/src/plugins/official/backend/watchers/mergeFragments.ts new file mode 100644 index 000000000..a7d7cea18 --- /dev/null +++ b/library/src/plugins/official/backend/watchers/mergeFragments.ts @@ -0,0 +1,172 @@ +// Icon: material-symbols:cloud-download +// Slug: Merge fragments into the DOM using a Server-Sent Event +// Description: Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. + +import { + DefaultFragmentMergeMode, + DefaultFragmentsUseViewTransitions, + DefaultSettleDurationMs, + EventTypes, + FragmentMergeModes, +} from "../../../../engine/consts"; +import { dsErr } from "../../../../engine/errors"; +import { + InitContext, + PluginType, + WatcherPlugin, +} from "../../../../engine/types"; +import { isBoolString } from "../../../../utils/text"; +import { + docWithViewTransitionAPI, + supportsViewTransitions, +} from "../../../../utils/view-transtions"; +import { idiomorph } from "../../../../vendored/idiomorph"; +import { + datastarSSEEventWatcher, + SETTLING_CLASS, + SWAPPING_CLASS, +} from "../shared"; + +export const MergeFragments: WatcherPlugin = { + type: PluginType.Watcher, + name: EventTypes.MergeFragments, + onGlobalInit: async (ctx) => { + const fragmentContainer = document.createElement("template"); + datastarSSEEventWatcher( + EventTypes.MergeFragments, + ({ + fragments: fragmentsRaw = "
", + selector = "", + mergeMode = DefaultFragmentMergeMode, + settleDuration: settleDurationRaw = + `${DefaultSettleDurationMs}`, + useViewTransition: useViewTransitionRaw = + `${DefaultFragmentsUseViewTransitions}`, + }) => { + const settleDuration = parseInt(settleDurationRaw); + const useViewTransition = isBoolString(useViewTransitionRaw); + + fragmentContainer.innerHTML = fragmentsRaw.trim(); + const fragments = [...fragmentContainer.content.children]; + fragments.forEach((fragment) => { + if (!(fragment instanceof Element)) { + throw dsErr("NoFragmentsFound"); + } + + const selectorOrID = selector || + `#${fragment.getAttribute("id")}`; + const targets = [ + ...document.querySelectorAll(selectorOrID) || + [], + ]; + if (!targets.length) { + throw dsErr("NoTargetsFound", { selectorOrID }); + } + + if (supportsViewTransitions && useViewTransition) { + docWithViewTransitionAPI.startViewTransition(() => + applyToTargets( + ctx, + mergeMode, + settleDuration, + fragment, + targets, + ) + ); + } else { + applyToTargets( + ctx, + mergeMode, + settleDuration, + fragment, + targets, + ); + } + }); + }, + ); + }, +}; + +function applyToTargets( + ctx: InitContext, + mergeMode: string, + settleDuration: number, + fragment: Element, + capturedTargets: Element[], +) { + for (const initialTarget of capturedTargets) { + initialTarget.classList.add(SWAPPING_CLASS); + const originalHTML = initialTarget.outerHTML; + let modifiedTarget = initialTarget; + switch (mergeMode) { + case FragmentMergeModes.Morph: + const result = idiomorph(modifiedTarget, fragment, { + callbacks: { + beforeNodeRemoved: (oldNode: Element, _: Element) => { + ctx.cleanup(oldNode); + return true; + }, + }, + }); + if (!result?.length) { + throw dsErr("MorphFailed"); + } + modifiedTarget = result[0] as Element; + break; + case FragmentMergeModes.Inner: + // Replace the contents of the target element with the response + modifiedTarget.innerHTML = fragment.innerHTML; + break; + case FragmentMergeModes.Outer: + // Replace the entire target element with the response + modifiedTarget.replaceWith(fragment); + break; + case FragmentMergeModes.Prepend: + // Insert the response before the first child of the target element + modifiedTarget.prepend(fragment); + break; + case FragmentMergeModes.Append: + // Insert the response after the last child of the target element + modifiedTarget.append(fragment); + break; + case FragmentMergeModes.Before: + // Insert the response before the target element + modifiedTarget.before(fragment); + break; + case FragmentMergeModes.After: + // Insert the response after the target element + modifiedTarget.after(fragment); + break; + case FragmentMergeModes.UpsertAttributes: + // Upsert the attributes of the target element + fragment.getAttributeNames().forEach((attrName) => { + const value = fragment.getAttribute(attrName)!; + modifiedTarget.setAttribute(attrName, value); + }); + break; + default: + throw dsErr("InvalidMergeMode", { mergeMode }); + } + ctx.cleanup(modifiedTarget); + + const cl = modifiedTarget.classList; + cl.add(SWAPPING_CLASS); + + ctx.apply(document.body); + + setTimeout(() => { + initialTarget.classList.remove(SWAPPING_CLASS); + cl.remove(SWAPPING_CLASS); + }, settleDuration); + + const revisedHTML = modifiedTarget.outerHTML; + + if (originalHTML !== revisedHTML) { + cl.add(SETTLING_CLASS); + setTimeout(() => { + cl.remove(SETTLING_CLASS); + }, settleDuration); + } + } +} diff --git a/library/src/plugins/official/backend/watchers/mergeSignals.ts b/library/src/plugins/official/backend/watchers/mergeSignals.ts new file mode 100644 index 000000000..7e128be14 --- /dev/null +++ b/library/src/plugins/official/backend/watchers/mergeSignals.ts @@ -0,0 +1,31 @@ +// Icon: material-symbols:settings-input-antenna +// Slug: Merge signals using a Server-Sent Event +// Description: Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. + +import { + DefaultMergeSignalsOnlyIfMissing, + EventTypes, +} from "../../../../engine/consts"; +import { PluginType, WatcherPlugin } from "../../../../engine/types"; +import { isBoolString, jsStrToObject } from "../../../../utils/text"; +import { datastarSSEEventWatcher } from "../shared"; + +export const MergeSignals: WatcherPlugin = { + type: PluginType.Watcher, + name: EventTypes.MergeSignals, + onGlobalInit: async (ctx) => { + datastarSSEEventWatcher( + EventTypes.MergeSignals, + ({ + signals: raw = "{}", + onlyIfMissing: onlyIfMissingRaw = + `${DefaultMergeSignalsOnlyIfMissing}`, + }) => { + const { signals } = ctx; + const onlyIfMissing = isBoolString(onlyIfMissingRaw); + signals.merge(jsStrToObject(raw), onlyIfMissing); + ctx.apply(document.body); + }, + ); + }, +}; diff --git a/library/src/plugins/official/backend/watchers/removeFragments.ts b/library/src/plugins/official/backend/watchers/removeFragments.ts new file mode 100644 index 000000000..995adfd68 --- /dev/null +++ b/library/src/plugins/official/backend/watchers/removeFragments.ts @@ -0,0 +1,60 @@ +// Icon: material-symbols:settings-input-antenna +// Slug: Remove fragments from the DOM using a Server-Sent Event +// Description: Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. + +import { + DefaultFragmentsUseViewTransitions, + DefaultSettleDurationMs, + EventTypes, +} from "../../../../engine/consts"; +import { dsErr } from "../../../../engine/errors"; +import { PluginType, WatcherPlugin } from "../../../../engine/types"; +import { isBoolString } from "../../../../utils/text"; +import { + docWithViewTransitionAPI, + supportsViewTransitions, +} from "../../../../utils/view-transtions"; +import { datastarSSEEventWatcher, SWAPPING_CLASS } from "../shared"; + +export const RemoveFragments: WatcherPlugin = { + type: PluginType.Watcher, + name: EventTypes.RemoveFragments, + onGlobalInit: async () => { + datastarSSEEventWatcher( + EventTypes.RemoveFragments, + ({ + selector, + settleDuration: settleDurationRaw = `${DefaultSettleDurationMs}`, + useViewTransition: useViewTransitionRaw = `${DefaultFragmentsUseViewTransitions}`, + }) => { + if (!!!selector.length) { + throw dsErr("NoSelectorProvided"); + } + + const settleDuration = parseInt(settleDurationRaw); + const useViewTransition = isBoolString(useViewTransitionRaw); + const removeTargets = document.querySelectorAll(selector); + + const applyToTargets = () => { + for (const target of removeTargets) { + target.classList.add(SWAPPING_CLASS); + } + + setTimeout(() => { + for (const target of removeTargets) { + target.remove(); + } + }, settleDuration); + }; + + if (supportsViewTransitions && useViewTransition) { + docWithViewTransitionAPI.startViewTransition(() => + applyToTargets() + ); + } else { + applyToTargets(); + } + }, + ); + }, +}; diff --git a/library/src/plugins/official/backend/watchers/removeSignals.ts b/library/src/plugins/official/backend/watchers/removeSignals.ts new file mode 100644 index 000000000..2f0d62a26 --- /dev/null +++ b/library/src/plugins/official/backend/watchers/removeSignals.ts @@ -0,0 +1,26 @@ +// Icon: material-symbols:settings-input-antenna +// Slug: Remove signals using a Server-Sent Event +// Description: Remember, SSE is just a regular SSE request but with the ability to send 0-inf messages to the client. + +import { EventTypes } from "../../../../engine/consts"; +import { dsErr } from "../../../../engine/errors"; +import { PluginType, WatcherPlugin } from "../../../../engine/types"; +import { datastarSSEEventWatcher } from "../shared"; + +export const RemoveSignals: WatcherPlugin = { + type: PluginType.Watcher, + name: EventTypes.RemoveSignals, + onGlobalInit: async (ctx) => { + datastarSSEEventWatcher( + EventTypes.RemoveSignals, + ({ paths: pathsRaw = "" }) => { + const paths = pathsRaw.split("\n").map((p) => p.trim()); + if (!!!paths?.length) { + throw dsErr("NoPathsProvided"); + } + ctx.signals.remove(...paths); + ctx.apply(document.body); + }, + ); + }, +}; diff --git a/code/ts/library/src/plugins/official/actions/dom/clipboard.ts b/library/src/plugins/official/browser/actions/clipboard.ts similarity index 52% rename from code/ts/library/src/plugins/official/actions/dom/clipboard.ts rename to library/src/plugins/official/browser/actions/clipboard.ts index 630107b4e..0b40d20ca 100644 --- a/code/ts/library/src/plugins/official/actions/dom/clipboard.ts +++ b/library/src/plugins/official/browser/actions/clipboard.ts @@ -3,17 +3,15 @@ // Slug: Copy text to the clipboard // Description: This action copies text to the clipboard using the Clipboard API. -import { ActionPlugin } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; -import { ERR_NOT_ALLOWED } from "../../../../engine/errors"; +import { dsErr } from "../../../../engine/errors"; +import { ActionPlugin, PluginType } from "../../../../engine/types"; export const Clipboard: ActionPlugin = { - pluginType: PluginType.Action, + type: PluginType.Action, name: "clipboard", - method: (_, text) => { + fn: (_, text) => { if (!navigator.clipboard) { - // Clipboard API not available - throw ERR_NOT_ALLOWED; + throw dsErr("ClipboardNotAvailable"); } navigator.clipboard.writeText(text); }, diff --git a/code/ts/library/src/plugins/official/attributes/visibility/intersects.ts b/library/src/plugins/official/browser/attributes/intersects.ts similarity index 54% rename from code/ts/library/src/plugins/official/attributes/visibility/intersects.ts rename to library/src/plugins/official/browser/attributes/intersects.ts index 9b8e4a00f..4f3ceff11 100644 --- a/code/ts/library/src/plugins/official/attributes/visibility/intersects.ts +++ b/library/src/plugins/official/browser/attributes/intersects.ts @@ -3,38 +3,41 @@ // Slug: Run expression when element intersects with viewport // Description: An attribute that runs an expression when the element intersects with the viewport. -import { AttributePlugin } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; +import { + AttributePlugin, + PluginType, + Requirement, +} from "../../../../engine/types"; const ONCE = "once"; const HALF = "half"; const FULL = "full"; // Run expression when element intersects with viewport -export const Intersection: AttributePlugin = { - pluginType: PluginType.Attribute, +export const Intersects: AttributePlugin = { + type: PluginType.Attribute, name: "intersects", - allowedModifiers: new Set([ONCE, HALF, FULL]), - mustHaveEmptyKey: true, - onLoad: (ctx) => { - const { modifiers } = ctx; + keyReq: Requirement.Denied, + mods: new Set([ONCE, HALF, FULL]), + onLoad: ({ el, rawKey, mods, genRX }) => { const options = { threshold: 0 }; - if (modifiers.has(FULL)) options.threshold = 1; - else if (modifiers.has(HALF)) options.threshold = 0.5; + if (mods.has(FULL)) options.threshold = 1; + else if (mods.has(HALF)) options.threshold = 0.5; + const rx = genRX(); const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { - ctx.expressionFn(ctx); - if (modifiers.has(ONCE)) { + rx(); + if (mods.has(ONCE)) { observer.disconnect(); - delete ctx.el.dataset[ctx.rawKey]; + delete el.dataset[rawKey]; } } }); }, options); - observer.observe(ctx.el); + observer.observe(el); return () => observer.disconnect(); }, }; diff --git a/library/src/plugins/official/browser/attributes/persist.ts b/library/src/plugins/official/browser/attributes/persist.ts new file mode 100644 index 000000000..2a05b5027 --- /dev/null +++ b/library/src/plugins/official/browser/attributes/persist.ts @@ -0,0 +1,47 @@ +// Authors: Delaney Gillilan +// Icon: mdi:floppy-variant +// Slug: Persist data to local storage or session storage +// Description: This plugin allows you to persist data to local storage or session storage. Once you add this attribute the data will be persisted to local storage or session storage. + +import { DATASTAR } from "../../../../engine/consts"; +import { + AttributePlugin, + NestedValues, + PluginType, +} from "../../../../engine/types"; + +const SESSION = "session"; + +export const Persist: AttributePlugin = { + type: PluginType.Attribute, + name: "persist", + mods: new Set([SESSION]), + onLoad: ({ key, value, signals, effect, mods }) => { + if (key === "") { + key = DATASTAR; + } + const storage = mods.has(SESSION) ? sessionStorage : localStorage; + const paths = value.split(/\s+/).filter((p) => p !== ""); + + const storageToSignals = () => { + const data = storage.getItem(key) || "{}"; + const nestedValues = JSON.parse(data); + signals.merge(nestedValues); + }; + + const signalsToStorage = () => { + let nv: NestedValues; + if (!!!paths.length) { + nv = signals.values(); + } else { + nv = signals.subset(...paths); + } + storage.setItem(key, JSON.stringify(nv)); + }; + + storageToSignals(); + return effect(() => { + signalsToStorage(); + }); + }, +}; diff --git a/library/src/plugins/official/browser/attributes/replaceUrl.ts b/library/src/plugins/official/browser/attributes/replaceUrl.ts new file mode 100644 index 000000000..be8b7214f --- /dev/null +++ b/library/src/plugins/official/browser/attributes/replaceUrl.ts @@ -0,0 +1,26 @@ +// Authors: Delaney Gillilan +// Icon: carbon:url +// Slug: Replace the current URL with a new URL +// Description: This plugin allows you to replace the current URL with a new URL. Once you add this attribute the current URL will be replaced with the new URL. + +import { + AttributePlugin, + PluginType, + Requirement, +} from "../../../../engine/types"; + +export const ReplaceUrl: AttributePlugin = { + type: PluginType.Attribute, + name: "replaceUrl", + keyReq: Requirement.Denied, + valReq: Requirement.Must, + onLoad: ({ effect, genRX }) => { + const rx = genRX(); + return effect(() => { + const url = rx(); + const baseUrl = window.location.href; + const fullUrl = new URL(url, baseUrl).toString(); + window.history.replaceState({}, "", fullUrl); + }); + }, +}; diff --git a/library/src/plugins/official/browser/attributes/scrollIntoView.ts b/library/src/plugins/official/browser/attributes/scrollIntoView.ts new file mode 100644 index 000000000..a075b756c --- /dev/null +++ b/library/src/plugins/official/browser/attributes/scrollIntoView.ts @@ -0,0 +1,86 @@ +// Authors: Delaney Gillilan +// Icon: hugeicons:mouse-scroll-01 +// Slug: Scroll an element into view +// Description: This attribute scrolls the element into view. + +import { dsErr } from "../../../../engine/errors"; +import { + AttributePlugin, + PluginType, + Requirement, +} from "../../../../engine/types"; + +const SMOOTH = "smooth"; +const INSTANT = "instant"; +const AUTO = "auto"; +const HSTART = "hstart"; +const HCENTER = "hcenter"; +const HEND = "hend"; +const HNEAREST = "hnearest"; +const VSTART = "vstart"; +const VCENTER = "vcenter"; +const VEND = "vend"; +const VNEAREST = "vnearest"; +const FOCUS = "focus"; + +const CENTER = "center"; +const START = "start"; +const END = "end"; +const NEAREST = "nearest"; + +// Scrolls the element into view +export const ScrollIntoView: AttributePlugin = { + type: PluginType.Attribute, + name: "scrollIntoView", + keyReq: Requirement.Denied, + valReq: Requirement.Denied, + mods: new Set([ + SMOOTH, + INSTANT, + AUTO, + HSTART, + HCENTER, + HEND, + HNEAREST, + VSTART, + VCENTER, + VEND, + VNEAREST, + FOCUS, + ]), + + onLoad: ({ el, mods, rawKey }) => { + if (!el.tabIndex) el.setAttribute("tabindex", "0"); + const opts: ScrollIntoViewOptions = { + behavior: SMOOTH, + block: CENTER, + inline: CENTER, + }; + if (mods.has(SMOOTH)) opts.behavior = SMOOTH; + if (mods.has(INSTANT)) opts.behavior = INSTANT; + if (mods.has(AUTO)) opts.behavior = AUTO; + if (mods.has(HSTART)) opts.inline = START; + if (mods.has(HCENTER)) opts.inline = CENTER; + if (mods.has(HEND)) opts.inline = END; + if (mods.has(HNEAREST)) opts.inline = NEAREST; + if (mods.has(VSTART)) opts.block = START; + if (mods.has(VCENTER)) opts.block = CENTER; + if (mods.has(VEND)) opts.block = END; + if (mods.has(VNEAREST)) opts.block = NEAREST; + + if (!(el instanceof HTMLElement || el instanceof SVGElement)) { + throw dsErr("NotHtmlSvgElement, el"); + } + if (!el.tabIndex) { + el.setAttribute("tabindex", "0"); + } + + el.scrollIntoView(opts); + if (mods.has("focus")) { + el.focus(); + } + + delete el.dataset[rawKey]; + return () => {}; + }, +}; diff --git a/library/src/plugins/official/browser/attributes/show.ts b/library/src/plugins/official/browser/attributes/show.ts new file mode 100644 index 000000000..a12d4c633 --- /dev/null +++ b/library/src/plugins/official/browser/attributes/show.ts @@ -0,0 +1,35 @@ +// Authors: Delaney Gillilan +// Icon: streamline:interface-edit-view-eye-eyeball-open-view +// Slug: Show or hide an element +// Description: This attribute shows or hides an element based on the value of the expression. If the expression is true, the element is shown. If the expression is false, the element is hidden. The element is hidden by setting the display property to none. + +import { + AttributePlugin, + PluginType, + Requirement, +} from "../../../../engine/types"; + +const NONE = "none"; +const DISPLAY = "display"; + +export const Show: AttributePlugin = { + type: PluginType.Attribute, + name: "show", + keyReq: Requirement.Denied, + valReq: Requirement.Must, + onLoad: ( + { el: { style: s }, genRX, effect }, + ) => { + const rx = genRX(); + return effect(async () => { + const shouldShow = rx(); + if (shouldShow) { + if (s.display === NONE) { + s.removeProperty(DISPLAY); + } + } else { + s.setProperty(DISPLAY, NONE); + } + }); + }, +}; diff --git a/code/ts/library/src/plugins/official/attributes/visibility/viewTransition.ts b/library/src/plugins/official/browser/attributes/viewTransition.ts similarity index 73% rename from code/ts/library/src/plugins/official/attributes/visibility/viewTransition.ts rename to library/src/plugins/official/browser/attributes/viewTransition.ts index c9360473f..0086e734b 100644 --- a/code/ts/library/src/plugins/official/attributes/visibility/viewTransition.ts +++ b/library/src/plugins/official/browser/attributes/viewTransition.ts @@ -3,21 +3,25 @@ // Slug: Setup view transitions // Description: This attribute plugin sets up view transitions for the current view. This plugin requires the view transition API to be enabled in the browser. If the browser does not support view transitions, an error will be logged to the console. -import { AttributePlugin } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; -import { supportsViewTransitions } from "../../../../utils/view-transitions"; +import { + AttributePlugin, + PluginType, + Requirement, +} from "../../../../engine/types"; +import { supportsViewTransitions } from "../../../../utils/view-transtions"; const VIEW_TRANSITION = "view-transition"; export const ViewTransition: AttributePlugin = { - pluginType: PluginType.Attribute, + type: PluginType.Attribute, name: VIEW_TRANSITION, + keyReq: Requirement.Denied, + valReq: Requirement.Must, onGlobalInit() { let hasViewTransitionMeta = false; document.head.childNodes.forEach((node) => { if ( - node instanceof HTMLMetaElement && - node.name === VIEW_TRANSITION + node instanceof HTMLMetaElement && node.name === VIEW_TRANSITION ) { hasViewTransitionMeta = true; } @@ -30,17 +34,15 @@ export const ViewTransition: AttributePlugin = { document.head.appendChild(meta); } }, - onLoad: (ctx) => { + onLoad: ({ effect, el, genRX }) => { if (!supportsViewTransitions) { console.error("Browser does not support view transitions"); return; } - - return ctx.reactivity.effect(() => { - const { el, expressionFn } = ctx; - let name = expressionFn(ctx); - if (!name) return; - + const rx = genRX(); + return effect(() => { + const name = rx(); + if (!name?.length) return; const elVTASTyle = el.style as unknown as CSSStyleDeclaration; elVTASTyle.viewTransitionName = name; }); diff --git a/library/src/plugins/official/core/attributes/computed.ts b/library/src/plugins/official/core/attributes/computed.ts new file mode 100644 index 000000000..377816a4b --- /dev/null +++ b/library/src/plugins/official/core/attributes/computed.ts @@ -0,0 +1,18 @@ +import { + AttributePlugin, + PluginType, + Requirement, +} from "../../../../engine/types"; + +const name = "computed"; +export const Computed: AttributePlugin = { + type: PluginType.Attribute, + name, + keyReq: Requirement.Must, + valReq: Requirement.Must, + removeOnLoad: true, + onLoad: ({ key, signals, genRX }) => { + const rx = genRX(); + signals.setComputed(key, rx); + }, +}; diff --git a/library/src/plugins/official/core/attributes/signals.ts b/library/src/plugins/official/core/attributes/signals.ts new file mode 100644 index 000000000..2983587f6 --- /dev/null +++ b/library/src/plugins/official/core/attributes/signals.ts @@ -0,0 +1,24 @@ +import { + AttributePlugin, + NestedValues, + PluginType, + Requirement, +} from "../../../../engine/types"; +import { jsStrToObject } from "../../../../utils/text"; + +export const Signals: AttributePlugin = { + type: PluginType.Attribute, + name: "signals", + valReq: Requirement.Must, + removeOnLoad: true, + onLoad: (ctx) => { + const { key, genRX, signals } = ctx; + if (key != "") { + signals.setValue(key, genRX()()); + } else { + const obj = jsStrToObject(ctx.value); + ctx.value = JSON.stringify(obj); + signals.merge(genRX()()); + } + }, +}; diff --git a/library/src/plugins/official/core/attributes/star.ts b/library/src/plugins/official/core/attributes/star.ts new file mode 100644 index 000000000..5f40de573 --- /dev/null +++ b/library/src/plugins/official/core/attributes/star.ts @@ -0,0 +1,15 @@ +import { + AttributePlugin, + PluginType, + Requirement, +} from "../../../../engine/types"; + +export const Star: AttributePlugin = { + type: PluginType.Attribute, + name: "star", + keyReq: Requirement.Denied, + valReq: Requirement.Denied, + onLoad: () => { + alert("YOU ARE PROBABLY OVERCOMPLICATING IT"); + }, +}; diff --git a/library/src/plugins/official/core/macros/signals.ts b/library/src/plugins/official/core/macros/signals.ts new file mode 100644 index 000000000..415b4a150 --- /dev/null +++ b/library/src/plugins/official/core/macros/signals.ts @@ -0,0 +1,11 @@ +import { MacroPlugin, PluginType } from "../../../../engine/types"; + +export const SignalValueMacro: MacroPlugin = { + name: "signalValue", + type: PluginType.Macro, + fn: (original: string) => { + const validJS = /(?[\w0-9.]*)((\.value))/gm; + const sub = `ctx.signals.signal('$1').value`; + return original.replaceAll(validJS, sub); + }, +}; diff --git a/library/src/plugins/official/dom/attributes/attributes.ts b/library/src/plugins/official/dom/attributes/attributes.ts new file mode 100644 index 000000000..c01ec7ed4 --- /dev/null +++ b/library/src/plugins/official/dom/attributes/attributes.ts @@ -0,0 +1,48 @@ +// Authors: Delaney Gillilan +// Icon: akar-icons:link-chain +// Slug: Bind attributes to expressions +// Description: Any attribute can be bound to an expression. The attribute will be updated reactively whenever the expression signal changes. + +import { + AttributePlugin, + NestedValues, + PluginType, + Requirement, +} from "../../../../engine/types"; +import { kebabize } from "../../../../utils/text"; + +export const Attributes: AttributePlugin = { + type: PluginType.Attribute, + name: "attributes", + valReq: Requirement.Must, + onLoad: ({ el, genRX, key, effect }) => { + const rx = genRX(); + if (key === "") { + return effect(async () => { + const binds = rx(); + Object.entries(binds).forEach(([attr, val]) => { + el.setAttribute(attr, val); + }); + }); + } else { + key = kebabize(key); + return effect(async () => { + let value = false; + try { + value = rx(); + } catch (e) {} // + let v: string; + if (typeof value === "string") { + v = value; + } else { + v = JSON.stringify(value); + } + if (!v || v === "false" || v === "null" || v === "undefined") { + el.removeAttribute(key); + } else { + el.setAttribute(key, v); + } + }); + } + }, +}; diff --git a/library/src/plugins/official/dom/attributes/bind.ts b/library/src/plugins/official/dom/attributes/bind.ts new file mode 100644 index 000000000..8c3d29a14 --- /dev/null +++ b/library/src/plugins/official/dom/attributes/bind.ts @@ -0,0 +1,202 @@ +// Authors: Delaney Gillilan +// Icon: akar-icons:link-chain +// Slug: Bind attributes to expressions +// Description: Any attribute can be bound to an expression. The attribute will be updated reactively whenever the expression signal changes. + +import { dsErr } from "../../../../engine/errors"; +import { + AttributePlugin, + PluginType, + Requirement, +} from "../../../../engine/types"; + +const dataURIRegex = /^data:(?[^;]+);base64,(?.*)$/; +const updateEvents = ["change", "input", "keydown"]; + +export const Bind: AttributePlugin = { + type: PluginType.Attribute, + name: "bind", + keyReq: Requirement.Exclusive, + valReq: Requirement.Exclusive, + onLoad: (ctx) => { + const { el, value, key, signals, effect } = ctx; + const signalName = !!key ? key : value; + + let setFromSignal = () => {}; + let el2sig = () => {}; + + // I better be tied to a signal + if (typeof signalName !== "string") { + throw dsErr("InvalidExpression"); + } + + const tnl = el.tagName.toLowerCase(); + let signalDefault: string | boolean | number | File = ""; + const isInput = tnl.includes("input"); + const type = el.getAttribute("type"); + const isCheckbox = tnl.includes("checkbox") || + (isInput && type === "checkbox"); + if (isCheckbox) { + signalDefault = false; + } + const isNumber = isInput && type === "number"; + if (isNumber) { + signalDefault = 0; + } + const isSelect = tnl.includes("select"); + const isRadio = tnl.includes("radio") || + (isInput && type === "radio"); + const isFile = isInput && type === "file"; + if (isFile) { + // can't set a default value for a file input, yet + } + if (isRadio) { + const name = el.getAttribute("name"); + if (!name?.length) { + el.setAttribute("name", signalName); + } + } + + signals.upsert(signalName, signalDefault); + + setFromSignal = () => { + const hasValue = "value" in el; + const v = signals.value(signalName); + const vStr = `${v}`; + if (isCheckbox || isRadio) { + const input = el as HTMLInputElement; + if (isCheckbox) { + input.checked = !!v || v === "true"; + } else if (isRadio) { + // evaluate the value as string to handle any type casting + // automatically since the attribute has to be a string anyways + input.checked = vStr === input.value; + } + } else if (isFile) { + // File input reading from a signal is not supported yet + } else if (isSelect) { + const select = el as HTMLSelectElement; + if (select.multiple) { + Array.from(select.options).forEach((opt) => { + if (opt?.disabled) return; + if (Array.isArray(v) || typeof v === "string") { + opt.selected = v.includes(opt.value); + } else if (typeof v === "number") { + opt.selected = v === Number(opt.value); + } else { + opt.selected = v as boolean; + } + }); + } else { + select.value = vStr; + } + } else if (hasValue) { + el.value = vStr; + } else { + el.setAttribute("value", vStr); + } + }; + + el2sig = async () => { + if (isFile) { + const files = [...((el as HTMLInputElement)?.files || [])], + allContents: string[] = [], + allMimes: string[] = [], + allNames: string[] = []; + + await Promise.all( + files.map((f) => { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result !== "string") { + throw dsErr( + "InvalidFileResultType", + { + type: typeof reader.result, + }, + ); + } + const match = reader.result.match(dataURIRegex); + if (!match?.groups) { + throw dsErr("InvalidDataUri", { + result: reader.result, + }); + } + allContents.push(match.groups.contents); + allMimes.push(match.groups.mime); + allNames.push(f.name); + }; + reader.onloadend = () => resolve(void 0); + reader.readAsDataURL(f); + }); + }), + ); + + signals.setValue(signalName, allContents); + const mimeName = `${signalName}Mimes`, + nameName = `${signalName}Names`; + if (mimeName in signals) { + signals.upsert(mimeName, allMimes); + } + if (nameName in signals) { + signals.upsert(nameName, allNames); + } + return; + } + + const current = signals.value(signalName); + const input = (el as HTMLInputElement) || (el as HTMLElement); + + if (typeof current === "number") { + const v = Number( + input.value || input.getAttribute("value"), + ); + signals.setValue(signalName, v); + } else if (typeof current === "string") { + const v = input.value || input.getAttribute("value") || ""; + signals.setValue(signalName, v); + } else if (typeof current === "boolean") { + if (isCheckbox) { + const v = input.checked || + input.getAttribute("checked") === "true"; + signals.setValue(signalName, v); + } else { + const v = Boolean( + input.value || input.getAttribute("value"), + ); + signals.setValue(signalName, v); + } + } else if (typeof current === "undefined") { + } else if (Array.isArray(current)) { + // check if the input is a select element + if (isSelect) { + const select = el as HTMLSelectElement; + const selectedOptions = [...select.selectedOptions]; + const selectedValues = selectedOptions + .filter((opt) => opt.selected) + .map((opt) => opt.value); + signals.setValue(signalName, selectedValues); + } else { + // assume it's a comma-separated string + const v = JSON.stringify(input.value.split(",")); + signals.setValue(signalName, v); + } + } else { + throw dsErr("UnsupportedSignalType", { + current: typeof current, + }); + } + }; + + updateEvents.forEach((event) => el.addEventListener(event, el2sig)); + const elSigClean = effect(() => setFromSignal()); + + return () => { + elSigClean(); + updateEvents.forEach((event) => { + el.removeEventListener(event, el2sig); + }); + }; + }, +}; diff --git a/library/src/plugins/official/dom/attributes/class.ts b/library/src/plugins/official/dom/attributes/class.ts new file mode 100644 index 000000000..510abedf0 --- /dev/null +++ b/library/src/plugins/official/dom/attributes/class.ts @@ -0,0 +1,42 @@ +// Authors: Delaney Gillilan +// Icon: ic:baseline-format-paint +// Slug: Add or remove classes from an element reactively +// Description: This action adds or removes classes from an element reactively based on the expression provided. The expression should be an object where the keys are the class names and the values are booleans. If the value is true, the class is added. If the value is false, the class is removed. + +import { + AttributePlugin, + PluginType, + Requirement, +} from "../../../../engine/types"; +import { kebabize } from "../../../../utils/text"; + +export const Class: AttributePlugin = { + type: PluginType.Attribute, + name: "class", + valReq: Requirement.Must, + onLoad: ({ key, el, genRX, effect }) => { + const cl = el.classList; + const rx = genRX(); + return effect(() => { + if (key === "") { + const classes: Object = rx>(); + for (const [k, v] of Object.entries(classes)) { + const classNames = k.split(/\s+/); + if (v) { + cl.add(...classNames); + } else { + cl.remove(...classNames); + } + } + } else { + const shouldInclude = rx(); + const cls = kebabize(key); + if (shouldInclude) { + cl.add(cls); + } else { + cl.remove(cls); + } + } + }); + }, +}; diff --git a/code/ts/library/src/plugins/official/attributes/dom/on.ts b/library/src/plugins/official/dom/attributes/on.ts similarity index 62% rename from code/ts/library/src/plugins/official/attributes/dom/on.ts rename to library/src/plugins/official/dom/attributes/on.ts index d280f2624..f5a1dbde2 100644 --- a/code/ts/library/src/plugins/official/attributes/dom/on.ts +++ b/library/src/plugins/official/dom/attributes/on.ts @@ -3,11 +3,13 @@ // Slug: Add an event listener to an element // Description: This action adds an event listener to an element. The event listener can be triggered by a variety of events, such as clicks, keypresses, and more. The event listener can also be set to trigger only once, or to be passive or capture. The event listener can also be debounced or throttled. The event listener can also be set to trigger only when the event target is outside the element. -import { AttributePlugin } from "../../../../engine"; -import { PluginType } from "../../../../engine/enums"; -import { ERR_BAD_ARGS } from "../../../../engine/errors"; -import { argsHas, argsToMs } from "../../../../utils/arguments"; -import { remoteSignals } from "../../../../utils/signals"; +import { dsErr } from "../../../../engine/errors"; +import { + AttributePlugin, + PluginType, + Requirement, +} from "../../../../engine/types"; +import { argsHas, argsMs } from "../../../../utils/arguments"; import { kebabize } from "../../../../utils/text"; import { debounce, throttle } from "../../../../utils/timing"; @@ -22,40 +24,34 @@ const knownOnModifiers = new Set([ "outside", ]); -let lastSignalsMarshalled = ""; - -// Sets the event listener of the element export const On: AttributePlugin = { - pluginType: PluginType.Attribute, + type: PluginType.Attribute, name: "on", - mustNotEmptyKey: true, - mustNotEmptyExpression: true, - argumentNames: ["evt"], - onLoad: (ctx) => { - const { el, key, expressionFn } = ctx; - - let target: Element | Window | Document = ctx.el; - if (ctx.modifiers.get("window")) { - target = window; - } + keyReq: Requirement.Must, + valReq: Requirement.Must, + argNames: ["evt"], + onLoad: ({ el, key, genRX, mods, signals, effect }) => { + const rx = genRX(); + let target: Element | Window | Document = el; + if (mods.has("window")) target = window; let callback = (evt?: Event) => { - expressionFn(ctx, evt); + rx(evt); }; - const debounceArgs = ctx.modifiers.get("debounce"); + const debounceArgs = mods.get("debounce"); if (debounceArgs) { - const wait = argsToMs(debounceArgs); + const wait = argsMs(debounceArgs); const leading = argsHas(debounceArgs, "leading", false); - const trailing = argsHas(debounceArgs, "noTrail", true); + const trailing = !argsHas(debounceArgs, "noTrail", false); callback = debounce(callback, wait, leading, trailing); } - const throttleArgs = ctx.modifiers.get("throttle"); + const throttleArgs = mods.get("throttle"); if (throttleArgs) { - const wait = argsToMs(throttleArgs); - const leading = argsHas(throttleArgs, "noLead", true); - const trailing = argsHas(throttleArgs, "noTrail", false); + const wait = argsMs(throttleArgs); + const leading = !argsHas(throttleArgs, "noLeading", false); + const trailing = argsHas(throttleArgs, "trail", false); callback = throttle(callback, wait, leading, trailing); } @@ -64,16 +60,16 @@ export const On: AttributePlugin = { passive: false, once: false, }; - if (!ctx.modifiers.has("capture")) evtListOpts.capture = false; - if (ctx.modifiers.has("passive")) evtListOpts.passive = true; - if (ctx.modifiers.has("once")) evtListOpts.once = true; + if (!mods.has("capture")) evtListOpts.capture = false; + if (mods.has("passive")) evtListOpts.passive = true; + if (mods.has("once")) evtListOpts.once = true; - const unknownModifierKeys = [...ctx.modifiers.keys()].filter((key) => - !knownOnModifiers.has(key) + const unknownModifierKeys = [...mods.keys()].filter( + (key) => !knownOnModifiers.has(key), ); unknownModifierKeys.forEach((attrName) => { - const eventValues = ctx.modifiers.get(attrName) || []; + const eventValues = mods.get(attrName) || []; const cb = callback; const revisedCallback = () => { const evt = event as any; @@ -86,11 +82,10 @@ export const On: AttributePlugin = { valid = attr; } else if (typeof attr === "string") { const lowerAttr = attr.toLowerCase().trim(); - const expr = eventValues.join("").toLowerCase().trim(); + const expr = [...eventValues].join("").toLowerCase().trim(); valid = lowerAttr === expr; } else { - // console.error(`Invalid value for ${attrName} modifier on ${key} on ${el}`); - throw ERR_BAD_ARGS; + throw dsErr("InvalidValue", { attrName, key, el }); } if (valid) { @@ -100,11 +95,12 @@ export const On: AttributePlugin = { callback = revisedCallback; }); + let lastSignalsMarshalled = ""; const eventName = kebabize(key).toLowerCase(); switch (eventName) { case "load": callback(); - delete ctx.el.dataset.onLoad; + delete el.dataset.onLoad; return () => {}; case "raf": @@ -120,13 +116,9 @@ export const On: AttributePlugin = { }; case "signals-change": - return ctx.reactivity.effect(() => { - const signals = ctx.signals(); - let signalsValue = signals.value; - if (ctx.modifiers.has("remote")) { - signalsValue = remoteSignals(signalsValue); - } - const current = JSON.stringify(signalsValue); + return effect(() => { + const onlyRemoteSignals = mods.has("remote"); + const current = signals.JSON(false, onlyRemoteSignals); if (lastSignalsMarshalled !== current) { lastSignalsMarshalled = current; callback(); @@ -134,7 +126,7 @@ export const On: AttributePlugin = { }); default: - const testOutside = ctx.modifiers.has("outside"); + const testOutside = mods.has("outside"); if (testOutside) { target = document; const cb = callback; @@ -156,7 +148,6 @@ export const On: AttributePlugin = { target.addEventListener(eventName, callback, evtListOpts); return () => { - // console.log(`Removing event listener for ${eventName} on ${el}`) target.removeEventListener(eventName, callback); }; } diff --git a/library/src/plugins/official/dom/attributes/ref.ts b/library/src/plugins/official/dom/attributes/ref.ts new file mode 100644 index 000000000..982d3c1e6 --- /dev/null +++ b/library/src/plugins/official/dom/attributes/ref.ts @@ -0,0 +1,23 @@ +// Authors: Delaney Gillilan +// Icon: mdi:cursor-pointer +// Slug: Create a reference to an element +// Description: This attribute creates a reference to an element that can be used in other expressions. + +import { + AttributePlugin, + PluginType, + Requirement, +} from "../../../../engine/types"; + +// Sets the value of the element +export const Ref: AttributePlugin = { + type: PluginType.Attribute, + name: "ref", + keyReq: Requirement.Exclusive, + valReq: Requirement.Exclusive, + onLoad: ({ el, key, value, signals }) => { + const signalName = !!key ? key : value; + signals.upsert(signalName, el); + return () => signals.setValue(signalName, null); + }, +}; diff --git a/library/src/plugins/official/dom/attributes/text.ts b/library/src/plugins/official/dom/attributes/text.ts new file mode 100644 index 000000000..abe45672a --- /dev/null +++ b/library/src/plugins/official/dom/attributes/text.ts @@ -0,0 +1,29 @@ +// Authors: Delaney Gillilan +// Icon: tabler:typography +// Slug: Set the text content of an element +// Description: This attribute sets the text content of an element to the result of the expression. + +import { dsErr } from "../../../../engine/errors"; +import { + AttributePlugin, + PluginType, + Requirement, +} from "../../../../engine/types"; + +export const Text: AttributePlugin = { + type: PluginType.Attribute, + name: "text", + keyReq: Requirement.Denied, + valReq: Requirement.Must, + onLoad: (ctx) => { + const { el, genRX, effect } = ctx; + const rx = genRX(); + if (!(el instanceof HTMLElement)) { + dsErr("NotHtmlElement"); + } + return effect(() => { + const res = rx(ctx); + el.textContent = `${res}`; + }); + }, +}; diff --git a/library/src/plugins/official/logic/actions/fit.ts b/library/src/plugins/official/logic/actions/fit.ts new file mode 100644 index 000000000..3125719e7 --- /dev/null +++ b/library/src/plugins/official/logic/actions/fit.ts @@ -0,0 +1,36 @@ +// Authors: Delaney Gillilan +// Icon: material-symbols:fit-screen-outline +// Slug: Clamp a value to a new range +// Description: This action clamps a value to a new range. The value is first scaled to the new range, then clamped to the new range. This is useful for scaling a value to a new range, then clamping it to that range. + +import { + ActionPlugin, + PluginType, + RuntimeContext, +} from "../../../../engine/types"; + +const { round, max, min } = Math; +export const Fit: ActionPlugin = { + type: PluginType.Action, + name: "fit", + fn: ( + _: RuntimeContext, + v: number, + oldMin: number, + oldMax: number, + newMin: number, + newMax: number, + shouldClamp = false, + shouldRound = false, + ) => { + let fitted = ((v - oldMin) / (oldMax - oldMin)) * (newMax - newMin) + + newMin; + if (shouldRound) { + fitted = round(fitted); + } + if (shouldClamp) { + fitted = max(newMin, min(newMax, fitted)); + } + return fitted; + }, +}; diff --git a/library/src/plugins/official/logic/actions/setAll.ts b/library/src/plugins/official/logic/actions/setAll.ts new file mode 100644 index 000000000..482a1822c --- /dev/null +++ b/library/src/plugins/official/logic/actions/setAll.ts @@ -0,0 +1,16 @@ +// Authors: Delaney Gillilan +// Icon: ion:checkmark-round +// Slug: Set all signals that match a regular expression + +import { ActionPlugin, PluginType } from "../../../../engine/types"; + +export const SetAll: ActionPlugin = { + type: PluginType.Action, + name: "setAll", + fn: (ctx, regexp, newValue) => { + const re = new RegExp(regexp); + ctx.signals.walk( + (name, signal) => re.test(name) && (signal.value = newValue), + ); + }, +}; diff --git a/library/src/plugins/official/logic/actions/toggleAll.ts b/library/src/plugins/official/logic/actions/toggleAll.ts new file mode 100644 index 000000000..da14c6606 --- /dev/null +++ b/library/src/plugins/official/logic/actions/toggleAll.ts @@ -0,0 +1,16 @@ +// Authors: Delaney Gillilan +// Icon: material-symbols:toggle-off +// Slug: Toggle all signals that match a regular expression + +import { ActionPlugin, PluginType } from "../../../../engine/types"; + +export const ToggleAll: ActionPlugin = { + type: PluginType.Action, + name: "toggleAll", + fn: (ctx, regexp) => { + const re = new RegExp(regexp); + ctx.signals.walk( + (name, signal) => re.test(name) && (signal.value = !signal.value), + ); + }, +}; diff --git a/code/ts/library/src/utils/arguments.ts b/library/src/utils/arguments.ts similarity index 64% rename from code/ts/library/src/utils/arguments.ts rename to library/src/utils/arguments.ts index 5b6d80861..11fec58fd 100644 --- a/code/ts/library/src/utils/arguments.ts +++ b/library/src/utils/arguments.ts @@ -1,26 +1,23 @@ -export function argsToMs(args: string[] | undefined) { - if (!args || args?.length === 0) return 0; - +export function argsMs(args: Set) { + if (!args || args.size <= 0) return 0; for (const arg of args) { if (arg.endsWith("ms")) { return Number(arg.replace("ms", "")); } else if (arg.endsWith("s")) { return Number(arg.replace("s", "")) * 1000; } - try { return parseFloat(arg); } catch (e) {} } - return 0; } export function argsHas( - args: string[] | undefined, + args: Set, arg: string, defaultValue = false, ) { - if (!args) return false; - return args.includes(arg) || defaultValue; + if (!args) return defaultValue; + return args.has(arg); } diff --git a/code/ts/library/src/utils/dom.ts b/library/src/utils/dom.ts similarity index 65% rename from code/ts/library/src/utils/dom.ts rename to library/src/utils/dom.ts index 8b54ea696..0dfc0946c 100644 --- a/code/ts/library/src/utils/dom.ts +++ b/library/src/utils/dom.ts @@ -1,7 +1,6 @@ import { DATASTAR } from "../engine/consts"; -import { ERR_NOT_FOUND } from "../engine/errors"; -export function consistentUniqID(el: Element) { +export function elUniqId(el: Element) { if (el.id) return el.id; let hash = 0; const hashUpdate = (n: number) => { @@ -32,19 +31,4 @@ export function consistentUniqID(el: Element) { el = el.parentNode as Element; } return DATASTAR + hash; -} - -export function scrollIntoView( - el: HTMLElement | SVGElement, - opts: ScrollIntoViewOptions, - shouldFocus = true, -) { - if (!(el instanceof HTMLElement || el instanceof SVGElement)) { - // Element is not an HTMLElement or SVGElement - throw ERR_NOT_FOUND; - } - if (!el.tabIndex) el.setAttribute("tabindex", "0"); - - el.scrollIntoView(opts); - if (shouldFocus) el.focus(); -} +} \ No newline at end of file diff --git a/library/src/utils/lru.ts b/library/src/utils/lru.ts new file mode 100644 index 000000000..192f42876 --- /dev/null +++ b/library/src/utils/lru.ts @@ -0,0 +1,22 @@ +export class LruCache { + private entries: Map = new Map(); + constructor(private maxEntries = 64) {} + + public get(key: string): T | null { + let entry: T | null = null; + if (this.entries.has(key)) { + entry = this.entries.get(key)!; + this.entries.delete(key); + this.entries.set(key, entry); + } + return entry; + } + + public set(key: string, value: T) { + if (this.entries.size >= this.maxEntries) { + const keyToDelete = this.entries.keys().next().value; + this.entries.delete(keyToDelete); + } + this.entries.set(key, value); + } +} diff --git a/library/src/utils/text.ts b/library/src/utils/text.ts new file mode 100644 index 000000000..48aa28385 --- /dev/null +++ b/library/src/utils/text.ts @@ -0,0 +1,16 @@ +export const isBoolString = (str: string) => str.trim() === "true"; + +export const kebabize = (str: string) => + str.replace( + /[A-Z]+(?![a-z])|[A-Z]/g, + ($, ofs) => (ofs ? "-" : "") + $.toLowerCase(), + ); + +export const camelize = (str: string) => + str.replace(/(?:^\w|[A-Z]|\b\w)/g, function (word, index) { + return index == 0 ? word.toLowerCase() : word.toUpperCase(); + }).replace(/\s+/g, ""); + +export const jsStrToObject = (raw: string) => { + return (new Function(`return Object.assign({}, ${raw})`))(); +}; diff --git a/code/ts/library/src/utils/timing.ts b/library/src/utils/timing.ts similarity index 100% rename from code/ts/library/src/utils/timing.ts rename to library/src/utils/timing.ts diff --git a/code/ts/library/src/utils/view-transitions.ts b/library/src/utils/view-transtions.ts similarity index 100% rename from code/ts/library/src/utils/view-transitions.ts rename to library/src/utils/view-transtions.ts diff --git a/code/ts/library/src/vendored/fetch-event-source/parse.ts b/library/src/vendored/fetch-event-source.ts similarity index 50% rename from code/ts/library/src/vendored/fetch-event-source/parse.ts rename to library/src/vendored/fetch-event-source.ts index ca7910ccb..f56366fa9 100644 --- a/code/ts/library/src/vendored/fetch-event-source/parse.ts +++ b/library/src/vendored/fetch-event-source.ts @@ -1,3 +1,5 @@ +import { dsErr } from "../engine/errors"; + /** * Represents a message sent in an event stream * https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#Event_stream_format @@ -187,3 +189,192 @@ function newMessage(): EventSourceMessage { retry: undefined, }; } + +export const EventStreamContentType = "text/event-stream"; + +const DefaultRetryInterval = 1000; +const LastEventId = "last-event-id"; + +export interface FetchEventSourceInit extends RequestInit { + /** + * The request headers. FetchEventSource only supports the Record format. + */ + headers?: Record; + + /** + * Called when a response is received. Use this to validate that the response + * actually matches what you expect (and throw if it doesn't.) If not provided, + * will default to a basic validation to ensure the content-type is text/event-stream. + */ + onopen?: (response: Response) => Promise; + + /** + * Called when a message is received. NOTE: Unlike the default browser + * EventSource.onmessage, this callback is called for _all_ events, + * even ones with a custom `event` field. + */ + onmessage?: (ev: EventSourceMessage) => void; + + /** + * Called when a response finishes. If you don't expect the server to kill + * the connection, you can throw an exception here and retry using onerror. + */ + onclose?: () => void; + + /** + * Called when there is any error making the request / processing messages / + * handling callbacks etc. Use this to control the retry strategy: if the + * error is fatal, rethrow the error inside the callback to stop the entire + * operation. Otherwise, you can return an interval (in milliseconds) after + * which the request will automatically retry (with the last-event-id). + * If this callback is not specified, or it returns undefined, fetchEventSource + * will treat every error as retriable and will try again after 1 second. + */ + onerror?: (err: any) => number | null | undefined | void; + + /** + * If true, will keep the request open even if the document is hidden. + * By default, fetchEventSource will close the request and reopen it + * automatically when the document becomes visible again. + */ + openWhenHidden?: boolean; + + /** The Fetch function to use. Defaults to window.fetch */ + fetch?: typeof fetch; + + /** The scaler for the retry interval. Defaults to 2 */ + retryScaler?: number; + + /** The maximum retry interval in milliseconds. Defaults to 30_000 */ + retryMaxWaitMs?: number; + + /** The maximum number of retries before giving up. Defaults to 10 */ + retryMaxCount?: number; +} + +export function fetchEventSource(input: RequestInfo, { + signal: inputSignal, + headers: inputHeaders, + onopen: inputOnOpen, + onmessage, + onclose, + onerror, + openWhenHidden, + fetch: inputFetch, + retryScaler = 2, + retryMaxWaitMs = 30_000, + retryMaxCount = 10, + ...rest +}: FetchEventSourceInit) { + return new Promise((resolve, reject) => { + let retries = 0; + + // make a copy of the input headers since we may modify it below: + const headers = { ...inputHeaders }; + if (!headers.accept) { + headers.accept = EventStreamContentType; + } + + let curRequestController: AbortController; + function onVisibilityChange() { + curRequestController.abort(); // close existing request on every visibility change + if (!document.hidden) { + create(); // page is now visible again, recreate request. + } + } + + if (!openWhenHidden) { + document.addEventListener("visibilitychange", onVisibilityChange); + } + + let retryInterval = DefaultRetryInterval; + let retryTimer = 0; + function dispose() { + document.removeEventListener( + "visibilitychange", + onVisibilityChange, + ); + window.clearTimeout(retryTimer); + curRequestController.abort(); + } + + // if the incoming signal aborts, dispose resources and resolve: + inputSignal?.addEventListener("abort", () => { + dispose(); + resolve(); // don't waste time constructing/logging errors + }); + + const fetch = inputFetch ?? window.fetch; + const onopen = inputOnOpen ?? + function defaultOnOpen( + // response: Response + ) {}; + + async function create() { + curRequestController = new AbortController(); + try { + const response = await fetch(input, { + ...rest, + headers, + signal: curRequestController.signal, + }); + + await onopen(response); + + await getBytes( + response.body!, + getLines(getMessages((id) => { + if (id) { + // signals the id and send it back on the next retry: + headers[LastEventId] = id; + } else { + // don't send the last-event-id header anymore: + delete headers[LastEventId]; + } + }, (retry) => { + retryInterval = retry; + }, onmessage)), + ); + + onclose?.(); + dispose(); + resolve(); + } catch (err) { + if (!curRequestController.signal.aborted) { + // if we haven't aborted the request ourselves: + try { + // check if we need to retry: + const interval: any = onerror?.(err) ?? retryInterval; + window.clearTimeout(retryTimer); + retryTimer = window.setTimeout(create, interval); + retryInterval *= retryScaler; // exponential backoff + retryInterval = Math.min(retryInterval, retryMaxWaitMs); + retries++; + if (retries >= retryMaxCount) { + // we should not retry anymore: + dispose(); + // Max retries hit, check your server or network connection + reject( + dsErr("SSE_MAX_RETRIES", { + retryInterval, + retryMaxCount, + ...rest, + }), + ); + } else { + console.error( + `Datastar failed to reach ${rest.method}:${input.toString()} retry in ${interval}ms`, + ); + } + } catch (innerErr) { + // we should not retry anymore: + dispose(); + reject(innerErr); + } + } + } + } + + create(); + }); +} diff --git a/code/ts/library/src/vendored/idiomorph.ts b/library/src/vendored/idiomorph.ts similarity index 97% rename from code/ts/library/src/vendored/idiomorph.ts rename to library/src/vendored/idiomorph.ts index 0f67c7fb1..83ba9b22d 100644 --- a/code/ts/library/src/vendored/idiomorph.ts +++ b/library/src/vendored/idiomorph.ts @@ -1,5 +1,5 @@ import { FragmentMergeModes } from "../engine/consts"; -import { ERR_BAD_ARGS, ERR_NOT_FOUND } from "../engine/errors"; +import { dsErr } from "../engine/errors"; const generatedByIdiomorphId = new WeakSet(); @@ -64,8 +64,10 @@ function morphNormalizedContent( // into either side of the best match const bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx); if (!bestMatch) { - // could not find a best match, so throw an error - throw ERR_NOT_FOUND; + throw dsErr("NoBestMatchFound", { + old: oldNode, + new: normalizedNewContent, + }); } // stash the siblings that will need to be inserted on either side of the best match @@ -84,8 +86,7 @@ function morphNormalizedContent( return []; } } else { - // console.error(`Do not understand how to morph style ${ctx.morphStyle}`); - throw ERR_BAD_ARGS; + throw dsErr("InvalidMorphStyle", { style: ctx.morphStyle }); } } @@ -109,8 +110,7 @@ function morphOldNodeTo(oldNode: Element, newContent: Element, ctx: any) { if (ctx.callbacks.beforeNodeAdded(newContent) === false) return; if (!oldNode.parentElement) { - // oldNode has no parentElement - throw ERR_BAD_ARGS; + throw dsErr("NoParentElementFound", { oldNode }); } oldNode.parentElement.replaceChild(newContent, oldNode); ctx.callbacks.afterNodeAdded(newContent); @@ -376,19 +376,15 @@ function handleHeadElement( // Push the remaining new head elements in the Map into the // nodes to append to the head tag nodesToAppend.push(...srcToNewHeadNodes.values()); - // console.log('to append: ', nodesToAppend) const promises = []; for (const newNode of nodesToAppend) { - // console.log('adding: ', newNode) const newElt = document.createRange().createContextualFragment( newNode.outerHTML, ).firstChild as Element | null; if (!newElt) { - // console.error(`could not create new element from: ${newNode.outerHTML}`); - throw ERR_BAD_ARGS; + throw dsErr("NewElementCouldNotBeCreated", { newNode }); } - // console.log(newElt) if (!!ctx.callbacks.beforeNodeAdded(newElt)) { if (newElt.hasAttribute("href") || newElt.hasAttribute("src")) { let resolver: (value: unknown) => void; @@ -426,7 +422,7 @@ function handleHeadElement( //============================================================================= // Misc //============================================================================= -function noOp() {} +function noOp() { } function createMorphContext( oldNode: Element, @@ -493,8 +489,7 @@ function removeNodesBetween( const tempNode = startInclusive; startInclusive = startInclusive?.nextSibling as Element; if (!tempNode) { - // tempNode is null - throw ERR_BAD_ARGS; + throw dsErr("NoTemporaryNodeFound", { startInclusive, endExclusive }); } removeNode(tempNode, ctx); } @@ -644,8 +639,7 @@ function parseContent(newContent: string) { ); const content = responseDoc.body.querySelector("template")?.content; if (!content) { - // Content is null - throw ERR_NOT_FOUND; + throw dsErr("NoContentFound", { newContent }); } generatedByIdiomorphId.add(content); return content; diff --git a/library/src/vendored/preact-core.ts b/library/src/vendored/preact-core.ts new file mode 100644 index 000000000..9dd242cbf --- /dev/null +++ b/library/src/vendored/preact-core.ts @@ -0,0 +1,839 @@ +// An named symbol/brand for detecting Signal instances even when they weren't + +import { dsErr } from "../engine/errors"; +import { OnRemovalFn } from "../engine/types"; + +// created using the same signals library version. +const BRAND_SYMBOL = Symbol.for("preact-signals"); + +// Flags for Computed and Effect. +const RUNNING = 1 << 0; +const NOTIFIED = 1 << 1; +const OUTDATED = 1 << 2; +const DISPOSED = 1 << 3; +const HAS_ERROR = 1 << 4; +const TRACKING = 1 << 5; + +// A linked list node used to track dependencies (sources) and dependents (targets). +// Also used to remember the source's last version number that the target saw. +type Node = { + // A source whose value the target depends on. + _source: Signal; + _prevSource?: Node; + _nextSource?: Node; + + // A target that depends on the source and should be notified when the source changes. + _target: Computed | Effect; + _prevTarget?: Node; + _nextTarget?: Node; + + // The version number of the source that target has last seen. We use version numbers + // instead of storing the source value, because source values can take arbitrary amount + // of memory, and computeds could hang on to them forever because they're lazily evaluated. + // Use the special value -1 to mark potentially unused but recyclable nodes. + _version: number; + + // Used to remember & roll back the source's previous `._node` value when entering & + // exiting a new evaluation context. + _rollbackNode?: Node; +}; + +function startBatch() { + batchDepth++; +} + +function endBatch() { + if (batchDepth > 1) { + batchDepth--; + return; + } + + let error: unknown; + let hasError = false; + + while (batchedEffect !== undefined) { + let effect: Effect | undefined = batchedEffect; + batchedEffect = undefined; + + batchIteration++; + + while (effect !== undefined) { + const next: Effect | undefined = effect._nextBatchedEffect; + effect._nextBatchedEffect = undefined; + effect._flags &= ~NOTIFIED; + + if (!(effect._flags & DISPOSED) && needsToRecompute(effect)) { + try { + effect._callback(); + } catch (err) { + if (!hasError) { + error = err; + hasError = true; + } + } + } + effect = next; + } + } + batchIteration = 0; + batchDepth--; + + if (hasError) { + throw dsErr("BatchError, error"); + } +} + +/** + * Combine multiple value updates into one "commit" at the end of the provided callback. + * + * Batches can be nested and changes are only flushed once the outermost batch callback + * completes. + * + * Accessing a signal that has been modified within a batch will reflect its updated + * value. + * + * @param fn The callback function. + * @returns The value returned by the callback. + */ +function batch(fn: () => T): T { + if (batchDepth > 0) { + return fn(); + } + /*@__INLINE__**/ startBatch(); + try { + return fn(); + } finally { + endBatch(); + } +} + +// Currently evaluated computed or effect. +let evalContext: Computed | Effect | undefined = undefined; + +/** + * Run a callback function that can access signal values without + * subscribing to the signal updates. + * + * @param fn The callback function. + * @returns The value returned by the callback. + */ +function untracked(fn: () => T): T { + const prevContext = evalContext; + evalContext = undefined; + try { + return fn(); + } finally { + evalContext = prevContext; + } +} + +// Effects collected into a batch. +let batchedEffect: Effect | undefined = undefined; +let batchDepth = 0; +let batchIteration = 0; + +// A global version number for signals, used for fast-pathing repeated +// computed.peek()/computed.value calls when nothing has changed globally. +let globalVersion = 0; + +function addDependency(signal: Signal): Node | undefined { + if (evalContext === undefined) { + return undefined; + } + + let node = signal._node; + if (node === undefined || node._target !== evalContext) { + /** + * `signal` is a new dependency. Create a new dependency node, and set it + * as the tail of the current context's dependency list. e.g: + * + * { A <-> B } + * ↑ ↑ + * tail node (new) + * ↓ + * { A <-> B <-> C } + * ↑ + * tail (evalContext._sources) + */ + node = { + _version: 0, + _source: signal, + _prevSource: evalContext._sources, + _nextSource: undefined, + _target: evalContext, + _prevTarget: undefined, + _nextTarget: undefined, + _rollbackNode: node, + }; + + if (evalContext._sources !== undefined) { + evalContext._sources._nextSource = node; + } + evalContext._sources = node; + signal._node = node; + + // Subscribe to change notifications from this dependency if we're in an effect + // OR evaluating a computed signal that in turn has subscribers. + if (evalContext._flags & TRACKING) { + signal._subscribe(node); + } + return node; + } else if (node._version === -1) { + // `signal` is an existing dependency from a previous evaluation. Reuse it. + node._version = 0; + + /** + * If `node` is not already the current tail of the dependency list (i.e. + * there is a next node in the list), then make the `node` the new tail. e.g: + * + * { A <-> B <-> C <-> D } + * ↑ ↑ + * node ┌─── tail (evalContext._sources) + * └─────│─────┐ + * ↓ ↓ + * { A <-> C <-> D <-> B } + * ↑ + * tail (evalContext._sources) + */ + if (node._nextSource !== undefined) { + node._nextSource._prevSource = node._prevSource; + + if (node._prevSource !== undefined) { + node._prevSource._nextSource = node._nextSource; + } + + node._prevSource = evalContext._sources; + node._nextSource = undefined; + + evalContext._sources!._nextSource = node; + evalContext._sources = node; + } + + // We can assume that the currently evaluated effect / computed signal is already + // subscribed to change notifications from `signal` if needed. + return node; + } + return undefined; +} + +/** + * The base class for plain and computed signals. + */ +// @ts-ignore: "Cannot redeclare exported variable 'Signal'." +// +// A function with the same name is defined later, so we need to ignore TypeScript's +// warning about a redeclared variable. +// +// The class is declared here, but later implemented with ES5-style protoTYPEOF_ +// This enables better control of the transpiled output size. +declare class Signal { + /** @internal */ + _value: unknown; + + /** + * @internal + * Version numbers should always be >= 0, because the special value -1 is used + * by Nodes to signify potentially unused but recyclable nodes. + */ + _version: number; + + /** @internal */ + _node?: Node; + + /** @internal */ + _targets?: Node; + + constructor(value?: T); + + /** @internal */ + _refresh(): boolean; + + /** @internal */ + _subscribe(node: Node): void; + + /** @internal */ + _unsubscribe(node: Node): void; + + subscribe(fn: (value: T) => void): () => void; + + valueOf(): T; + + toString(): string; + + toJSON(): T; + + peek(): T; + + brand: typeof BRAND_SYMBOL; + + get value(): T; + set value(value: T); +} + +/** @internal */ +// @ts-ignore: "Cannot redeclare exported variable 'Signal'." +// +// A class with the same name has already been declared, so we need to ignore +// TypeScript's warning about a redeclared variable. +// +// The previously declared class is implemented here with ES5-style protoTYPEOF_ +// This enables better control of the transpiled output size. +function Signal(this: Signal, value?: unknown) { + this._value = value; + this._version = 0; + this._node = undefined; + this._targets = undefined; +} + +Signal.prototype.brand = BRAND_SYMBOL; + +Signal.prototype._refresh = function () { + return true; +}; + +Signal.prototype._subscribe = function (node) { + if (this._targets !== node && node._prevTarget === undefined) { + node._nextTarget = this._targets; + if (this._targets !== undefined) { + this._targets._prevTarget = node; + } + this._targets = node; + } +}; + +Signal.prototype._unsubscribe = function (node) { + // Only run the unsubscribe step if the signal has any subscribers to begin with. + if (this._targets !== undefined) { + const prev = node._prevTarget; + const next = node._nextTarget; + if (prev !== undefined) { + prev._nextTarget = next; + node._prevTarget = undefined; + } + if (next !== undefined) { + next._prevTarget = prev; + node._nextTarget = undefined; + } + if (node === this._targets) { + this._targets = next; + } + } +}; + +Signal.prototype.subscribe = function (fn) { + return effect(() => { + const value = this.value; + + const prevContext = evalContext; + evalContext = undefined; + try { + fn(value); + } finally { + evalContext = prevContext; + } + }); +}; + +Signal.prototype.valueOf = function () { + return this.value; +}; + +Signal.prototype.toString = function () { + return this.value + ""; +}; + +Signal.prototype.toJSON = function () { + return this.value; +}; + +Signal.prototype.peek = function () { + const prevContext = evalContext; + evalContext = undefined; + try { + return this.value; + } finally { + evalContext = prevContext; + } +}; + +Object.defineProperty(Signal.prototype, "value", { + get(this: Signal) { + const node = addDependency(this); + if (node !== undefined) { + node._version = this._version; + } + return this._value; + }, + set(this: Signal, value) { + if (value !== this._value) { + if (batchIteration > 100) { + throw dsErr("SignalCycleDetected"); + } + + this._value = value; + this._version++; + globalVersion++; + + /**@__INLINE__*/ startBatch(); + try { + for ( + let node = this._targets; + node !== undefined; + node = node._nextTarget + ) { + node._target._notify(); + } + } finally { + endBatch(); + } + } + }, +}); + +/** + * Create a new plain signal. + * + * @param value The initial value for the signal. + * @returns A new signal. + */ +export function signal(value: T): Signal; +export function signal(): Signal; +export function signal(value?: T): Signal { + return new Signal(value); +} + +function needsToRecompute(target: Computed | Effect): boolean { + // Check the dependencies for changed values. The dependency list is already + // in order of use. Therefore if multiple dependencies have changed values, only + // the first used dependency is re-evaluated at this point. + for ( + let node = target._sources; + node !== undefined; + node = node._nextSource + ) { + // If there's a new version of the dependency before or after refreshing, + // or the dependency has something blocking it from refreshing at all (e.g. a + // dependency cycle), then we need to recompute. + if ( + node._source._version !== node._version || + !node._source._refresh() || + node._source._version !== node._version + ) { + return true; + } + } + // If none of the dependencies have changed values since last recompute then + // there's no need to recompute. + return false; +} + +function prepareSources(target: Computed | Effect) { + /** + * 1. Mark all current sources as re-usable nodes (version: -1) + * 2. Set a rollback node if the current node is being used in a different context + * 3. Point 'target._sources' to the tail of the doubly-linked list, e.g: + * + * { undefined <- A <-> B <-> C -> undefined } + * ↑ ↑ + * │ └──────┐ + * target._sources = A; (node is head) │ + * ↓ │ + * target._sources = C; (node is tail) ─┘ + */ + for ( + let node = target._sources; + node !== undefined; + node = node._nextSource + ) { + const rollbackNode = node._source._node; + if (rollbackNode !== undefined) { + node._rollbackNode = rollbackNode; + } + node._source._node = node; + node._version = -1; + + if (node._nextSource === undefined) { + target._sources = node; + break; + } + } +} + +function cleanupSources(target: Computed | Effect) { + let node = target._sources; + let head: Node | undefined = undefined; + + /** + * At this point 'target._sources' points to the tail of the doubly-linked list. + * It contains all existing sources + new sources in order of use. + * Iterate backwards until we find the head node while dropping old dependencies. + */ + while (node !== undefined) { + const prev = node._prevSource; + + /** + * The node was not re-used, unsubscribe from its change notifications and remove itself + * from the doubly-linked list. e.g: + * + * { A <-> B <-> C } + * ↓ + * { A <-> C } + */ + if (node._version === -1) { + node._source._unsubscribe(node); + + if (prev !== undefined) { + prev._nextSource = node._nextSource; + } + if (node._nextSource !== undefined) { + node._nextSource._prevSource = prev; + } + } else { + /** + * The new head is the last node seen which wasn't removed/unsubscribed + * from the doubly-linked list. e.g: + * + * { A <-> B <-> C } + * ↑ ↑ ↑ + * │ │ └ head = node + * │ └ head = node + * └ head = node + */ + head = node; + } + + node._source._node = node._rollbackNode; + if (node._rollbackNode !== undefined) { + node._rollbackNode = undefined; + } + + node = prev; + } + + target._sources = head; +} + +export declare class Computed extends Signal { + _fn: () => T; + _sources?: Node; + _globalVersion: number; + _flags: number; + + constructor(fn: () => T); + + _notify(): void; + get value(): T; +} + +export function Computed(this: Computed, fn: () => unknown) { + Signal.call(this, undefined); + + this._fn = fn; + this._sources = undefined; + this._globalVersion = globalVersion - 1; + this._flags = OUTDATED; +} + +Computed.prototype = new Signal() as Computed; + +Computed.prototype._refresh = function () { + this._flags &= ~NOTIFIED; + + if (this._flags & RUNNING) { + return false; + } + + // If this computed signal has subscribed to updates from its dependencies + // (TRACKING flag set) and none of them have notified about changes (OUTDATED + // flag not set), then the computed value can't have changed. + if ((this._flags & (OUTDATED | TRACKING)) === TRACKING) { + return true; + } + this._flags &= ~OUTDATED; + + if (this._globalVersion === globalVersion) { + return true; + } + this._globalVersion = globalVersion; + + // Mark this computed signal running before checking the dependencies for value + // changes, so that the RUNNING flag can be used to notice cyclical dependencies. + this._flags |= RUNNING; + if (this._version > 0 && !needsToRecompute(this)) { + this._flags &= ~RUNNING; + return true; + } + + const prevContext = evalContext; + try { + prepareSources(this); + evalContext = this; + const value = this._fn(); + if ( + this._flags & HAS_ERROR || + this._value !== value || + this._version === 0 + ) { + this._value = value; + this._flags &= ~HAS_ERROR; + this._version++; + } + } catch (err) { + this._value = err; + this._flags |= HAS_ERROR; + this._version++; + } + evalContext = prevContext; + cleanupSources(this); + this._flags &= ~RUNNING; + return true; +}; + +Computed.prototype._subscribe = function (node) { + if (this._targets === undefined) { + this._flags |= OUTDATED | TRACKING; + + // A computed signal subscribes lazily to its dependencies when it + // gets its first subscriber. + for ( + let node = this._sources; + node !== undefined; + node = node._nextSource + ) { + node._source._subscribe(node); + } + } + Signal.prototype._subscribe.call(this, node); +}; + +Computed.prototype._unsubscribe = function (node) { + // Only run the unsubscribe step if the computed signal has any subscribers. + if (this._targets !== undefined) { + Signal.prototype._unsubscribe.call(this, node); + + // Computed signal unsubscribes from its dependencies when it loses its last subscriber. + // This makes it possible for unreferences subgraphs of computed signals to get garbage collected. + if (this._targets === undefined) { + this._flags &= ~TRACKING; + + for ( + let node = this._sources; + node !== undefined; + node = node._nextSource + ) { + node._source._unsubscribe(node); + } + } + } +}; + +Computed.prototype._notify = function () { + if (!(this._flags & NOTIFIED)) { + this._flags |= OUTDATED | NOTIFIED; + + for ( + let node = this._targets; + node !== undefined; + node = node._nextTarget + ) { + node._target._notify(); + } + } +}; + +Object.defineProperty(Computed.prototype, "value", { + get(this: Computed) { + if (this._flags & RUNNING) { + // Cycle detected + throw dsErr("SignalCycleDetected"); + } + const node = addDependency(this); + this._refresh(); + if (node !== undefined) { + node._version = this._version; + } + if (this._flags & HAS_ERROR) { + throw dsErr("GetComputedError", { value: this._value }); + } + return this._value; + }, +}); + +/** + * An interface for read-only signals. + */ +interface ReadonlySignal { + readonly value: T; + peek(): T; + + subscribe(fn: (value: T) => void): () => void; + valueOf(): T; + toString(): string; + toJSON(): T; + brand: typeof BRAND_SYMBOL; +} + +/** + * Create a new signal that is computed based on the values of other signals. + * + * The returned computed signal is read-only, and its value is automatically + * updated when any signals accessed from within the callback function change. + * + * @param fn The effect callback. + * @returns A new read-only signal. + */ +function computed(fn: () => T): ReadonlySignal { + return new Computed(fn); +} + +function cleanupEffect(effect: Effect) { + const cleanup = effect._cleanup; + effect._cleanup = undefined; + + if (typeof cleanup === "function") { + /*@__INLINE__**/ startBatch(); + + // Run cleanup functions always outside of any context. + const prevContext = evalContext; + evalContext = undefined; + try { + cleanup!(); + } catch (error) { + effect._flags &= ~RUNNING; + effect._flags |= DISPOSED; + disposeEffect(effect); + throw dsErr("CleanupEffectError", { error }); + } finally { + evalContext = prevContext; + endBatch(); + } + } +} + +function disposeEffect(effect: Effect) { + for ( + let node = effect._sources; + node !== undefined; + node = node._nextSource + ) { + node._source._unsubscribe(node); + } + effect._fn = undefined; + effect._sources = undefined; + + cleanupEffect(effect); +} + +function endEffect(this: Effect, prevContext?: Computed | Effect) { + if (evalContext !== this) { + throw dsErr("EndEffectError"); + } + cleanupSources(this); + evalContext = prevContext; + + this._flags &= ~RUNNING; + if (this._flags & DISPOSED) { + disposeEffect(this); + } + endBatch(); +} + +export type EffectFn = () => OnRemovalFn | void | Promise; + +declare class Effect { + _fn?: EffectFn; + _cleanup?: () => void; + _sources?: Node; + _nextBatchedEffect?: Effect; + _flags: number; + + constructor(fn: EffectFn); + + _callback(): void; + _start(): () => void; + _notify(): void; + _dispose(): void; +} + +function Effect(this: Effect, fn: EffectFn) { + this._fn = fn; + this._cleanup = undefined; + this._sources = undefined; + this._nextBatchedEffect = undefined; + this._flags = TRACKING; +} + +Effect.prototype._callback = function () { + const finish = this._start(); + try { + if (this._flags & DISPOSED) return; + if (this._fn === undefined) return; + + const cleanup = this._fn(); + if (typeof cleanup === "function") { + this._cleanup = cleanup!; + } + } finally { + finish(); + } +}; + +Effect.prototype._start = function () { + if (this._flags & RUNNING) { + throw dsErr("SignalCycleDetected"); + } + this._flags |= RUNNING; + this._flags &= ~DISPOSED; + cleanupEffect(this); + prepareSources(this); + + /*@__INLINE__**/ startBatch(); + const prevContext = evalContext; + evalContext = this; + return endEffect.bind(this, prevContext); +}; + +Effect.prototype._notify = function () { + if (!(this._flags & NOTIFIED)) { + this._flags |= NOTIFIED; + this._nextBatchedEffect = batchedEffect; + batchedEffect = this; + } +}; + +Effect.prototype._dispose = function () { + this._flags |= DISPOSED; + + if (!(this._flags & RUNNING)) { + disposeEffect(this); + } +}; + +/** + * Create an effect to run arbitrary code in response to signal changes. + * + * An effect tracks which signals are accessed within the given callback + * function `fn`, and re-runs the callback when those signals change. + * + * The callback may return a cleanup function. The cleanup function gets + * run once, either when the callback is next called or when the effect + * gets disposed, whichever happens first. + * + * @param fn The effect callback. + * @returns A function for disposing the effect. + */ +function effect(fn: EffectFn): () => void { + const effect = new Effect(fn); + try { + effect._callback(); + } catch (error) { + effect._dispose(); + throw dsErr("EffectError", { error }); + } + // Return a bound function instead of a wrapper like `() => effect._dispose()`, + // because bound functions seem to be just as fast and take up a lot less memory. + return effect._dispose.bind(effect); +} + +export { batch, computed, effect, Signal, untracked }; +export type { ReadonlySignal }; diff --git a/code/ts/library/tsconfig.json b/library/tsconfig.json similarity index 100% rename from code/ts/library/tsconfig.json rename to library/tsconfig.json diff --git a/design/SDK.md b/sdk/README.md similarity index 100% rename from design/SDK.md rename to sdk/README.md diff --git a/code/dotnet/.gitignore b/sdk/dotnet/.gitignore similarity index 100% rename from code/dotnet/.gitignore rename to sdk/dotnet/.gitignore diff --git a/code/dotnet/Build.ps1 b/sdk/dotnet/Build.ps1 similarity index 100% rename from code/dotnet/Build.ps1 rename to sdk/dotnet/Build.ps1 diff --git a/code/dotnet/README.md b/sdk/dotnet/README.md similarity index 87% rename from code/dotnet/README.md rename to sdk/dotnet/README.md index a9c7d6406..e7592f88a 100644 --- a/code/dotnet/README.md +++ b/sdk/dotnet/README.md @@ -18,13 +18,13 @@ Real-time Hypermedia first Library and Framework for dotnet
- +

- +
@@ -63,7 +63,7 @@ app.UseStaticFiles(); app.MapGet("/displayDate", async (IServerSentEventGenerator sse) => { string today = DateTime.Now.ToString("%y-%M-%d %h:%m:%s"); - await sse.MergeFragments($"""
{today}
"""); + await sse.MergeFragments($"""
{today}
"""); }); app.MapGet("/removeDate", async (IServerSentEventGenerator sse) => { await sse.RemoveFragments("#date"); }); app.MapPost("/changeOutput", async (IServerSentEventGenerator sse, IDatastarSignals signals) => diff --git a/code/dotnet/sdk/src/.gitattributes b/sdk/dotnet/src/.gitattributes similarity index 100% rename from code/dotnet/sdk/src/.gitattributes rename to sdk/dotnet/src/.gitattributes diff --git a/code/dotnet/sdk/src/Consts.fs b/sdk/dotnet/src/Consts.fs similarity index 95% rename from code/dotnet/sdk/src/Consts.fs rename to sdk/dotnet/src/Consts.fs index 5c52bd65b..387982562 100644 --- a/code/dotnet/sdk/src/Consts.fs +++ b/sdk/dotnet/src/Consts.fs @@ -37,9 +37,9 @@ type EventType = module Consts = let [] DatastarKey = "datastar" - let [] Version = "0.20.1" - let [] VersionClientByteSize = 35480 - let [] VersionClientByteSizeGzip = 12565 + let [] Version = "0.21.0-beta1" + let [] VersionClientByteSize = 33186 + let [] VersionClientByteSizeGzip = 12206 /// Default: TimeSpan.FromMilliseconds 300 let DefaultSettleDuration = TimeSpan.FromMilliseconds 300 diff --git a/code/dotnet/sdk/src/Datastar.fsproj b/sdk/dotnet/src/Datastar.fsproj similarity index 100% rename from code/dotnet/sdk/src/Datastar.fsproj rename to sdk/dotnet/src/Datastar.fsproj diff --git a/code/dotnet/sdk/src/DependencyInjection/ServerSentEventService.fs b/sdk/dotnet/src/DependencyInjection/ServerSentEventService.fs similarity index 100% rename from code/dotnet/sdk/src/DependencyInjection/ServerSentEventService.fs rename to sdk/dotnet/src/DependencyInjection/ServerSentEventService.fs diff --git a/code/dotnet/sdk/src/DependencyInjection/ServerSentEventServices.fs b/sdk/dotnet/src/DependencyInjection/ServerSentEventServices.fs similarity index 100% rename from code/dotnet/sdk/src/DependencyInjection/ServerSentEventServices.fs rename to sdk/dotnet/src/DependencyInjection/ServerSentEventServices.fs diff --git a/code/dotnet/sdk/src/Falco/Response.fs b/sdk/dotnet/src/Falco/Response.fs similarity index 100% rename from code/dotnet/sdk/src/Falco/Response.fs rename to sdk/dotnet/src/Falco/Response.fs diff --git a/code/dotnet/sdk/src/Scripts/ServerSentEventExtensions.fs b/sdk/dotnet/src/Scripts/ServerSentEventExtensions.fs similarity index 100% rename from code/dotnet/sdk/src/Scripts/ServerSentEventExtensions.fs rename to sdk/dotnet/src/Scripts/ServerSentEventExtensions.fs diff --git a/code/dotnet/sdk/src/Scripts/ServerSentEventGeneratorExtensions.fs b/sdk/dotnet/src/Scripts/ServerSentEventGeneratorExtensions.fs similarity index 100% rename from code/dotnet/sdk/src/Scripts/ServerSentEventGeneratorExtensions.fs rename to sdk/dotnet/src/Scripts/ServerSentEventGeneratorExtensions.fs diff --git a/code/dotnet/sdk/src/ServerSentEvent.fs b/sdk/dotnet/src/ServerSentEvent.fs similarity index 100% rename from code/dotnet/sdk/src/ServerSentEvent.fs rename to sdk/dotnet/src/ServerSentEvent.fs diff --git a/code/dotnet/sdk/src/ServerSentEventGenerator.fs b/sdk/dotnet/src/ServerSentEventGenerator.fs similarity index 100% rename from code/dotnet/sdk/src/ServerSentEventGenerator.fs rename to sdk/dotnet/src/ServerSentEventGenerator.fs diff --git a/code/dotnet/sdk/src/ServerSentEventHttpHandler.fs b/sdk/dotnet/src/ServerSentEventHttpHandler.fs similarity index 100% rename from code/dotnet/sdk/src/ServerSentEventHttpHandler.fs rename to sdk/dotnet/src/ServerSentEventHttpHandler.fs diff --git a/code/dotnet/sdk/src/Utility.fs b/sdk/dotnet/src/Utility.fs similarity index 100% rename from code/dotnet/sdk/src/Utility.fs rename to sdk/dotnet/src/Utility.fs diff --git a/code/go/sdk/.gitattributes b/sdk/go/.gitattributes similarity index 100% rename from code/go/sdk/.gitattributes rename to sdk/go/.gitattributes diff --git a/code/go/sdk/consts.go b/sdk/go/consts.go similarity index 96% rename from code/go/sdk/consts.go rename to sdk/go/consts.go index b346cf3c2..e6c57a3d8 100644 --- a/code/go/sdk/consts.go +++ b/sdk/go/consts.go @@ -6,9 +6,9 @@ import "time" const ( DatastarKey = "datastar" - Version = "0.20.1" - VersionClientByteSize = 35480 - VersionClientByteSizeGzip = 12565 + Version = "0.21.0-beta1" + VersionClientByteSize = 33186 + VersionClientByteSizeGzip = 12206 //region Default durations diff --git a/code/go/sdk/execute-script-sugar.go b/sdk/go/execute-script-sugar.go similarity index 100% rename from code/go/sdk/execute-script-sugar.go rename to sdk/go/execute-script-sugar.go diff --git a/code/go/sdk/execute.go b/sdk/go/execute.go similarity index 100% rename from code/go/sdk/execute.go rename to sdk/go/execute.go diff --git a/code/go/sdk/fragments-sugar.go b/sdk/go/fragments-sugar.go similarity index 87% rename from code/go/sdk/fragments-sugar.go rename to sdk/go/fragments-sugar.go index 2d3342d65..6c3ce25b3 100644 --- a/code/go/sdk/fragments-sugar.go +++ b/sdk/go/fragments-sugar.go @@ -97,21 +97,21 @@ func (sse *ServerSentEventGenerator) MergeFragmentGostar(child elements.ElementR } func GetSSE(urlFormat string, args ...any) string { - return fmt.Sprintf(`@get('%s')`, fmt.Sprintf(urlFormat, args...)) + return fmt.Sprintf(`sse('%s',{method:'get'})`, fmt.Sprintf(urlFormat, args...)) } func PostSSE(urlFormat string, args ...any) string { - return fmt.Sprintf(`@post('%s')`, fmt.Sprintf(urlFormat, args...)) + return fmt.Sprintf(`sse('%s',{method:'post'})`, fmt.Sprintf(urlFormat, args...)) } func PutSSE(urlFormat string, args ...any) string { - return fmt.Sprintf(`@put('%s')`, fmt.Sprintf(urlFormat, args...)) + return fmt.Sprintf(`sse('%s',{method:'put'})`, fmt.Sprintf(urlFormat, args...)) } func PatchSSE(urlFormat string, args ...any) string { - return fmt.Sprintf(`@patch('%s')`, fmt.Sprintf(urlFormat, args...)) + return fmt.Sprintf(`sse('%s',{method:'patch'})`, fmt.Sprintf(urlFormat, args...)) } func DeleteSSE(urlFormat string, args ...any) string { - return fmt.Sprintf(`@delete('%s')`, fmt.Sprintf(urlFormat, args...)) + return fmt.Sprintf(`sse('%s',{method:'delete'})`, fmt.Sprintf(urlFormat, args...)) } diff --git a/code/go/sdk/fragments.go b/sdk/go/fragments.go similarity index 100% rename from code/go/sdk/fragments.go rename to sdk/go/fragments.go diff --git a/code/go/sdk/signals-sugar.go b/sdk/go/signals-sugar.go similarity index 100% rename from code/go/sdk/signals-sugar.go rename to sdk/go/signals-sugar.go diff --git a/code/go/sdk/signals.go b/sdk/go/signals.go similarity index 100% rename from code/go/sdk/signals.go rename to sdk/go/signals.go diff --git a/code/go/sdk/sse.go b/sdk/go/sse.go similarity index 100% rename from code/go/sdk/sse.go rename to sdk/go/sse.go diff --git a/code/go/sdk/types.go b/sdk/go/types.go similarity index 100% rename from code/go/sdk/types.go rename to sdk/go/types.go diff --git a/code/php/sdk/.gitattributes b/sdk/php/.gitattributes similarity index 100% rename from code/php/sdk/.gitattributes rename to sdk/php/.gitattributes diff --git a/code/php/sdk/.github/PULL_REQUEST_TEMPLATE.md b/sdk/php/.github/PULL_REQUEST_TEMPLATE.md similarity index 100% rename from code/php/sdk/.github/PULL_REQUEST_TEMPLATE.md rename to sdk/php/.github/PULL_REQUEST_TEMPLATE.md diff --git a/code/php/sdk/.gitignore b/sdk/php/.gitignore similarity index 100% rename from code/php/sdk/.gitignore rename to sdk/php/.gitignore diff --git a/code/php/sdk/README.md b/sdk/php/README.md similarity index 100% rename from code/php/sdk/README.md rename to sdk/php/README.md diff --git a/code/php/sdk/composer.json b/sdk/php/composer.json similarity index 100% rename from code/php/sdk/composer.json rename to sdk/php/composer.json diff --git a/code/php/sdk/ecs.php b/sdk/php/ecs.php similarity index 100% rename from code/php/sdk/ecs.php rename to sdk/php/ecs.php diff --git a/code/php/sdk/phpstan.neon b/sdk/php/phpstan.neon similarity index 100% rename from code/php/sdk/phpstan.neon rename to sdk/php/phpstan.neon diff --git a/code/php/sdk/src/.gitattributes b/sdk/php/src/.gitattributes similarity index 100% rename from code/php/sdk/src/.gitattributes rename to sdk/php/src/.gitattributes diff --git a/code/php/sdk/src/Consts.php b/sdk/php/src/Consts.php similarity index 92% rename from code/php/sdk/src/Consts.php rename to sdk/php/src/Consts.php index 194a2c447..50a18a2f4 100644 --- a/code/php/sdk/src/Consts.php +++ b/sdk/php/src/Consts.php @@ -10,9 +10,9 @@ class Consts { public const DATASTAR_KEY = 'datastar'; - public const VERSION = '0.20.1'; - public const VERSION_CLIENT_BYTE_SIZE = 35480; - public const VERSION_CLIENT_BYTE_SIZE_GZIP = 12565; + public const VERSION = '0.21.0-beta1'; + public const VERSION_CLIENT_BYTE_SIZE = 33186; + public const VERSION_CLIENT_BYTE_SIZE_GZIP = 12206; // The default duration for settling during merges. Allows for CSS transitions to complete. public const DEFAULT_SETTLE_DURATION = 300; diff --git a/code/php/sdk/src/ServerSentEventData.php b/sdk/php/src/ServerSentEventData.php similarity index 100% rename from code/php/sdk/src/ServerSentEventData.php rename to sdk/php/src/ServerSentEventData.php diff --git a/code/php/sdk/src/ServerSentEventGenerator.php b/sdk/php/src/ServerSentEventGenerator.php similarity index 100% rename from code/php/sdk/src/ServerSentEventGenerator.php rename to sdk/php/src/ServerSentEventGenerator.php diff --git a/code/php/sdk/src/enums/EventType.php b/sdk/php/src/enums/EventType.php similarity index 100% rename from code/php/sdk/src/enums/EventType.php rename to sdk/php/src/enums/EventType.php diff --git a/code/php/sdk/src/enums/FragmentMergeMode.php b/sdk/php/src/enums/FragmentMergeMode.php similarity index 100% rename from code/php/sdk/src/enums/FragmentMergeMode.php rename to sdk/php/src/enums/FragmentMergeMode.php diff --git a/code/php/sdk/src/events/EventInterface.php b/sdk/php/src/events/EventInterface.php similarity index 100% rename from code/php/sdk/src/events/EventInterface.php rename to sdk/php/src/events/EventInterface.php diff --git a/code/php/sdk/src/events/EventTrait.php b/sdk/php/src/events/EventTrait.php similarity index 100% rename from code/php/sdk/src/events/EventTrait.php rename to sdk/php/src/events/EventTrait.php diff --git a/code/php/sdk/src/events/ExecuteScript.php b/sdk/php/src/events/ExecuteScript.php similarity index 100% rename from code/php/sdk/src/events/ExecuteScript.php rename to sdk/php/src/events/ExecuteScript.php diff --git a/code/php/sdk/src/events/MergeFragments.php b/sdk/php/src/events/MergeFragments.php similarity index 100% rename from code/php/sdk/src/events/MergeFragments.php rename to sdk/php/src/events/MergeFragments.php diff --git a/code/php/sdk/src/events/MergeSignals.php b/sdk/php/src/events/MergeSignals.php similarity index 100% rename from code/php/sdk/src/events/MergeSignals.php rename to sdk/php/src/events/MergeSignals.php diff --git a/code/php/sdk/src/events/RemoveFragments.php b/sdk/php/src/events/RemoveFragments.php similarity index 100% rename from code/php/sdk/src/events/RemoveFragments.php rename to sdk/php/src/events/RemoveFragments.php diff --git a/code/php/sdk/src/events/RemoveSignals.php b/sdk/php/src/events/RemoveSignals.php similarity index 100% rename from code/php/sdk/src/events/RemoveSignals.php rename to sdk/php/src/events/RemoveSignals.php diff --git a/code/php/sdk/tests/README.md b/sdk/php/tests/README.md similarity index 100% rename from code/php/sdk/tests/README.md rename to sdk/php/tests/README.md diff --git a/code/php/sdk/tests/Unit/ExecuteScriptTest.php b/sdk/php/tests/Unit/ExecuteScriptTest.php similarity index 100% rename from code/php/sdk/tests/Unit/ExecuteScriptTest.php rename to sdk/php/tests/Unit/ExecuteScriptTest.php diff --git a/code/php/sdk/tests/Unit/MergeFragmentsTest.php b/sdk/php/tests/Unit/MergeFragmentsTest.php similarity index 100% rename from code/php/sdk/tests/Unit/MergeFragmentsTest.php rename to sdk/php/tests/Unit/MergeFragmentsTest.php diff --git a/code/php/sdk/tests/Unit/MergeSignalsTest.php b/sdk/php/tests/Unit/MergeSignalsTest.php similarity index 100% rename from code/php/sdk/tests/Unit/MergeSignalsTest.php rename to sdk/php/tests/Unit/MergeSignalsTest.php diff --git a/code/php/sdk/tests/Unit/RemoveFragmentsTest.php b/sdk/php/tests/Unit/RemoveFragmentsTest.php similarity index 100% rename from code/php/sdk/tests/Unit/RemoveFragmentsTest.php rename to sdk/php/tests/Unit/RemoveFragmentsTest.php diff --git a/code/php/sdk/tests/Unit/RemoveSignalsTest.php b/sdk/php/tests/Unit/RemoveSignalsTest.php similarity index 100% rename from code/php/sdk/tests/Unit/RemoveSignalsTest.php rename to sdk/php/tests/Unit/RemoveSignalsTest.php diff --git a/code/go/site/.gitignore b/site/.gitignore similarity index 100% rename from code/go/site/.gitignore rename to site/.gitignore diff --git a/code/go/cmd/asciiconvertor/main.go b/site/cmd/asciiconvertor/main.go similarity index 96% rename from code/go/cmd/asciiconvertor/main.go rename to site/cmd/asciiconvertor/main.go index 32c4049fe..9466b92e3 100644 --- a/code/go/cmd/asciiconvertor/main.go +++ b/site/cmd/asciiconvertor/main.go @@ -8,7 +8,7 @@ import ( "os" "github.com/klauspost/compress/zstd" - "github.com/starfederation/datastar/code/go/site" + "github.com/starfederation/datastar/site" "github.com/valyala/bytebufferpool" ) diff --git a/code/go/cmd/site/.gitignore b/site/cmd/site/.gitignore similarity index 100% rename from code/go/cmd/site/.gitignore rename to site/cmd/site/.gitignore diff --git a/code/go/cmd/site/main.go b/site/cmd/site/main.go similarity index 94% rename from code/go/cmd/site/main.go rename to site/cmd/site/main.go index 69d14993a..2141d03e7 100644 --- a/code/go/cmd/site/main.go +++ b/site/cmd/site/main.go @@ -11,7 +11,7 @@ import ( "github.com/delaneyj/toolbelt" "github.com/joho/godotenv" - "github.com/starfederation/datastar/code/go/site" + "github.com/starfederation/datastar/site" ) const port = 8080 diff --git a/code/go/site/postcss.config.js b/site/postcss.config.js similarity index 100% rename from code/go/site/postcss.config.js rename to site/postcss.config.js diff --git a/code/go/site/router.go b/site/router.go similarity index 92% rename from code/go/site/router.go rename to site/router.go index 1f1b8fdb7..cf8082ed0 100644 --- a/code/go/site/router.go +++ b/site/router.go @@ -34,6 +34,10 @@ func staticAbsolutePath(path string) string { return "https://data-star.dev/" + staticSys.HashName("static/"+path) } +func canonicalUrl(uri string) string { + return "https://data-star.dev" + uri +} + func RunBlocking(port int, readyCh chan struct{}) toolbelt.CtxErrFunc { return func(ctx context.Context) error { @@ -74,6 +78,7 @@ func setupRoutes(ctx context.Context, router chi.Router) (cleanup func() error, ns, err := embeddednats.New(ctx, embeddednats.WithNATSServerOptions(&natsserver.Options{ JetStream: true, + StoreDir: "./data/nats", Port: natsPort, })) if err != nil { @@ -94,8 +99,9 @@ func setupRoutes(ctx context.Context, router chi.Router) (cleanup func() error, setupHome(router, sessionSignals, ns), setupGuide(ctx, router), setupReferenceRoutes(ctx, router), - setupExamples(ctx, router, sessionSignals, ns), + setupExamples(ctx, router, sessionSignals), setupEssays(ctx, router), + setupErrors(ctx, router), setupMemes(router), setupBundler(router), ); err != nil { diff --git a/code/go/site/routes_bundler.go b/site/routes_bundler.go similarity index 99% rename from code/go/site/routes_bundler.go rename to site/routes_bundler.go index 07fe9845d..fe9f2e1ba 100644 --- a/code/go/site/routes_bundler.go +++ b/site/routes_bundler.go @@ -19,7 +19,7 @@ import ( "github.com/evanw/esbuild/pkg/api" "github.com/go-chi/chi/v5" "github.com/segmentio/encoding/json" - datastar "github.com/starfederation/datastar/code/go/sdk" + datastar "github.com/starfederation/datastar/sdk/go" "github.com/valyala/bytebufferpool" "github.com/zeebo/xxh3" ) diff --git a/code/go/site/routes_bundler.qtpl b/site/routes_bundler.qtpl similarity index 100% rename from code/go/site/routes_bundler.qtpl rename to site/routes_bundler.qtpl diff --git a/site/routes_bundler.qtpl.go b/site/routes_bundler.qtpl.go new file mode 100644 index 000000000..d0d0da4c4 --- /dev/null +++ b/site/routes_bundler.qtpl.go @@ -0,0 +1,87 @@ +// Code generated by qtc from "routes_bundler.qtpl". DO NOT EDIT. +// See https://github.com/valyala/quicktemplate for details. + +//line site/routes_bundler.qtpl:1 +package site + +//line site/routes_bundler.qtpl:1 +import ( + qtio422016 "io" + + qt422016 "github.com/valyala/quicktemplate" +) + +//line site/routes_bundler.qtpl:1 +var ( + _ = qtio422016.Copy + _ = qt422016.AcquireByteBuffer +) + +//line site/routes_bundler.qtpl:1 +func streambundlerContent(qw422016 *qt422016.Writer, manifest PluginManifest) { +//line site/routes_bundler.qtpl:1 + qw422016.N().S(` + +import { Datastar } from "../engine"; +`) +//line site/routes_bundler.qtpl:4 + for _, p := range manifest.Plugins { +//line site/routes_bundler.qtpl:4 + qw422016.N().S(`import { `) +//line site/routes_bundler.qtpl:5 + qw422016.E().S(p.Name) +//line site/routes_bundler.qtpl:5 + qw422016.N().S(` } from "`) +//line site/routes_bundler.qtpl:5 + qw422016.E().S(p.Path) +//line site/routes_bundler.qtpl:5 + qw422016.N().S(`"; +`) +//line site/routes_bundler.qtpl:6 + } +//line site/routes_bundler.qtpl:6 + qw422016.N().S(` +Datastar.load( +`) +//line site/routes_bundler.qtpl:9 + for _, p := range manifest.Plugins { +//line site/routes_bundler.qtpl:9 + qw422016.N().S(` `) +//line site/routes_bundler.qtpl:10 + qw422016.E().S(p.Name) +//line site/routes_bundler.qtpl:10 + qw422016.N().S(`, +`) +//line site/routes_bundler.qtpl:11 + } +//line site/routes_bundler.qtpl:11 + qw422016.N().S(`); +`) +//line site/routes_bundler.qtpl:13 +} + +//line site/routes_bundler.qtpl:13 +func writebundlerContent(qq422016 qtio422016.Writer, manifest PluginManifest) { +//line site/routes_bundler.qtpl:13 + qw422016 := qt422016.AcquireWriter(qq422016) +//line site/routes_bundler.qtpl:13 + streambundlerContent(qw422016, manifest) +//line site/routes_bundler.qtpl:13 + qt422016.ReleaseWriter(qw422016) +//line site/routes_bundler.qtpl:13 +} + +//line site/routes_bundler.qtpl:13 +func bundlerContent(manifest PluginManifest) string { +//line site/routes_bundler.qtpl:13 + qb422016 := qt422016.AcquireByteBuffer() +//line site/routes_bundler.qtpl:13 + writebundlerContent(qb422016, manifest) +//line site/routes_bundler.qtpl:13 + qs422016 := string(qb422016.B) +//line site/routes_bundler.qtpl:13 + qt422016.ReleaseByteBuffer(qb422016) +//line site/routes_bundler.qtpl:13 + return qs422016 +//line site/routes_bundler.qtpl:13 +} diff --git a/code/go/site/routes_bundler.templ b/site/routes_bundler.templ similarity index 85% rename from code/go/site/routes_bundler.templ rename to site/routes_bundler.templ index dc7b65432..cb9d8d40e 100644 --- a/code/go/site/routes_bundler.templ +++ b/site/routes_bundler.templ @@ -3,16 +3,16 @@ package site import ( "fmt" "github.com/dustin/go-humanize" - datastar "github.com/starfederation/datastar/code/go/sdk" + datastar "github.com/starfederation/datastar/sdk/go" "net/http" "path/filepath" "strings" ) templ PageBundler(r *http.Request, manifest PluginManifest, signals *BundlerSignals) { - @Page("Bundler", "Bundle only the plugins you need to reduce the size of Datastar even further.") { + @Page("Bundler", "Bundle only the plugins you need to reduce the size of Datastar even further.", "/bundler") { @header(r) -
+
Bundler
While Datastar is still one of the smallest frameworks available, you can bundle only the plugins you need to reduce the size even further.
@@ -26,9 +26,9 @@ templ PageBundler(r *http.Request, manifest PluginManifest, signals *BundlerSign
Plugins
- - - + + +
@@ -39,7 +39,6 @@ templ PageBundler(r *http.Request, manifest PluginManifest, signals *BundlerSign {{ signal := fmt.Sprintf("includedPlugins.%s", plugin.Key) pluginDir := filepath.Dir(plugin.Path[11:]) - // cls := fmt.Sprintf("{'bg-base-200':!$%s, 'bg-success text-success-content': $%s}", signal, signal) }} if currentPath != pluginDir {
{ pluginDir }
diff --git a/site/routes_errors.go b/site/routes_errors.go new file mode 100644 index 000000000..0ae1096da --- /dev/null +++ b/site/routes_errors.go @@ -0,0 +1,96 @@ +package site + +import ( + "context" + "net/http" + "net/url" + "slices" + "strings" + + "github.com/a-h/templ" + "github.com/go-chi/chi/v5" + "github.com/samber/lo" +) + +func setupErrors(ctx context.Context, router chi.Router) error { + + mdDataset, err := markdownRenders(ctx, "errors") + if err != nil { + return err + } + + sidebarLinks := make([]*SidebarLink, 0, len(mdDataset)) + for id := range mdDataset { + sidebarLinks = append(sidebarLinks, &SidebarLink{ID: id}) + } + slices.SortFunc(sidebarLinks, func(a, b *SidebarLink) int { + return strings.Compare(a.ID, b.ID) + }) + + sidebarGroups := []*SidebarGroup{ + { + Label: "Errors", + Links: sidebarLinks, + }, + } + lo.ForEach(sidebarGroups, func(group *SidebarGroup, grpIdx int) { + lo.ForEach(group.Links, func(link *SidebarLink, linkIdx int) { + link.URL = templ.SafeURL("/errors/" + link.ID) + link.Label = link.ID + + if linkIdx > 0 { + link.Prev = group.Links[linkIdx-1] + } else if grpIdx > 0 { + prvGrp := sidebarGroups[grpIdx-1] + link.Prev = prvGrp.Links[len(prvGrp.Links)-1] + } + + if linkIdx < len(group.Links)-1 { + link.Next = group.Links[linkIdx+1] + } else if grpIdx < len(sidebarGroups)-1 { + nxtGrp := sidebarGroups[grpIdx+1] + link.Next = nxtGrp.Links[0] + } + }) + }) + + router.Route("/errors", func(errorsRouter chi.Router) { + errorsRouter.Get("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, string(sidebarGroups[0].Links[0].URL), http.StatusFound) + }) + + errorsRouter.Get("/{id}", func(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + mdData, ok := mdDataset[id] + if !ok { + http.Error(w, "not found", http.StatusNotFound) + return + } + + var currentLink *SidebarLink + for _, group := range sidebarGroups { + for _, link := range group.Links { + if link.ID == id { + currentLink = link + break + } + } + } + + params, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + contents := mdData.Contents + for key, values := range params { + contents = strings.ReplaceAll(contents, "{ "+key+" }", strings.Join(values, ",")) + } + + SidebarPage(r, sidebarGroups, currentLink, mdData.Title, mdData.Description, contents).Render(r.Context(), w) + }) + }) + + return nil +} diff --git a/code/go/site/routes_essays.go b/site/routes_essays.go similarity index 100% rename from code/go/site/routes_essays.go rename to site/routes_essays.go diff --git a/code/go/site/routes_examples.go b/site/routes_examples.go similarity index 94% rename from code/go/site/routes_examples.go rename to site/routes_examples.go index 25ad4f130..445afadd6 100644 --- a/code/go/site/routes_examples.go +++ b/site/routes_examples.go @@ -8,7 +8,6 @@ import ( "strings" "github.com/a-h/templ" - "github.com/delaneyj/toolbelt/embeddednats" "github.com/go-chi/chi/v5" "github.com/go-sanitize/sanitize" "github.com/gorilla/sessions" @@ -19,7 +18,7 @@ var ( sanitizer *sanitize.Sanitizer ) -func setupExamples(ctx context.Context, router chi.Router, signals sessions.Store, ns *embeddednats.Server) (err error) { +func setupExamples(ctx context.Context, router chi.Router, signals sessions.Store) (err error) { mdDataset, err := markdownRenders(ctx, "examples") if err != nil { return err @@ -83,7 +82,6 @@ func setupExamples(ctx context.Context, router chi.Router, signals sessions.Stor {ID: "img_src_bind"}, {ID: "dbmon"}, {ID: "bad_apple"}, - {ID: "mouse_move"}, {ID: "web_component"}, {ID: "persist"}, {ID: "execute_script"}, @@ -92,7 +90,6 @@ func setupExamples(ctx context.Context, router chi.Router, signals sessions.Stor {ID: "replace_url_from_signals"}, {ID: "prefetch"}, {ID: "debounce_and_throttle"}, - // {ID: "snake"}, }, }, { @@ -180,15 +177,10 @@ func setupExamples(ctx context.Context, router chi.Router, signals sessions.Stor setupExamplesOfflineSync(examplesRouter, signals), setupExamplesDbmon(examplesRouter), setupExamplesBadApple(examplesRouter), - setupExamplesMousemove(ctx, examplesRouter, ns), setupExamplesExecuteScript(examplesRouter), setupExamplesDispatchCustomEvent(examplesRouter), setupExamplesReplaceURL(examplesRouter), setupExamplesPrefetch(examplesRouter), - // setupExamplesSnake(examplesRouter), - // - // setupExamplesShoelaceKitchensink(examplesRouter), - // setupExamplesSignalsIfMissing(examplesRouter), setupExamplesViewTransitionAPI(examplesRouter), setupExamplesModelBinding(examplesRouter), diff --git a/code/go/site/routes_examples_active_search.go b/site/routes_examples_active_search.go similarity index 96% rename from code/go/site/routes_examples_active_search.go rename to site/routes_examples_active_search.go index 227defce3..9569b31d5 100644 --- a/code/go/site/routes_examples_active_search.go +++ b/site/routes_examples_active_search.go @@ -10,7 +10,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-faker/faker/v4" "github.com/lithammer/fuzzysearch/fuzzy" - datastar "github.com/starfederation/datastar/code/go/sdk" + datastar "github.com/starfederation/datastar/sdk/go" ) func setupExamplesActiveSearch(examplesRouter chi.Router) error { diff --git a/code/go/site/routes_examples_active_search.templ b/site/routes_examples_active_search.templ similarity index 85% rename from code/go/site/routes_examples_active_search.templ rename to site/routes_examples_active_search.templ index 0fa79995f..3009ed437 100644 --- a/code/go/site/routes_examples_active_search.templ +++ b/site/routes_examples_active_search.templ @@ -1,6 +1,9 @@ package site -import "fmt" +import ( + "fmt" + datastar "github.com/starfederation/datastar/sdk/go" +) type ActiveSearchSignals struct { Search string `json:"search"` @@ -17,7 +20,7 @@ templ ActiveSearchComponent(filteredUsers []*ActiveSearchUser, scores map[string