parent
6755978c03
commit
ead925409c
@ -0,0 +1,42 @@ |
|||||||
|
# dependencies (bun install) |
||||||
|
node_modules |
||||||
|
|
||||||
|
# output |
||||||
|
out |
||||||
|
dist |
||||||
|
*.tgz |
||||||
|
|
||||||
|
# code coverage |
||||||
|
coverage |
||||||
|
*.lcov |
||||||
|
|
||||||
|
# logs |
||||||
|
logs |
||||||
|
_.log |
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json |
||||||
|
|
||||||
|
# dotenv environment variable files |
||||||
|
.env |
||||||
|
.env.development.local |
||||||
|
.env.test.local |
||||||
|
.env.production.local |
||||||
|
.env.local |
||||||
|
|
||||||
|
# caches |
||||||
|
.eslintcache |
||||||
|
.cache |
||||||
|
*.tsbuildinfo |
||||||
|
|
||||||
|
# IntelliJ based IDEs |
||||||
|
.idea |
||||||
|
|
||||||
|
# Finder (MacOS) folder config |
||||||
|
.DS_Store |
||||||
|
|
||||||
|
.astro |
||||||
|
|
||||||
|
# Playwright |
||||||
|
/test-results/ |
||||||
|
/playwright-report/ |
||||||
|
/blob-report/ |
||||||
|
/playwright/.cache/ |
@ -0,0 +1,4 @@ |
|||||||
|
# Multi-Framework Signal Proxies |
||||||
|
|
||||||
|
Thanks for providing the basic multi-framework template: |
||||||
|
https://github.com/aleksadencic/multi-framework-app |
@ -0,0 +1,11 @@ |
|||||||
|
import { defineConfig } from "astro/config"; |
||||||
|
|
||||||
|
import react from "@astrojs/react"; |
||||||
|
import vue from "@astrojs/vue"; |
||||||
|
import svelte from "@astrojs/svelte"; |
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({ |
||||||
|
integrations: [react(), vue(), svelte()], |
||||||
|
srcDir: "./src/app", |
||||||
|
}); |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,48 @@ |
|||||||
|
{ |
||||||
|
"name": "multi-framework-signals", |
||||||
|
"version": "0.1.0", |
||||||
|
"private": true, |
||||||
|
"type": "module", |
||||||
|
"scripts": { |
||||||
|
"dev": "astro dev", |
||||||
|
"start": "astro dev", |
||||||
|
"build": "astro build", |
||||||
|
"preview": "astro preview", |
||||||
|
"astro": "astro", |
||||||
|
"test": "vitest", |
||||||
|
"test:e2e": "playwright test" |
||||||
|
}, |
||||||
|
"dependencies": { |
||||||
|
"ng-signals": "0.1.0", |
||||||
|
"@astrojs/react": "4.3.0", |
||||||
|
"@astrojs/svelte": "7.1.0", |
||||||
|
"@astrojs/vue": "^5.1.0", |
||||||
|
"@gn8/alien-signals-react": "^0.1.1", |
||||||
|
"@gn8/alien-signals-solid": "^0.1.1", |
||||||
|
"@gn8/alien-signals-svelte": "^0.1.1", |
||||||
|
"@gn8/alien-signals-vue": "^0.1.1", |
||||||
|
"@ldo/ldo": "^1.0.0-alpha.32", |
||||||
|
"@types/react": "19.1.10", |
||||||
|
"@types/react-dom": "19.1.7", |
||||||
|
"@types/shexj": "^2.1.7", |
||||||
|
"ng-alien-deepsignals": "^0.1.0", |
||||||
|
"alien-signals": "^2.0.7", |
||||||
|
"astro": "5.13.2", |
||||||
|
"install": "^0.13.0", |
||||||
|
"npm": "^11.5.2", |
||||||
|
"prettier-eslint": "^16.4.2", |
||||||
|
"react": "19.1.1", |
||||||
|
"react-dom": "19.1.1", |
||||||
|
"svelte": "5.38.2", |
||||||
|
"vue": "3.5.19" |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"@ldo/traverser-shexj": "^1.0.0-alpha.28", |
||||||
|
"@playwright/test": "^1.55.0", |
||||||
|
"@types/node": "24.3.0", |
||||||
|
"@types/react": "19.1.10", |
||||||
|
"@types/react-dom": "19.1.7", |
||||||
|
"vite": "7.1.3", |
||||||
|
"vitest": "^3.2.4" |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,79 @@ |
|||||||
|
import { defineConfig, devices } from "@playwright/test"; |
||||||
|
|
||||||
|
/** |
||||||
|
* Read environment variables from file. |
||||||
|
* https://github.com/motdotla/dotenv
|
||||||
|
*/ |
||||||
|
// import dotenv from 'dotenv';
|
||||||
|
// import path from 'path';
|
||||||
|
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||||
|
|
||||||
|
/** |
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/ |
||||||
|
export default defineConfig({ |
||||||
|
testDir: "./src/frontends/tests", |
||||||
|
/* Run tests in files in parallel */ |
||||||
|
fullyParallel: true, |
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */ |
||||||
|
forbidOnly: !!process.env.CI, |
||||||
|
/* Retry on CI only */ |
||||||
|
retries: process.env.CI ? 2 : 0, |
||||||
|
/* Opt out of parallel tests on CI. */ |
||||||
|
workers: process.env.CI ? 1 : undefined, |
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ |
||||||
|
reporter: "html", |
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ |
||||||
|
use: { |
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */ |
||||||
|
baseURL: "http://localhost:4321", |
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ |
||||||
|
trace: "on-first-retry", |
||||||
|
}, |
||||||
|
|
||||||
|
/* Configure projects for major browsers */ |
||||||
|
projects: [ |
||||||
|
// {
|
||||||
|
// name: "chromium",
|
||||||
|
// use: { ...devices["Desktop Chrome"] },
|
||||||
|
// },
|
||||||
|
|
||||||
|
{ |
||||||
|
name: "firefox", |
||||||
|
use: { ...devices["Desktop Firefox"] }, |
||||||
|
}, |
||||||
|
|
||||||
|
{ |
||||||
|
name: "webkit", |
||||||
|
use: { ...devices["Desktop Safari"] }, |
||||||
|
}, |
||||||
|
|
||||||
|
/* Test against mobile viewports. */ |
||||||
|
// {
|
||||||
|
// name: 'Mobile Chrome',
|
||||||
|
// use: { ...devices['Pixel 5'] },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Safari',
|
||||||
|
// use: { ...devices['iPhone 12'] },
|
||||||
|
// },
|
||||||
|
|
||||||
|
/* Test against branded browsers. */ |
||||||
|
// {
|
||||||
|
// name: 'Microsoft Edge',
|
||||||
|
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Google Chrome',
|
||||||
|
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||||
|
// },
|
||||||
|
], |
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */ |
||||||
|
webServer: { |
||||||
|
command: "npm run dev", |
||||||
|
url: "http://localhost:4321", |
||||||
|
reuseExistingServer: !process.env.CI, |
||||||
|
}, |
||||||
|
}); |
@ -0,0 +1,43 @@ |
|||||||
|
--- |
||||||
|
const { react, svelte, vue, astro } = Astro.props; |
||||||
|
|
||||||
|
const frameworkName = Object.keys(Astro.props)[0]; |
||||||
|
--- |
||||||
|
|
||||||
|
<div class:list={["box", { react, svelte, vue, astro }]}> |
||||||
|
<p class="title">{frameworkName}</p> |
||||||
|
<div class="wrap"> |
||||||
|
<slot /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.box { |
||||||
|
border: 2px solid rgb(88, 88, 81); |
||||||
|
border-radius: 1em; |
||||||
|
|
||||||
|
padding: 1em; |
||||||
|
margin-top: 1em; |
||||||
|
|
||||||
|
margin-left: auto; |
||||||
|
margin-right: auto; |
||||||
|
|
||||||
|
min-width: 60%; |
||||||
|
} |
||||||
|
.wrap { |
||||||
|
padding: 10px; |
||||||
|
} |
||||||
|
.title { |
||||||
|
background: var(--highlightColor); |
||||||
|
padding: 0 10px; |
||||||
|
} |
||||||
|
.vue { |
||||||
|
background-color: rgb(255, 212, 255); |
||||||
|
} |
||||||
|
.react { |
||||||
|
background-color: rgb(189, 216, 255); |
||||||
|
} |
||||||
|
.svelte { |
||||||
|
background-color: rgb(255, 216, 189); |
||||||
|
} |
||||||
|
</style> |
@ -0,0 +1,7 @@ |
|||||||
|
import { HelloWorldReact } from "src/frontends/react/HelloWorld"; |
||||||
|
|
||||||
|
const Root = () => { |
||||||
|
return <HelloWorldReact />; |
||||||
|
}; |
||||||
|
|
||||||
|
export default Root; |
@ -0,0 +1,5 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import HelloWorld from "src/frontends/svelte/HelloWorld.svelte"; |
||||||
|
</script> |
||||||
|
|
||||||
|
<HelloWorld /> |
@ -0,0 +1,9 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import HelloWorld from 'src/frontends/vue/HelloWorld.vue'; |
||||||
|
|
||||||
|
// |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<HelloWorld></HelloWorld> |
||||||
|
</template> |
@ -0,0 +1,21 @@ |
|||||||
|
--- |
||||||
|
import Highlight from "../components/Highlight.astro"; |
||||||
|
--- |
||||||
|
|
||||||
|
<!doctype html> |
||||||
|
<html lang="en" data-theme="dark"> |
||||||
|
<head> |
||||||
|
<meta name="viewport" content="width=device-width" /> |
||||||
|
<meta name="generator" content={Astro.generator} /> |
||||||
|
<title>Multi-Framework Signal Experiments</title> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<Highlight astro> |
||||||
|
<header class="container">Multi-Framework Signal Experiments</header> |
||||||
|
|
||||||
|
<main class="container"> |
||||||
|
<slot /> |
||||||
|
</main> |
||||||
|
</Highlight> |
||||||
|
</body> |
||||||
|
</html> |
@ -0,0 +1,24 @@ |
|||||||
|
--- |
||||||
|
import Layout from "../layouts/Layout.astro"; |
||||||
|
import Highlight from "../components/Highlight.astro"; |
||||||
|
|
||||||
|
import VueRoot from "../components/VueRoot.vue"; |
||||||
|
import ReactRoot from "../components/ReactRoot"; |
||||||
|
import SvelteRoot from "../components/SvelteRoot.svelte"; |
||||||
|
|
||||||
|
const title = "Multi-framework app"; |
||||||
|
--- |
||||||
|
|
||||||
|
<Layout title={title}> |
||||||
|
<Highlight vue> |
||||||
|
<VueRoot client:only /> |
||||||
|
</Highlight> |
||||||
|
|
||||||
|
<Highlight react> |
||||||
|
<ReactRoot client:only="react" /> |
||||||
|
</Highlight> |
||||||
|
|
||||||
|
<Highlight svelte> |
||||||
|
<SvelteRoot client:only /> |
||||||
|
</Highlight> |
||||||
|
</Layout> |
@ -0,0 +1,3 @@ |
|||||||
|
import { atom } from "nanostores"; |
||||||
|
|
||||||
|
export const selectedFilters = atom<string[]>([]); |
@ -0,0 +1,152 @@ |
|||||||
|
import React from "react"; |
||||||
|
import useShape from "ng-signals/frontendAdapters/react/useShape"; |
||||||
|
import flattenObject from "../utils/flattenObject"; |
||||||
|
import { TestObjectShapeType } from "src/shapes/ldo/testShape.shapeTypes"; |
||||||
|
|
||||||
|
export function HelloWorldReact() { |
||||||
|
const state = useShape(TestObjectShapeType); |
||||||
|
|
||||||
|
// @ts-expect-error
|
||||||
|
window.reactState = state; |
||||||
|
|
||||||
|
if (!state) return <>Loading state</>; |
||||||
|
|
||||||
|
// Create a table from the state object: One column for keys, one for values, one with an input to change the value.
|
||||||
|
|
||||||
|
return ( |
||||||
|
<div> |
||||||
|
<p>Rendered in React</p> |
||||||
|
|
||||||
|
<button |
||||||
|
onClick={() => { |
||||||
|
state.boolValue = !state.boolValue; |
||||||
|
state.numValue += 2; |
||||||
|
}} |
||||||
|
> |
||||||
|
click me to change multiple props |
||||||
|
</button> |
||||||
|
|
||||||
|
<table border={1} cellPadding={5}> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Key</th> |
||||||
|
<th>Value</th> |
||||||
|
<th>Edit</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{(() => { |
||||||
|
const setNestedValue = (obj: any, path: string, value: any) => { |
||||||
|
const keys = path.split("."); |
||||||
|
let current = obj; |
||||||
|
|
||||||
|
for (let i = 0; i < keys.length - 1; i++) { |
||||||
|
current = current[keys[i]]; |
||||||
|
} |
||||||
|
|
||||||
|
current[keys[keys.length - 1]] = value; |
||||||
|
}; |
||||||
|
|
||||||
|
const getNestedValue = (obj: any, path: string) => { |
||||||
|
return path |
||||||
|
.split(".") |
||||||
|
.reduce((current, key) => current[key], obj); |
||||||
|
}; |
||||||
|
|
||||||
|
return flattenObject(state).map(([key, value]) => ( |
||||||
|
<tr key={key}> |
||||||
|
<td>{key}</td> |
||||||
|
<td> |
||||||
|
{value instanceof Set |
||||||
|
? Array.from(value).join(", ") |
||||||
|
: Array.isArray(value) |
||||||
|
? `[${value.join(", ")}]` |
||||||
|
: JSON.stringify(value)} |
||||||
|
</td> |
||||||
|
<td> |
||||||
|
{typeof value === "string" ? ( |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
value={value} |
||||||
|
onChange={(e) => { |
||||||
|
setNestedValue(state, key, e.target.value); |
||||||
|
}} |
||||||
|
/> |
||||||
|
) : typeof value === "number" ? ( |
||||||
|
<input |
||||||
|
type="number" |
||||||
|
value={value} |
||||||
|
onChange={(e) => { |
||||||
|
setNestedValue(state, key, Number(e.target.value)); |
||||||
|
}} |
||||||
|
/> |
||||||
|
) : typeof value === "boolean" ? ( |
||||||
|
<input |
||||||
|
type="checkbox" |
||||||
|
checked={value} |
||||||
|
onChange={(e) => { |
||||||
|
setNestedValue(state, key, e.target.checked); |
||||||
|
}} |
||||||
|
/> |
||||||
|
) : Array.isArray(value) ? ( |
||||||
|
<div> |
||||||
|
<button |
||||||
|
onClick={() => { |
||||||
|
const currentArray = getNestedValue(state, key); |
||||||
|
setNestedValue(state, key, [ |
||||||
|
...currentArray, |
||||||
|
currentArray.length + 1, |
||||||
|
]); |
||||||
|
}} |
||||||
|
> |
||||||
|
Add |
||||||
|
</button> |
||||||
|
<button |
||||||
|
onClick={() => { |
||||||
|
const currentArray = getNestedValue(state, key); |
||||||
|
if (currentArray.length > 0) { |
||||||
|
setNestedValue( |
||||||
|
state, |
||||||
|
key, |
||||||
|
currentArray.slice(0, -1) |
||||||
|
); |
||||||
|
} |
||||||
|
}} |
||||||
|
> |
||||||
|
Remove |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
) : value instanceof Set ? ( |
||||||
|
<div> |
||||||
|
<button |
||||||
|
onClick={() => { |
||||||
|
const currentSet = getNestedValue(state, key); |
||||||
|
currentSet.add(`item${currentSet.size + 1}`); |
||||||
|
}} |
||||||
|
> |
||||||
|
Add |
||||||
|
</button> |
||||||
|
<button |
||||||
|
onClick={() => { |
||||||
|
const currentSet = getNestedValue(state, key); |
||||||
|
const lastItem = Array.from(currentSet).pop(); |
||||||
|
if (lastItem) { |
||||||
|
currentSet.delete(lastItem); |
||||||
|
} |
||||||
|
}} |
||||||
|
> |
||||||
|
Remove |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
) : ( |
||||||
|
"N/A" |
||||||
|
)} |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
)); |
||||||
|
})()} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,122 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { TestObjectShapeType } from "src/shapes/ldo/testShape.shapeTypes"; |
||||||
|
import useShape from "ng-signals/frontendAdapters/svelte/useShape.svelte"; |
||||||
|
import flattenObject from "../utils/flattenObject"; |
||||||
|
|
||||||
|
const shapeObject = useShape(TestObjectShapeType); |
||||||
|
|
||||||
|
function getNestedValue(obj: any, path: string) { |
||||||
|
return path |
||||||
|
.split(".") |
||||||
|
.reduce((cur, k) => (cur == null ? cur : cur[k]), obj); |
||||||
|
} |
||||||
|
function setNestedValue(obj: any, path: string, value: any) { |
||||||
|
const keys = path.split("."); |
||||||
|
let cur = obj; |
||||||
|
for (let i = 0; i < keys.length - 1; i++) { |
||||||
|
cur = cur[keys[i]]; |
||||||
|
if (cur == null) return; |
||||||
|
} |
||||||
|
cur[keys[keys.length - 1]] = value; |
||||||
|
} |
||||||
|
const flatEntries = $derived( |
||||||
|
$shapeObject ? flattenObject($shapeObject as any) : [] |
||||||
|
); |
||||||
|
$effect(() => { |
||||||
|
(window as any).svelteState = $shapeObject; |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if $shapeObject} |
||||||
|
<div> |
||||||
|
<p>Rendered in Svelte</p> |
||||||
|
<table border="1" cellpadding="5"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Key</th> |
||||||
|
<th>Value</th> |
||||||
|
<th>Edit</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{#each flatEntries as [key, value] (key)} |
||||||
|
<tr> |
||||||
|
<td style="white-space:nowrap;">{key}</td> |
||||||
|
<td> |
||||||
|
{#if value instanceof Set} |
||||||
|
{Array.from(value).join(", ")} |
||||||
|
{:else if Array.isArray(value)} |
||||||
|
[{value.join(", ")}] |
||||||
|
{:else} |
||||||
|
{JSON.stringify(value)} |
||||||
|
{/if} |
||||||
|
</td> |
||||||
|
<td> |
||||||
|
{#if typeof value === "string"} |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
{value} |
||||||
|
oninput={(e: any) => |
||||||
|
setNestedValue($shapeObject, key, e.target.value)} |
||||||
|
/> |
||||||
|
{:else if typeof value === "number"} |
||||||
|
<input |
||||||
|
type="number" |
||||||
|
{value} |
||||||
|
oninput={(e: any) => |
||||||
|
setNestedValue($shapeObject, key, Number(e.target.value))} |
||||||
|
/> |
||||||
|
{:else if typeof value === "boolean"} |
||||||
|
<input |
||||||
|
type="checkbox" |
||||||
|
checked={value} |
||||||
|
onchange={(e: any) => |
||||||
|
setNestedValue($shapeObject, key, e.target.checked)} |
||||||
|
/> |
||||||
|
{:else if Array.isArray(value)} |
||||||
|
<div style="display:flex; gap:.5rem;"> |
||||||
|
<button |
||||||
|
onclick={() => { |
||||||
|
const cur = getNestedValue($shapeObject, key) || []; |
||||||
|
setNestedValue($shapeObject, key, [ |
||||||
|
...cur, |
||||||
|
cur.length + 1, |
||||||
|
]); |
||||||
|
}}>Add</button |
||||||
|
> |
||||||
|
<button |
||||||
|
onclick={() => { |
||||||
|
const cur = getNestedValue($shapeObject, key) || []; |
||||||
|
if (cur.length) |
||||||
|
setNestedValue($shapeObject, key, cur.slice(0, -1)); |
||||||
|
}}>Remove</button |
||||||
|
> |
||||||
|
</div> |
||||||
|
{:else if value instanceof Set} |
||||||
|
<div style="display:flex; gap:.5rem;"> |
||||||
|
<button |
||||||
|
onclick={() => { |
||||||
|
const cur: Set<any> = getNestedValue($shapeObject, key); |
||||||
|
cur.add(`item${cur.size + 1}`); |
||||||
|
}}>Add</button |
||||||
|
> |
||||||
|
<button |
||||||
|
onclick={() => { |
||||||
|
const cur: Set<any> = getNestedValue($shapeObject, key); |
||||||
|
const last = Array.from(cur).pop(); |
||||||
|
if (last !== undefined) cur.delete(last); |
||||||
|
}}>Remove</button |
||||||
|
> |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
N/A |
||||||
|
{/if} |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
{/each} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<p>Loading state</p> |
||||||
|
{/if} |
@ -0,0 +1,177 @@ |
|||||||
|
import { test, expect } from "@playwright/test"; |
||||||
|
|
||||||
|
const mockTestObject = { |
||||||
|
type: "TestObject", |
||||||
|
stringValue: "string", |
||||||
|
numValue: 42, |
||||||
|
boolValue: true, |
||||||
|
nullValue: null, |
||||||
|
arrayValue: [1, 2, 3], |
||||||
|
objectValue: { |
||||||
|
nestedString: "nested", |
||||||
|
nestedNum: 7, |
||||||
|
nestedArray: [10, 12], |
||||||
|
}, |
||||||
|
setValue: new Set(["v1", "v2", "v3"]), |
||||||
|
}; |
||||||
|
|
||||||
|
test("components load", async ({ page }) => { |
||||||
|
await page.goto("/"); |
||||||
|
await page.waitForSelector(".vue astro-island"); |
||||||
|
|
||||||
|
await expect(page.locator(".vue .title")).toHaveText("vue"); |
||||||
|
await expect(page.locator(".react .title")).toHaveText("react"); |
||||||
|
await expect(page.locator(".svelte .title")).toHaveText("svelte"); |
||||||
|
}); |
||||||
|
|
||||||
|
// TODO: Test without signal pooling.
|
||||||
|
test.describe("cross framework propagation", () => { |
||||||
|
const frameworks = ["vue", "react", "svelte"] as const; |
||||||
|
|
||||||
|
const isPlainObject = (v: unknown): v is Record<string, unknown> => |
||||||
|
typeof v === "object" && |
||||||
|
v !== null && |
||||||
|
!Array.isArray(v) && |
||||||
|
!(v instanceof Set); |
||||||
|
|
||||||
|
function changedValue(original: unknown) { |
||||||
|
if (typeof original === "string") return original + "_changed"; |
||||||
|
if (typeof original === "number") return original + 10; |
||||||
|
if (typeof original === "boolean") return !original; |
||||||
|
if (Array.isArray(original)) return [...original, {}, {}]; |
||||||
|
if (original instanceof Set) new Set(original).add("_changed"); |
||||||
|
|
||||||
|
return original; |
||||||
|
} |
||||||
|
|
||||||
|
async function mutateCell( |
||||||
|
row: ReturnType<(typeof test)["info"] extends any ? any : never>, |
||||||
|
original: unknown |
||||||
|
) { |
||||||
|
if (typeof original === "string") { |
||||||
|
const input = row.locator("input[type='text']"); |
||||||
|
await input.fill(String(changedValue(original))); |
||||||
|
await input.blur(); |
||||||
|
} else if (typeof original === "number") { |
||||||
|
const input = row.locator("input[type='number']"); |
||||||
|
await input.fill(String(changedValue(original))); |
||||||
|
await input.blur(); |
||||||
|
} else if (typeof original === "boolean") { |
||||||
|
const input = row.locator("input[type='checkbox']"); |
||||||
|
await input.setChecked(Boolean(changedValue(original))); |
||||||
|
} else if (Array.isArray(original)) { |
||||||
|
const addButton = row.locator("button", { hasText: "Add" }); |
||||||
|
await addButton.click(); |
||||||
|
await addButton.click(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function assertCell( |
||||||
|
row: ReturnType<(typeof test)["info"] extends any ? any : never>, |
||||||
|
original: unknown, |
||||||
|
meta: { framework: string; key: string } |
||||||
|
) { |
||||||
|
const { framework, key } = meta; |
||||||
|
const expected = changedValue(original); |
||||||
|
const cell = row.locator("td").nth(1); |
||||||
|
|
||||||
|
if (typeof original === "string") { |
||||||
|
const input = row.locator("input[type='text']"); |
||||||
|
await expect( |
||||||
|
input, |
||||||
|
`Text value mismatch (${framework}:${key})` |
||||||
|
).toHaveValue(String(expected)); |
||||||
|
await expect( |
||||||
|
cell, |
||||||
|
`Rendered text mismatch (${framework}:${key})` |
||||||
|
).toContainText(String(expected)); |
||||||
|
} else if (typeof original === "number") { |
||||||
|
const input = row.locator("input[type='number']"); |
||||||
|
await expect( |
||||||
|
input, |
||||||
|
`Number value mismatch (${framework}:${key})` |
||||||
|
).toHaveValue(String(expected)); |
||||||
|
await expect( |
||||||
|
cell, |
||||||
|
`Rendered number mismatch (${framework}:${key})` |
||||||
|
).toContainText(String(expected)); |
||||||
|
} else if (typeof original === "boolean") { |
||||||
|
const input = row.locator("input[type='checkbox']"); |
||||||
|
await expect( |
||||||
|
input, |
||||||
|
`Checkbox state mismatch (${framework}:${key})` |
||||||
|
).toBeChecked({ |
||||||
|
checked: Boolean(expected), |
||||||
|
}); |
||||||
|
await expect( |
||||||
|
cell, |
||||||
|
`Rendered boolean mismatch (${framework}:${key})` |
||||||
|
).toContainText(String(expected)); |
||||||
|
} else if (Array.isArray(original)) { |
||||||
|
const expectedLength = (original as unknown[]).length + 2; |
||||||
|
await expect( |
||||||
|
cell, |
||||||
|
`Array length mismatch (${framework}:${key}) expected ${expectedLength}` |
||||||
|
).toContainText(String(expectedLength)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
for (const source of frameworks) { |
||||||
|
for (const target of frameworks) { |
||||||
|
if (source === target) continue; |
||||||
|
|
||||||
|
test(`${source} edits propagate to ${target}`, async ({ page }) => { |
||||||
|
await page.goto("/"); |
||||||
|
await page.waitForSelector(".vue astro-island"); |
||||||
|
|
||||||
|
// Mutate in source
|
||||||
|
await test.step(`Mutate values in ${source}`, async () => { |
||||||
|
for (const [key, value] of Object.entries(mockTestObject)) { |
||||||
|
if (isPlainObject(value)) { |
||||||
|
for (const [k2, v2] of Object.entries(value)) { |
||||||
|
const fullKey = `${key}.${k2}`; |
||||||
|
const row = page.locator(`.${source} tr`, { hasText: fullKey }); |
||||||
|
await mutateCell(row, v2); |
||||||
|
} |
||||||
|
} else { |
||||||
|
const row = page.locator(`.${source} tr`, { hasText: key }); |
||||||
|
await mutateCell(row, value); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// Assert in target
|
||||||
|
await test.step(`Assert propagation into ${target}`, async () => { |
||||||
|
for (const [key, value] of Object.entries(mockTestObject)) { |
||||||
|
if (isPlainObject(value)) { |
||||||
|
for (const [k2, v2] of Object.entries(value)) { |
||||||
|
const fullKey = `${key}.${k2}`; |
||||||
|
const row = page.locator(`.${target} tr`, { hasText: fullKey }); |
||||||
|
await assertCell(row, v2, { framework: target, key: fullKey }); |
||||||
|
} |
||||||
|
} else { |
||||||
|
const row = page.locator(`.${target} tr`, { hasText: key }); |
||||||
|
await assertCell(row, value, { framework: target, key }); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// Optional: also ensure source reflects its own changes (helps isolate failures)
|
||||||
|
await test.step(`Validate mutated source ${source}`, async () => { |
||||||
|
for (const [key, value] of Object.entries(mockTestObject)) { |
||||||
|
if (isPlainObject(value)) { |
||||||
|
for (const [k2, v2] of Object.entries(value)) { |
||||||
|
const fullKey = `${key}.${k2}`; |
||||||
|
const row = page.locator(`.${source} tr`, { hasText: fullKey }); |
||||||
|
await assertCell(row, v2, { framework: source, key: fullKey }); |
||||||
|
} |
||||||
|
} else { |
||||||
|
const row = page.locator(`.${source} tr`, { hasText: key }); |
||||||
|
await assertCell(row, value, { framework: source, key }); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
@ -0,0 +1,43 @@ |
|||||||
|
interface FlattenOptions { |
||||||
|
/** Maximum depth to traverse (default: 8). */ |
||||||
|
maxDepth?: number; |
||||||
|
/** Skip keys that start with a dollar sign (deepSignal meta). Default: true */ |
||||||
|
skipDollarKeys?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
const isPlainObject = (v: any) => |
||||||
|
Object.prototype.toString.call(v) === "[object Object]"; |
||||||
|
|
||||||
|
const flattenObject = ( |
||||||
|
obj: any, |
||||||
|
prefix = "", |
||||||
|
options: FlattenOptions = {}, |
||||||
|
seen = new Set<any>(), |
||||||
|
depth = 0 |
||||||
|
): Array<[string, any, string, any]> => { |
||||||
|
const { maxDepth = 8, skipDollarKeys = true } = options; |
||||||
|
const result: Array<[string, any, string, any]> = []; |
||||||
|
if (!obj || typeof obj !== "object") return result; |
||||||
|
if (seen.has(obj)) return result; // cycle detected
|
||||||
|
seen.add(obj); |
||||||
|
if (depth > maxDepth) return result; |
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(obj)) { |
||||||
|
if (skipDollarKeys && key.startsWith("$")) continue; |
||||||
|
const fullKey = prefix ? `${prefix}.${key}` : key; |
||||||
|
if ( |
||||||
|
value && |
||||||
|
typeof value === "object" && |
||||||
|
!Array.isArray(value) && |
||||||
|
!(value instanceof Set) && |
||||||
|
isPlainObject(value) |
||||||
|
) { |
||||||
|
result.push(...flattenObject(value, fullKey, options, seen, depth + 1)); |
||||||
|
} else { |
||||||
|
result.push([fullKey, value, key, obj]); |
||||||
|
} |
||||||
|
} |
||||||
|
return result; |
||||||
|
}; |
||||||
|
|
||||||
|
export default flattenObject; |
@ -0,0 +1,126 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import { computed } from 'vue'; |
||||||
|
import useShape from 'ng-signals/frontendAdapters/vue/useShape'; |
||||||
|
import flattenObject from '../utils/flattenObject'; |
||||||
|
import { TestObjectShapeType } from 'src/shapes/ldo/testShape.shapeTypes'; |
||||||
|
|
||||||
|
// Acquire deep signal object (proxy) for a shape; scope second arg left empty string for parity |
||||||
|
const shapeObj = useShape(TestObjectShapeType); |
||||||
|
|
||||||
|
// Expose for devtools exploration |
||||||
|
// @ts-ignore |
||||||
|
window.vueState = shapeObj; |
||||||
|
|
||||||
|
const flatEntries = computed(() => flattenObject(shapeObj)); |
||||||
|
|
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<template> |
||||||
|
<div class="vue"> |
||||||
|
<p>Rendered in Vue</p> |
||||||
|
|
||||||
|
<template v-if="shapeObj && shapeObj.type"> |
||||||
|
<!-- Direct property access --> |
||||||
|
<input type="text" v-model="shapeObj.type" /> |
||||||
|
<input type="text" v-model="shapeObj.objectValue.nestedString" /> |
||||||
|
|
||||||
|
<!-- Property access through object recursion --> |
||||||
|
<table border="1" cellpadding="5" style="margin-top:1rem; max-width:100%; font-size:0.9rem;"> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>Key</th> |
||||||
|
<th>Value</th> |
||||||
|
<th>Edit</th> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
|
||||||
|
<tbody> |
||||||
|
<tr v-for="([path, value, key, parent]) in flatEntries" :key="path"> |
||||||
|
<!-- Key--> |
||||||
|
<td style="white-space:nowrap;">{{ path }}</td> |
||||||
|
|
||||||
|
<!-- Value --> |
||||||
|
<td> |
||||||
|
<template v-if="value instanceof Set"> |
||||||
|
{{ Array.from(value).join(', ') }} |
||||||
|
</template> |
||||||
|
<template v-else-if="Array.isArray(value)"> |
||||||
|
[{{ value.join(', ') }}] |
||||||
|
</template> |
||||||
|
<template v-else> |
||||||
|
{{ JSON.stringify(value) }} |
||||||
|
</template> |
||||||
|
</td> |
||||||
|
|
||||||
|
<!-- Edit --> |
||||||
|
<td> |
||||||
|
<!-- String editing --> |
||||||
|
<template v-if="typeof value === 'string'"> |
||||||
|
<template v-if="path.indexOf('.') === -1"> |
||||||
|
<input type="text" v-model="(shapeObj)[key]" /> |
||||||
|
</template> |
||||||
|
<template v-else> |
||||||
|
<input type="text" v-bind:value="(parent)[key]" |
||||||
|
v-on:input="(e) => { (parent)[key] = (e.target as any).value; }" /> |
||||||
|
</template> |
||||||
|
</template> |
||||||
|
<!-- Number editing --> |
||||||
|
<template v-else-if="typeof value === 'number'"> |
||||||
|
<template v-if="path.indexOf('.') === -1"> |
||||||
|
<input type="number" v-model="(shapeObj)[key]" /> |
||||||
|
</template> |
||||||
|
<template v-else> |
||||||
|
<input type="number" v-bind:value="(parent)[key]" |
||||||
|
v-on:input="(e) => { (parent)[key] = +(e.target as any).value; }" /> |
||||||
|
</template> |
||||||
|
|
||||||
|
</template> |
||||||
|
<!-- Boolean editing --> |
||||||
|
<template v-else-if="typeof value === 'boolean'"> |
||||||
|
<template v-if="path.indexOf('.') === -1"> |
||||||
|
<input type="checkbox" v-model="(shapeObj)[key]" /> |
||||||
|
</template> |
||||||
|
<template v-else> |
||||||
|
<input type="checkbox" v-bind:value="value" |
||||||
|
v-on:input="(e) => { (parent)[key] = (e.target as any).value; }" /> |
||||||
|
</template> |
||||||
|
</template> |
||||||
|
<!-- Array editing --> |
||||||
|
<template v-else-if="Array.isArray(value)"> |
||||||
|
<template v-if="path.indexOf('.') === -1"> |
||||||
|
<div style="display:flex; gap:.5rem;"> |
||||||
|
<button @click="() => { parent[key] = [...value, value.length + 1] }">Add</button> |
||||||
|
<button @click="() => { parent[key] = value.slice(1) }">Remove</button> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
<template v-else> |
||||||
|
<div style="display:flex; gap:.5rem;"> |
||||||
|
<button @click="() => { parent[key] = [...value, value.length + 1] }">Add</button> |
||||||
|
<button @click="() => { parent[key] = value.slice(1) }">Remove</button> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
</template> |
||||||
|
|
||||||
|
<!-- Set editing --> |
||||||
|
<template v-else-if="value instanceof Set"> |
||||||
|
<div style="display:flex; gap:.5rem;"> |
||||||
|
<button @click="() => { value.add(`item${value.size + 1}`); }">Add</button> |
||||||
|
<button |
||||||
|
@click="() => { const last = Array.from(value).pop(); if (last !== undefined) value.delete(last); }">Remove</button> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
<template v-else> |
||||||
|
N/A |
||||||
|
</template> |
||||||
|
|
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
</template> |
||||||
|
<template v-else> |
||||||
|
<p>Loading state</p> |
||||||
|
</template> |
||||||
|
</div> |
||||||
|
</template> |
@ -0,0 +1,218 @@ |
|||||||
|
import { describe, test, expect } from "vitest"; |
||||||
|
import { |
||||||
|
applyDiff, |
||||||
|
applyDiffToDeepSignal, |
||||||
|
} from "ng-signals/connector/applyDiff"; |
||||||
|
import type { Patch } from "ng-signals/connector/applyDiff"; |
||||||
|
|
||||||
|
/** |
||||||
|
* Build a patch path string from segments (auto-prefix /) |
||||||
|
*/ |
||||||
|
function p(...segs: (string | number)[]) { |
||||||
|
return "/" + segs.map(String).join("/"); |
||||||
|
} |
||||||
|
|
||||||
|
describe("applyDiff - set operations (primitives)", () => { |
||||||
|
test("add single primitive into new set", () => { |
||||||
|
const state: any = {}; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "add", type: "set", path: p("tags"), value: "a" }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(state.tags).toBeInstanceOf(Set); |
||||||
|
expect([...state.tags]).toEqual(["a"]); |
||||||
|
}); |
||||||
|
test("add multiple primitives into new set", () => { |
||||||
|
const state: any = {}; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "add", type: "set", path: p("nums"), value: [1, 2, 3] }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect([...state.nums]).toEqual([1, 2, 3]); |
||||||
|
}); |
||||||
|
test("add primitives merging into existing set", () => { |
||||||
|
const state: any = { nums: new Set([1]) }; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "add", type: "set", path: p("nums"), value: [2, 3] }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect([...state.nums].sort()).toEqual([1, 2, 3]); |
||||||
|
}); |
||||||
|
test("remove single primitive from set", () => { |
||||||
|
const state: any = { tags: new Set(["a", "b"]) }; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "remove", type: "set", path: p("tags"), value: "a" }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect([...state.tags]).toEqual(["b"]); |
||||||
|
}); |
||||||
|
test("remove multiple primitives from set", () => { |
||||||
|
const state: any = { nums: new Set([1, 2, 3, 4]) }; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "remove", type: "set", path: p("nums"), value: [2, 4] }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect([...state.nums].sort()).toEqual([1, 3]); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe("applyDiff - set operations (object sets)", () => { |
||||||
|
test("add object entries to new object-set", () => { |
||||||
|
const state: any = {}; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ |
||||||
|
op: "add", |
||||||
|
type: "set", |
||||||
|
path: p("users"), |
||||||
|
value: { u1: { id: "u1", n: 1 }, u2: { id: "u2", n: 2 } }, |
||||||
|
}, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(state.users.u1).toEqual({ id: "u1", n: 1 }); |
||||||
|
expect(state.users.u2).toEqual({ id: "u2", n: 2 }); |
||||||
|
}); |
||||||
|
test("merge object entries into existing object-set", () => { |
||||||
|
const state: any = { users: { u1: { id: "u1", n: 1 } } }; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ |
||||||
|
op: "add", |
||||||
|
type: "set", |
||||||
|
path: p("users"), |
||||||
|
value: { u2: { id: "u2", n: 2 } }, |
||||||
|
}, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(Object.keys(state.users).sort()).toEqual(["u1", "u2"]); |
||||||
|
}); |
||||||
|
test("remove object entries from object-set", () => { |
||||||
|
const state: any = { users: { u1: {}, u2: {}, u3: {} } }; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "remove", type: "set", path: p("users"), value: ["u1", "u3"] }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(Object.keys(state.users)).toEqual(["u2"]); |
||||||
|
}); |
||||||
|
test("adding primitives to existing object-set replaces with Set", () => { |
||||||
|
const state: any = { mixed: { a: {}, b: {} } }; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "add", type: "set", path: p("mixed"), value: [1, 2] }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(state.mixed).toBeInstanceOf(Set); |
||||||
|
expect([...state.mixed]).toEqual([1, 2]); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe("applyDiff - object & literal operations", () => { |
||||||
|
test("add object (create empty object)", () => { |
||||||
|
const state: any = {}; |
||||||
|
const diff: Patch[] = [{ op: "add", path: p("address"), type: "object" }]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(state.address).toEqual({}); |
||||||
|
}); |
||||||
|
test("add nested object path with ensurePathExists", () => { |
||||||
|
const state: any = {}; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "add", path: p("a", "b", "c"), type: "object" }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff, true); |
||||||
|
expect(state.a.b.c).toEqual({}); |
||||||
|
}); |
||||||
|
test("add primitive value", () => { |
||||||
|
const state: any = { address: {} }; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "add", path: p("address", "street"), value: "1st" }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(state.address.street).toBe("1st"); |
||||||
|
}); |
||||||
|
test("overwrite primitive value", () => { |
||||||
|
const state: any = { address: { street: "old" } }; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "add", path: p("address", "street"), value: "new" }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(state.address.street).toBe("new"); |
||||||
|
}); |
||||||
|
test("remove primitive", () => { |
||||||
|
const state: any = { address: { street: "1st", country: "Greece" } }; |
||||||
|
const diff: Patch[] = [{ op: "remove", path: p("address", "street") }]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(state.address.street).toBeUndefined(); |
||||||
|
expect(state.address.country).toBe("Greece"); |
||||||
|
}); |
||||||
|
test("remove object branch", () => { |
||||||
|
const state: any = { address: { street: "1st" }, other: 1 }; |
||||||
|
const diff: Patch[] = [{ op: "remove", path: p("address") }]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(state.address).toBeUndefined(); |
||||||
|
expect(state.other).toBe(1); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe("applyDiff - multiple mixed patches in a single diff", () => { |
||||||
|
test("sequence of mixed set/object/literal add & remove", () => { |
||||||
|
const state: any = { |
||||||
|
users: { u1: { id: "u1" } }, |
||||||
|
tags: new Set(["old"]), |
||||||
|
}; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "add", type: "set", path: p("users"), value: { u2: { id: "u2" } } }, |
||||||
|
{ op: "add", path: p("profile"), type: "object" }, |
||||||
|
{ op: "add", path: p("profile", "name"), value: "Alice" }, |
||||||
|
{ op: "add", type: "set", path: p("tags"), value: ["new"] }, |
||||||
|
{ op: "remove", type: "set", path: p("tags"), value: "old" }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(Object.keys(state.users).sort()).toEqual(["u1", "u2"]); |
||||||
|
expect(state.profile.name).toBe("Alice"); |
||||||
|
expect([...state.tags]).toEqual(["new"]); |
||||||
|
}); |
||||||
|
|
||||||
|
test("complex nested path creation and mutations with ensurePathExists", () => { |
||||||
|
const state: any = {}; |
||||||
|
const diff: Patch[] = [ |
||||||
|
{ op: "add", path: p("a", "b"), type: "object" }, |
||||||
|
{ op: "add", path: p("a", "b", "c"), value: 1 }, |
||||||
|
{ op: "add", type: "set", path: p("a", "nums"), value: [1, 2, 3] }, |
||||||
|
{ op: "remove", type: "set", path: p("a", "nums"), value: 2 }, |
||||||
|
{ op: "add", path: p("a", "b", "d"), value: 2 }, |
||||||
|
{ op: "remove", path: p("a", "b", "c") }, |
||||||
|
]; |
||||||
|
applyDiff(state, diff, true); |
||||||
|
expect(state.a.b.c).toBeUndefined(); |
||||||
|
expect(state.a.b.d).toBe(2); |
||||||
|
expect(state.a.nums).toBeInstanceOf(Set); |
||||||
|
expect([...state.a.nums].sort()).toEqual([1, 3]); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe("applyDiff - ignored / invalid scenarios", () => { |
||||||
|
test("skip patch with non-leading slash path", () => { |
||||||
|
const state: any = {}; |
||||||
|
const diff: Patch[] = [{ op: "add", path: "address/street", value: "x" }]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(state).toEqual({}); |
||||||
|
}); |
||||||
|
test("missing parent without ensurePathExists -> patch skipped and no mutation", () => { |
||||||
|
const state: any = {}; |
||||||
|
const diff: Patch[] = [{ op: "add", path: p("a", "b", "c"), value: 1 }]; |
||||||
|
applyDiff(state, diff, false); |
||||||
|
expect(state).toEqual({}); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe("applyDiff - ignored / invalid scenarios", () => { |
||||||
|
test("skip patch with non-leading slash path", () => { |
||||||
|
const state: any = {}; |
||||||
|
const diff: Patch[] = [{ op: "add", path: "address/street", value: "x" }]; |
||||||
|
applyDiff(state, diff); |
||||||
|
expect(state).toEqual({}); |
||||||
|
}); |
||||||
|
test("missing parent without ensurePathExists -> patch skipped and no mutation", () => { |
||||||
|
const state: any = {}; |
||||||
|
const diff: Patch[] = [{ op: "add", path: p("a", "b", "c"), value: 1 }]; |
||||||
|
applyDiff(state, diff, false); |
||||||
|
expect(state).toEqual({}); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,56 @@ |
|||||||
|
import { describe, expect, test } from "vitest"; |
||||||
|
import { createSignalObjectForShape } from "ng-signals/connector/createSignalObjectForShape.ts"; |
||||||
|
|
||||||
|
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); |
||||||
|
|
||||||
|
describe("Signal modification and propagation to backend with or without signal pooling", () => { |
||||||
|
for (const withPooling of [true, false]) { |
||||||
|
test(`shape object notification comes back to others ${ |
||||||
|
withPooling ? "with" : "without" |
||||||
|
} signal pooling`, async () => {
|
||||||
|
const object1 = createSignalObjectForShape( |
||||||
|
"TestShape", |
||||||
|
undefined, |
||||||
|
withPooling |
||||||
|
); |
||||||
|
const object2 = createSignalObjectForShape( |
||||||
|
"TestShape", |
||||||
|
undefined, |
||||||
|
withPooling |
||||||
|
); |
||||||
|
|
||||||
|
const object3 = createSignalObjectForShape( |
||||||
|
"Shape2", |
||||||
|
undefined, |
||||||
|
withPooling |
||||||
|
); |
||||||
|
const object4 = createSignalObjectForShape( |
||||||
|
"Shape2", |
||||||
|
undefined, |
||||||
|
withPooling |
||||||
|
); |
||||||
|
|
||||||
|
await wait(10); |
||||||
|
|
||||||
|
// Update object 1 and expect object 2 to update as well.
|
||||||
|
// @ts-expect-error
|
||||||
|
object1.name = "Updated name from object1"; |
||||||
|
|
||||||
|
await wait(10); |
||||||
|
// @ts-expect-error
|
||||||
|
expect(object2.name).toBe("Updated name from object1"); |
||||||
|
|
||||||
|
// Expect object of different shape not to have changed.
|
||||||
|
// @ts-expect-error
|
||||||
|
expect(object3.name).toBe("Niko's cat"); |
||||||
|
|
||||||
|
// Update object 4 and expect object 3 with same shape to have updated.
|
||||||
|
// @ts-expect-error
|
||||||
|
object4.name = "Updated name from object4"; |
||||||
|
|
||||||
|
await wait(10); |
||||||
|
// @ts-expect-error
|
||||||
|
expect(object3!.name).toBe("Updated name from object4"); |
||||||
|
}); |
||||||
|
} |
||||||
|
}); |
@ -0,0 +1,255 @@ |
|||||||
|
import * as shapeManager from "./shapeManager"; |
||||||
|
import type { WasmConnection, Diff, Scope } from "./types"; |
||||||
|
import type { CompactShapeType, LdoCompactBase } from "@ldo/ldo"; |
||||||
|
import type { Person } from "src/shapes/ldo/personShape.typings"; |
||||||
|
import type { Cat } from "src/shapes/ldo/catShape.typings"; |
||||||
|
import type { TestObject } from "src/shapes/ldo/testShape.typings"; |
||||||
|
import updateShape from "./updateShape"; |
||||||
|
|
||||||
|
// Messages exchanged over the BroadcastChannel("shape-manager")
|
||||||
|
interface WasmMessage { |
||||||
|
type: |
||||||
|
| "Request" |
||||||
|
| "InitialResponse" |
||||||
|
| "FrontendUpdate" |
||||||
|
| "BackendUpdate" |
||||||
|
| "Stop"; |
||||||
|
connectionId: string; |
||||||
|
diff?: Diff; |
||||||
|
schema?: CompactShapeType<any>["schema"]; |
||||||
|
initialData?: LdoCompactBase; |
||||||
|
} |
||||||
|
|
||||||
|
export const mockTestObject = { |
||||||
|
id: "ex:mock-id-1", |
||||||
|
type: "TestObject", |
||||||
|
stringValue: "string", |
||||||
|
numValue: 42, |
||||||
|
boolValue: true, |
||||||
|
arrayValue: [1, 2, 3], |
||||||
|
objectValue: { |
||||||
|
id: "urn:obj-1", |
||||||
|
nestedString: "nested", |
||||||
|
nestedNum: 7, |
||||||
|
nestedArray: [10, 12], |
||||||
|
}, |
||||||
|
anotherObject: { |
||||||
|
"id:1": { |
||||||
|
id: "id:1", |
||||||
|
prop1: "prop1 value", |
||||||
|
prop2: 100, |
||||||
|
}, |
||||||
|
"id:2": { |
||||||
|
id: "id:1", |
||||||
|
prop1: "prop2 value", |
||||||
|
prop2: 200, |
||||||
|
}, |
||||||
|
}, |
||||||
|
} satisfies TestObject; |
||||||
|
|
||||||
|
const mockShapeObject1 = { |
||||||
|
id: "ex:person-1", |
||||||
|
type: "Person", |
||||||
|
name: "Bob", |
||||||
|
address: { |
||||||
|
id: "urn:person-home-1", |
||||||
|
street: "First street", |
||||||
|
houseNumber: "15", |
||||||
|
}, |
||||||
|
hasChildren: true, |
||||||
|
numberOfHouses: 0, |
||||||
|
} satisfies Person; |
||||||
|
|
||||||
|
const mockShapeObject2 = { |
||||||
|
id: "ex:cat-1", |
||||||
|
type: "Cat", |
||||||
|
name: "Niko's cat", |
||||||
|
age: 12, |
||||||
|
numberOfHomes: 3, |
||||||
|
address: { |
||||||
|
id: "Nikos-cat-home", |
||||||
|
street: "Niko's street", |
||||||
|
houseNumber: "15", |
||||||
|
floor: 0, |
||||||
|
}, |
||||||
|
} satisfies Cat; |
||||||
|
|
||||||
|
// Single BroadcastChannel for wasm-land side
|
||||||
|
const communicationChannel = new BroadcastChannel("shape-manager"); |
||||||
|
|
||||||
|
function getInitialObjectByShapeId<T extends LdoCompactBase>( |
||||||
|
shapeId?: string, |
||||||
|
): T { |
||||||
|
if (shapeId?.includes("TestObject")) return mockTestObject as unknown as T; |
||||||
|
if (shapeId?.includes("Person")) return mockShapeObject1 as unknown as T; |
||||||
|
if (shapeId?.includes("Cat")) return mockShapeObject2 as unknown as T; |
||||||
|
console.warn( |
||||||
|
"BACKEND: requestShape for unknown shape, returning empty object.", |
||||||
|
shapeId, |
||||||
|
); |
||||||
|
return {} as T; |
||||||
|
} |
||||||
|
|
||||||
|
// Register handler for messages coming from js-land
|
||||||
|
communicationChannel.addEventListener( |
||||||
|
"message", |
||||||
|
(event: MessageEvent<WasmMessage>) => { |
||||||
|
console.log("BACKEND: Received message", event.data); |
||||||
|
const { type, connectionId, schema } = event.data; |
||||||
|
|
||||||
|
if (type === "Request") { |
||||||
|
const shapeId = schema?.shapes?.[0]?.id; |
||||||
|
const initialData = getInitialObjectByShapeId(shapeId); |
||||||
|
|
||||||
|
// Store connection. We store the shapeId string to allow equality across connections.
|
||||||
|
shapeManager.connections.set(connectionId, { |
||||||
|
id: connectionId, |
||||||
|
// Cast to any to satisfy WasmConnection type, comparison in updateShape uses ==
|
||||||
|
shape: (shapeId ?? "__unknown__") as any, |
||||||
|
state: initialData, |
||||||
|
callback: (diff: Diff, conId: WasmConnection["id"]) => { |
||||||
|
// Notify js-land about backend updates
|
||||||
|
const msg: WasmMessage = { |
||||||
|
type: "BackendUpdate", |
||||||
|
connectionId: conId, |
||||||
|
diff, |
||||||
|
}; |
||||||
|
communicationChannel.postMessage(msg); |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
const msg: WasmMessage = { |
||||||
|
type: "InitialResponse", |
||||||
|
connectionId, |
||||||
|
initialData, |
||||||
|
}; |
||||||
|
communicationChannel.postMessage(msg); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (type === "Stop") { |
||||||
|
shapeManager.connections.delete(connectionId); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (type === "FrontendUpdate" && event.data.diff) { |
||||||
|
updateShape(connectionId, event.data.diff); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
console.warn("BACKEND: Unknown message type or missing diff", event.data); |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
// Keep the original function for compatibility with any direct callers.
|
||||||
|
let connectionIdCounter = 1; |
||||||
|
export default async function requestShape<T extends LdoCompactBase>( |
||||||
|
shape: CompactShapeType<T>, |
||||||
|
_scope: Scope | undefined, |
||||||
|
callback: (diff: Diff, connectionId: WasmConnection["id"]) => void, |
||||||
|
): Promise<{ connectionId: string; shapeObject: T }> { |
||||||
|
const connectionId = `connection-${connectionIdCounter++}-${shape.schema.shapes?.[0]?.id}`; |
||||||
|
const shapeId = shape.schema.shapes?.[0]?.id; |
||||||
|
const shapeObject = getInitialObjectByShapeId<T>(shapeId); |
||||||
|
|
||||||
|
shapeManager.connections.set(connectionId, { |
||||||
|
id: connectionId, |
||||||
|
shape: (shapeId ?? "__unknown__") as any, |
||||||
|
state: shapeObject, |
||||||
|
callback, |
||||||
|
}); |
||||||
|
|
||||||
|
return { connectionId, shapeObject }; |
||||||
|
} |
||||||
|
|
||||||
|
const getObjectsForShapeType = <T extends LdoCompactBase>( |
||||||
|
shape: CompactShapeType<T>, |
||||||
|
scope: string = "", |
||||||
|
): T[] => { |
||||||
|
// Procedure
|
||||||
|
// - Get all triples for the scope
|
||||||
|
// - Parse the schema (all shapes and anonymous shapes required for the shape type).
|
||||||
|
|
||||||
|
// - Group triples by subject
|
||||||
|
// - For the shapeType in the schema, match all required predicates
|
||||||
|
// - For predicates pointing to nested objects
|
||||||
|
// - recurse
|
||||||
|
|
||||||
|
// Repeat procedure for all matched subjects with optional predicates
|
||||||
|
|
||||||
|
const quads: [ |
||||||
|
string, |
||||||
|
string, |
||||||
|
number | string | boolean, |
||||||
|
string | undefined, |
||||||
|
][] = []; |
||||||
|
|
||||||
|
// The URI of the shape to find matches for.
|
||||||
|
const schemaId = shape.shape; |
||||||
|
// ShexJ shape object
|
||||||
|
const rootShapeDecl = shape.schema.shapes?.find( |
||||||
|
(shape) => shape.id === schemaId, |
||||||
|
); |
||||||
|
if (!rootShapeDecl) |
||||||
|
throw new Error(`Could not find shape id ${schemaId} in shape schema`); |
||||||
|
|
||||||
|
if (rootShapeDecl.shapeExpr.type !== "Shape") |
||||||
|
throw new Error("Expected shapeExpr.type to be Shape"); |
||||||
|
|
||||||
|
const shapeExpression = rootShapeDecl.shapeExpr.expression; |
||||||
|
// If shape is a reference...
|
||||||
|
if (typeof shapeExpression === "string") { |
||||||
|
// TODO: Recurse
|
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
const requiredPredicates = []; |
||||||
|
const optionalPredicates = []; |
||||||
|
|
||||||
|
if (shapeExpression?.type === "EachOf") { |
||||||
|
const predicates = shapeExpression.expressions.map((constraint) => { |
||||||
|
if (typeof constraint === "string") { |
||||||
|
// Cannot parse constraint refs
|
||||||
|
return; |
||||||
|
} else if (constraint.type === "TripleConstraint") { |
||||||
|
requiredPredicates.push({ |
||||||
|
predicate: constraint.predicate, |
||||||
|
}); |
||||||
|
} else { |
||||||
|
// EachOf or OneOf possible?
|
||||||
|
} |
||||||
|
}); |
||||||
|
} else if (shapeExpression?.type === "OneOf") { |
||||||
|
// Does not occur AFAIK.
|
||||||
|
} else if (shapeExpression?.type === "TripleConstraint") { |
||||||
|
// Does not occur AFAIK.
|
||||||
|
} |
||||||
|
|
||||||
|
return []; |
||||||
|
}; |
||||||
|
|
||||||
|
interface ShapeConstraintTracked { |
||||||
|
subject: string; |
||||||
|
childOf?: ShapeConstraintTracked; |
||||||
|
predicates: [ |
||||||
|
{ |
||||||
|
displayName: string; |
||||||
|
uri: string; |
||||||
|
type: "number" | "string" | "boolean" | "nested" | "literal"; |
||||||
|
literalValue?: number | string | boolean | number[] | string[]; |
||||||
|
nested?: ShapeConstraintTracked; |
||||||
|
min: number; |
||||||
|
max: number; |
||||||
|
currentCount: number; |
||||||
|
}, |
||||||
|
]; |
||||||
|
} |
||||||
|
|
||||||
|
// Group by subject, check predicates of root level
|
||||||
|
// For all subjects of root level,
|
||||||
|
// - recurse
|
||||||
|
|
||||||
|
// Construct matching subjects
|
||||||
|
// for each optional and non-optional predicate
|
||||||
|
// - fill objects and record
|
||||||
|
// - build tracked object (keeping reference counts to check if the object is still valid)
|
@ -0,0 +1,10 @@ |
|||||||
|
import type { Diff, ObjectState, WasmConnection } from "./types"; |
||||||
|
|
||||||
|
const connections: Map<WasmConnection["id"], WasmConnection> = new Map(); |
||||||
|
|
||||||
|
/** Mock function to apply diffs. Just uses a copy of the diff as the new object. */ |
||||||
|
export function applyDiff(currentState: ObjectState, diff: Diff): ObjectState { |
||||||
|
return JSON.parse(JSON.stringify(diff)); |
||||||
|
} |
||||||
|
|
||||||
|
export { connections }; |
@ -0,0 +1,14 @@ |
|||||||
|
# SPARQL builders |
||||||
|
|
||||||
|
Utilities to build SPARQL SELECT and CONSTRUCT queries from a ShapeConstraint structure. |
||||||
|
|
||||||
|
Exports: |
||||||
|
|
||||||
|
- buildSelectQuery(shape, options) |
||||||
|
- buildConstructQuery(shape, options) |
||||||
|
|
||||||
|
Options: |
||||||
|
|
||||||
|
- prefixes: Record<prefix, IRI> |
||||||
|
- graph: named graph IRI or CURIE |
||||||
|
- includeOptionalForMinZero: wrap min=0 predicates in OPTIONAL (default true) |
@ -0,0 +1,149 @@ |
|||||||
|
import type { |
||||||
|
BuildContext, |
||||||
|
PredicateConstraint, |
||||||
|
ShapeConstraint, |
||||||
|
SparqlBuildOptions, |
||||||
|
} from "./common"; |
||||||
|
import { |
||||||
|
predicateToSparql, |
||||||
|
prefixesToText, |
||||||
|
toIriOrCurie, |
||||||
|
uniqueVar, |
||||||
|
valuesBlock, |
||||||
|
varToken, |
||||||
|
} from "./common"; |
||||||
|
|
||||||
|
/** |
||||||
|
* Build a SPARQL CONSTRUCT query from a ShapeConstraint definition. |
||||||
|
* The WHERE mirrors the graph template. Optional predicates (min=0) are wrapped in OPTIONAL in WHERE |
||||||
|
* but still appear in the CONSTRUCT template so that matched triples are constructed. |
||||||
|
*/ |
||||||
|
export function buildConstructQuery( |
||||||
|
shape: ShapeConstraint, |
||||||
|
options?: SparqlBuildOptions, |
||||||
|
): string { |
||||||
|
const ctx: BuildContext = { usedVars: new Set<string>() }; |
||||||
|
const prefixes = prefixesToText(options?.prefixes); |
||||||
|
const subject = toIriOrCurie(shape.subject); |
||||||
|
|
||||||
|
const templateLines: string[] = []; |
||||||
|
const whereLines: string[] = []; |
||||||
|
const postFilters: string[] = []; |
||||||
|
const valuesBlocks: string[] = []; |
||||||
|
|
||||||
|
const rootVar = |
||||||
|
subject.startsWith("?") || subject.startsWith("$") |
||||||
|
? subject |
||||||
|
: uniqueVar(ctx, "s"); |
||||||
|
if (!subject.startsWith("?") && !subject.startsWith("$")) { |
||||||
|
valuesBlocks.push(valuesBlock(rootVar, [subject] as any)); |
||||||
|
} |
||||||
|
|
||||||
|
const predicates = Array.isArray(shape.predicates) |
||||||
|
? shape.predicates |
||||||
|
: [...shape.predicates]; |
||||||
|
for (const pred of predicates) { |
||||||
|
addConstructPattern( |
||||||
|
ctx, |
||||||
|
pred, |
||||||
|
rootVar, |
||||||
|
templateLines, |
||||||
|
whereLines, |
||||||
|
postFilters, |
||||||
|
valuesBlocks, |
||||||
|
options, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const graphWrap = (body: string) => |
||||||
|
options?.graph |
||||||
|
? `GRAPH ${toIriOrCurie(options.graph)} {\n${body}\n}` |
||||||
|
: body; |
||||||
|
|
||||||
|
const where = [ |
||||||
|
...valuesBlocks, |
||||||
|
graphWrap(whereLines.join("\n")), |
||||||
|
...postFilters, |
||||||
|
] |
||||||
|
.filter(Boolean) |
||||||
|
.join("\n"); |
||||||
|
|
||||||
|
const template = templateLines.join("\n"); |
||||||
|
|
||||||
|
return [prefixes, `CONSTRUCT {`, template, `} WHERE {`, where, `}`].join( |
||||||
|
"\n", |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
function addConstructPattern( |
||||||
|
ctx: BuildContext, |
||||||
|
pred: PredicateConstraint, |
||||||
|
subjectVar: string, |
||||||
|
template: string[], |
||||||
|
where: string[], |
||||||
|
postFilters: string[], |
||||||
|
valuesBlocks: string[], |
||||||
|
options?: SparqlBuildOptions, |
||||||
|
) { |
||||||
|
const p = predicateToSparql(pred.uri); |
||||||
|
const objVar = uniqueVar(ctx, pred.displayName || "o"); |
||||||
|
const objTerm = |
||||||
|
pred.type === "nested" && |
||||||
|
pred.nested?.subject && |
||||||
|
!pred.nested.subject.match(/^\?|^\$/) |
||||||
|
? toIriOrCurie(pred.nested.subject) |
||||||
|
: objVar; |
||||||
|
|
||||||
|
const triple = `${subjectVar} ${p} ${objTerm} .`; |
||||||
|
|
||||||
|
const isOptional = |
||||||
|
(pred.min ?? 0) === 0 && (options?.includeOptionalForMinZero ?? true); |
||||||
|
|
||||||
|
if (pred.type === "nested" && pred.nested) { |
||||||
|
template.push(triple); |
||||||
|
const nestedBody: string[] = [triple]; |
||||||
|
const nestedPreds = Array.isArray(pred.nested.predicates) |
||||||
|
? pred.nested.predicates |
||||||
|
: [...pred.nested.predicates]; |
||||||
|
for (const n of nestedPreds) { |
||||||
|
addConstructPattern( |
||||||
|
ctx, |
||||||
|
n, |
||||||
|
objTerm, |
||||||
|
template, |
||||||
|
nestedBody, |
||||||
|
postFilters, |
||||||
|
valuesBlocks, |
||||||
|
options, |
||||||
|
); |
||||||
|
} |
||||||
|
const block = nestedBody.join("\n"); |
||||||
|
where.push(isOptional ? `OPTIONAL {\n${block}\n}` : block); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Non-nested
|
||||||
|
template.push(triple); |
||||||
|
const blockLines: string[] = [triple]; |
||||||
|
|
||||||
|
if (pred.type === "literal" && pred.literalValue !== undefined) { |
||||||
|
if (Array.isArray(pred.literalValue)) { |
||||||
|
valuesBlocks.push(valuesBlock(objVar, pred.literalValue as any[])); |
||||||
|
} else { |
||||||
|
const lit = |
||||||
|
typeof pred.literalValue === "string" || |
||||||
|
typeof pred.literalValue === "number" || |
||||||
|
typeof pred.literalValue === "boolean" |
||||||
|
? pred.literalValue |
||||||
|
: String(pred.literalValue); |
||||||
|
postFilters.push( |
||||||
|
`FILTER(${objVar} = ${typeof lit === "string" ? `"${String(lit).replace(/"/g, '\\"')}"` : lit})`, |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const block = blockLines.join("\n"); |
||||||
|
where.push(isOptional ? `OPTIONAL {\n${block}\n}` : block); |
||||||
|
} |
||||||
|
|
||||||
|
export default buildConstructQuery; |
@ -0,0 +1,152 @@ |
|||||||
|
import type { |
||||||
|
BuildContext, |
||||||
|
PredicateConstraint, |
||||||
|
ShapeConstraint, |
||||||
|
SparqlBuildOptions, |
||||||
|
} from "./common"; |
||||||
|
import { |
||||||
|
predicateToSparql, |
||||||
|
prefixesToText, |
||||||
|
toIriOrCurie, |
||||||
|
uniqueVar, |
||||||
|
valuesBlock, |
||||||
|
varToken, |
||||||
|
} from "./common"; |
||||||
|
|
||||||
|
/** |
||||||
|
* Build a SPARQL SELECT query from a ShapeConstraint definition. |
||||||
|
* The query matches the shape subject and constraints; optional predicates (min=0) are wrapped in OPTIONAL. |
||||||
|
*/ |
||||||
|
export function buildSelectQuery( |
||||||
|
shape: ShapeConstraint, |
||||||
|
options?: SparqlBuildOptions, |
||||||
|
): string { |
||||||
|
const ctx: BuildContext = { usedVars: new Set<string>() }; |
||||||
|
const prefixes = prefixesToText(options?.prefixes); |
||||||
|
const subject = toIriOrCurie(shape.subject); |
||||||
|
|
||||||
|
const selectVars: string[] = []; |
||||||
|
const whereLines: string[] = []; |
||||||
|
const postFilters: string[] = []; |
||||||
|
const valuesBlocks: string[] = []; |
||||||
|
|
||||||
|
// ensure a consistent root variable when subject is a variable
|
||||||
|
const rootVar = |
||||||
|
subject.startsWith("?") || subject.startsWith("$") |
||||||
|
? subject |
||||||
|
: uniqueVar(ctx, "s"); |
||||||
|
if (!subject.startsWith("?") && !subject.startsWith("$")) { |
||||||
|
// bind fixed subject via VALUES for portability
|
||||||
|
valuesBlocks.push(valuesBlock(rootVar, [subject] as any)); |
||||||
|
} |
||||||
|
|
||||||
|
const predicates = Array.isArray(shape.predicates) |
||||||
|
? shape.predicates |
||||||
|
: [...shape.predicates]; |
||||||
|
|
||||||
|
for (const pred of predicates) { |
||||||
|
addPredicatePattern( |
||||||
|
ctx, |
||||||
|
pred, |
||||||
|
rootVar, |
||||||
|
whereLines, |
||||||
|
selectVars, |
||||||
|
postFilters, |
||||||
|
valuesBlocks, |
||||||
|
options, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const graphWrap = (body: string) => |
||||||
|
options?.graph |
||||||
|
? `GRAPH ${toIriOrCurie(options.graph)} {\n${body}\n}` |
||||||
|
: body; |
||||||
|
|
||||||
|
const where = [ |
||||||
|
...valuesBlocks, |
||||||
|
graphWrap(whereLines.join("\n")), |
||||||
|
...postFilters, |
||||||
|
] |
||||||
|
.filter(Boolean) |
||||||
|
.join("\n"); |
||||||
|
|
||||||
|
const select = selectVars.length ? selectVars.join(" ") : "*"; |
||||||
|
|
||||||
|
return [prefixes, `SELECT ${select} WHERE {`, where, `}`].join("\n"); |
||||||
|
} |
||||||
|
|
||||||
|
function addPredicatePattern( |
||||||
|
ctx: BuildContext, |
||||||
|
pred: PredicateConstraint, |
||||||
|
subjectVar: string, |
||||||
|
where: string[], |
||||||
|
selectVars: string[], |
||||||
|
postFilters: string[], |
||||||
|
valuesBlocks: string[], |
||||||
|
options?: SparqlBuildOptions, |
||||||
|
) { |
||||||
|
const p = predicateToSparql(pred.uri); |
||||||
|
const objVar = uniqueVar(ctx, pred.displayName || "o"); |
||||||
|
const objTerm = |
||||||
|
pred.type === "nested" && |
||||||
|
pred.nested?.subject && |
||||||
|
!pred.nested.subject.match(/^\?|^\$/) |
||||||
|
? toIriOrCurie(pred.nested.subject) |
||||||
|
: objVar; |
||||||
|
|
||||||
|
const triple = `${subjectVar} ${p} ${objTerm} .`; |
||||||
|
|
||||||
|
const isOptional = |
||||||
|
(pred.min ?? 0) === 0 && (options?.includeOptionalForMinZero ?? true); |
||||||
|
|
||||||
|
if (pred.type === "nested" && pred.nested) { |
||||||
|
// For nested, we select the nested object var and then recurse
|
||||||
|
if (objTerm === objVar) selectVars.push(objVar); |
||||||
|
const nestedBody: string[] = [triple]; |
||||||
|
const nestedPreds = Array.isArray(pred.nested.predicates) |
||||||
|
? pred.nested.predicates |
||||||
|
: [...pred.nested.predicates]; |
||||||
|
for (const n of nestedPreds) { |
||||||
|
addPredicatePattern( |
||||||
|
ctx, |
||||||
|
n, |
||||||
|
objTerm, |
||||||
|
nestedBody, |
||||||
|
selectVars, |
||||||
|
postFilters, |
||||||
|
valuesBlocks, |
||||||
|
options, |
||||||
|
); |
||||||
|
} |
||||||
|
const block = nestedBody.join("\n"); |
||||||
|
where.push(isOptional ? `OPTIONAL {\n${block}\n}` : block); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Non-nested: literals or IRIs
|
||||||
|
selectVars.push(objVar); |
||||||
|
const blockLines: string[] = [triple]; |
||||||
|
|
||||||
|
if (pred.type === "literal" && pred.literalValue !== undefined) { |
||||||
|
if (Array.isArray(pred.literalValue)) { |
||||||
|
// VALUES block for IN-like matching
|
||||||
|
valuesBlocks.push(valuesBlock(objVar, pred.literalValue as any[])); |
||||||
|
} else { |
||||||
|
// simple equality filter
|
||||||
|
const lit = |
||||||
|
typeof pred.literalValue === "string" || |
||||||
|
typeof pred.literalValue === "number" || |
||||||
|
typeof pred.literalValue === "boolean" |
||||||
|
? pred.literalValue |
||||||
|
: String(pred.literalValue); |
||||||
|
postFilters.push( |
||||||
|
`FILTER(${objVar} = ${typeof lit === "string" ? `"${String(lit).replace(/"/g, '\\"')}"` : lit})`, |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const block = blockLines.join("\n"); |
||||||
|
where.push(isOptional ? `OPTIONAL {\n${block}\n}` : block); |
||||||
|
} |
||||||
|
|
||||||
|
export default buildSelectQuery; |
@ -0,0 +1,125 @@ |
|||||||
|
/** |
||||||
|
* Shared helpers and types to build SPARQL queries from ShapeConstraint |
||||||
|
*/ |
||||||
|
|
||||||
|
export type LiteralKind = |
||||||
|
| "number" |
||||||
|
| "string" |
||||||
|
| "boolean" |
||||||
|
| "nested" |
||||||
|
| "literal"; |
||||||
|
|
||||||
|
export interface PredicateConstraint { |
||||||
|
displayName: string; |
||||||
|
uri: string; |
||||||
|
type: LiteralKind; |
||||||
|
literalValue?: number | string | boolean | number[] | string[]; |
||||||
|
nested?: ShapeConstraint; |
||||||
|
min: number; |
||||||
|
max: number; |
||||||
|
currentCount: number; |
||||||
|
} |
||||||
|
|
||||||
|
export interface ShapeConstraint { |
||||||
|
subject: string; |
||||||
|
// In upstream code this is typed as a 1-length tuple; we normalize to an array here
|
||||||
|
predicates: PredicateConstraint[] | [PredicateConstraint]; |
||||||
|
} |
||||||
|
|
||||||
|
export interface SparqlBuildOptions { |
||||||
|
prefixes?: Record<string, string>; |
||||||
|
graph?: string; // IRI of the named graph to query, if any
|
||||||
|
includeOptionalForMinZero?: boolean; // default true
|
||||||
|
} |
||||||
|
|
||||||
|
export const defaultPrefixes: Record<string, string> = { |
||||||
|
xsd: "http://www.w3.org/2001/XMLSchema#", |
||||||
|
rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#", |
||||||
|
rdfs: "http://www.w3.org/2000/01/rdf-schema#", |
||||||
|
}; |
||||||
|
|
||||||
|
export function prefixesToText(prefixes?: Record<string, string>): string { |
||||||
|
const all = { ...defaultPrefixes, ...(prefixes ?? {}) }; |
||||||
|
return Object.entries(all) |
||||||
|
.map(([p, iri]) => `PREFIX ${p}: <${iri}>`) |
||||||
|
.join("\n"); |
||||||
|
} |
||||||
|
|
||||||
|
export function toIriOrCurie(term: string): string { |
||||||
|
// variable
|
||||||
|
if (term.startsWith("?") || term.startsWith("$")) return term; |
||||||
|
// blank node
|
||||||
|
if (term.startsWith("_:")) return term; |
||||||
|
// full IRI
|
||||||
|
if (term.includes("://")) return `<${term}>`; |
||||||
|
// fallback: assume CURIE or already-angled
|
||||||
|
if (term.startsWith("<") && term.endsWith(">")) return term; |
||||||
|
return term; // CURIE, caller must ensure prefix provided
|
||||||
|
} |
||||||
|
|
||||||
|
export function predicateToSparql(uri: string): string { |
||||||
|
// Allow CURIEs or IRIs
|
||||||
|
return toIriOrCurie(uri); |
||||||
|
} |
||||||
|
|
||||||
|
export function safeVarName(name: string): string { |
||||||
|
const base = name |
||||||
|
.replace(/[^a-zA-Z0-9_]/g, "_") |
||||||
|
.replace(/^([0-9])/, "_$1") |
||||||
|
.slice(0, 60); |
||||||
|
return base || "v"; |
||||||
|
} |
||||||
|
|
||||||
|
export function varToken(name: string): string { |
||||||
|
const n = name.startsWith("?") || name.startsWith("$") ? name.slice(1) : name; |
||||||
|
return `?${safeVarName(n)}`; |
||||||
|
} |
||||||
|
|
||||||
|
export function formatLiteral(value: string | number | boolean): string { |
||||||
|
if (typeof value === "number") return String(value); |
||||||
|
if (typeof value === "boolean") return value ? "true" : "false"; |
||||||
|
// default string literal
|
||||||
|
const escaped = value.replace(/"/g, '\\"'); |
||||||
|
return `"${escaped}"`; |
||||||
|
} |
||||||
|
|
||||||
|
export function formatTermForValues(value: string | number | boolean): string { |
||||||
|
if (typeof value === "number" || typeof value === "boolean") |
||||||
|
return formatLiteral(value); |
||||||
|
// strings: detect IRI or CURIE and keep raw; otherwise quote
|
||||||
|
const v = value.trim(); |
||||||
|
const looksLikeIri = v.startsWith("<") && v.endsWith(">"); |
||||||
|
const looksLikeHttp = v.includes("://"); |
||||||
|
const looksLikeCurie = |
||||||
|
/^[A-Za-z_][A-Za-z0-9_-]*:.+$/u.test(v) && !looksLikeHttp; |
||||||
|
if (looksLikeIri || looksLikeHttp || looksLikeCurie) { |
||||||
|
return looksLikeHttp ? `<${v}>` : v; |
||||||
|
} |
||||||
|
return formatLiteral(v); |
||||||
|
} |
||||||
|
|
||||||
|
export function valuesBlock( |
||||||
|
varName: string, |
||||||
|
values: Array<string | number | boolean>, |
||||||
|
): string { |
||||||
|
const rendered = values.map(formatTermForValues).join(" "); |
||||||
|
return `VALUES ${varName} { ${rendered} }`; |
||||||
|
} |
||||||
|
|
||||||
|
export interface BuildContext { |
||||||
|
// Tracks used variable names to avoid collisions
|
||||||
|
usedVars: Set<string>; |
||||||
|
} |
||||||
|
|
||||||
|
export function uniqueVar(ctx: BuildContext, base: string): string { |
||||||
|
let candidate = varToken(base); |
||||||
|
if (!ctx.usedVars.has(candidate)) { |
||||||
|
ctx.usedVars.add(candidate); |
||||||
|
return candidate; |
||||||
|
} |
||||||
|
let i = 2; |
||||||
|
while (ctx.usedVars.has(`${candidate}_${i}`)) i++; |
||||||
|
const unique = `${candidate}_${i}`; |
||||||
|
ctx.usedVars.add(unique); |
||||||
|
return unique; |
||||||
|
} |
@ -0,0 +1,19 @@ |
|||||||
|
import type { CompactShapeType, LdoCompactBase } from "@ldo/ldo"; |
||||||
|
import type { Patch } from "ng-signals/connector/applyDiff"; |
||||||
|
import type { Shape } from "ng-signals/types"; |
||||||
|
|
||||||
|
/** The Scope of a shape request */ |
||||||
|
export type Scope = string | string[]; |
||||||
|
|
||||||
|
/** The diff format used to communicate updates between wasm-land and js-land. */ |
||||||
|
export type Diff = Patch[]; |
||||||
|
|
||||||
|
export type ObjectState = object; |
||||||
|
|
||||||
|
/** A connection established between wasm-land and js-land for subscription of a shape. */ |
||||||
|
export type WasmConnection<T extends LdoCompactBase = LdoCompactBase> = { |
||||||
|
id: string; |
||||||
|
shape: CompactShapeType<T>; |
||||||
|
state: ObjectState; |
||||||
|
callback: (diff: Diff, connectionId: WasmConnection["id"]) => void; |
||||||
|
}; |
@ -0,0 +1,22 @@ |
|||||||
|
import * as shapeManager from "./shapeManager"; |
||||||
|
import type { WasmConnection, Diff } from "./types"; |
||||||
|
|
||||||
|
export default async function updateShape( |
||||||
|
connectionId: WasmConnection["id"], |
||||||
|
diff: Diff, |
||||||
|
) { |
||||||
|
const connection = shapeManager.connections.get(connectionId); |
||||||
|
if (!connection) throw new Error("No Connection found."); |
||||||
|
|
||||||
|
console.log("BACKEND: Received update request from ", connectionId); |
||||||
|
|
||||||
|
const newState = shapeManager.applyDiff(connection.state, diff); |
||||||
|
connection.state = newState; |
||||||
|
|
||||||
|
shapeManager.connections.forEach((con) => { |
||||||
|
// if (con.shape == connection.shape) {
|
||||||
|
// con.state = newState;
|
||||||
|
// con.callback(diff, con.id);
|
||||||
|
// }
|
||||||
|
}); |
||||||
|
} |
@ -0,0 +1,77 @@ |
|||||||
|
import type { CompactSchema } from "@ldo/ldo"; |
||||||
|
|
||||||
|
/** |
||||||
|
* ============================================================================= |
||||||
|
* catShapeSchema: Compact Schema for catShape |
||||||
|
* ============================================================================= |
||||||
|
*/ |
||||||
|
export const catShapeSchema: CompactSchema = { |
||||||
|
"http://example.org/Cat": { |
||||||
|
iri: "http://example.org/Cat", |
||||||
|
predicates: [ |
||||||
|
{ |
||||||
|
type: "literal", |
||||||
|
literalValue: ["Cat"], |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", |
||||||
|
readablePredicate: "type", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "string", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/name", |
||||||
|
readablePredicate: "name", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "number", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/age", |
||||||
|
readablePredicate: "age", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "number", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/numberOfHomes", |
||||||
|
readablePredicate: "numberOfHomes", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "nested", |
||||||
|
nestedSchema: "http://example.org/Cat::http://example.org/address", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/address", |
||||||
|
readablePredicate: "address", |
||||||
|
}, |
||||||
|
], |
||||||
|
}, |
||||||
|
"http://example.org/Cat::http://example.org/address": { |
||||||
|
iri: "http://example.org/Cat::http://example.org/address", |
||||||
|
predicates: [ |
||||||
|
{ |
||||||
|
type: "string", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/street", |
||||||
|
readablePredicate: "street", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "string", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/houseNumber", |
||||||
|
readablePredicate: "houseNumber", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "number", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/floor", |
||||||
|
readablePredicate: "floor", |
||||||
|
}, |
||||||
|
], |
||||||
|
}, |
||||||
|
}; |
@ -0,0 +1,9 @@ |
|||||||
|
import type { CompactShapeType } from "@ldo/ldo"; |
||||||
|
import { catShapeSchema } from "./catShape.schema"; |
||||||
|
import type { Cat } from "./catShape.typings"; |
||||||
|
|
||||||
|
// Compact ShapeTypes for catShape
|
||||||
|
export const CatShapeType: CompactShapeType<Cat> = { |
||||||
|
schema: catShapeSchema, |
||||||
|
shape: "http://example.org/Cat", |
||||||
|
}; |
@ -0,0 +1,48 @@ |
|||||||
|
export type IRI = string; |
||||||
|
|
||||||
|
/** |
||||||
|
* ============================================================================= |
||||||
|
* Typescript Typings for catShape |
||||||
|
* ============================================================================= |
||||||
|
*/ |
||||||
|
|
||||||
|
/** |
||||||
|
* Cat Type |
||||||
|
*/ |
||||||
|
export interface Cat { |
||||||
|
id: IRI; |
||||||
|
/** |
||||||
|
* Original IRI: http://www.w3.org/1999/02/22-rdf-syntax-ns#type
|
||||||
|
*/ |
||||||
|
type: "Cat"; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/name
|
||||||
|
*/ |
||||||
|
name: string; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/age
|
||||||
|
*/ |
||||||
|
age: number; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/numberOfHomes
|
||||||
|
*/ |
||||||
|
numberOfHomes: number; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/address
|
||||||
|
*/ |
||||||
|
address: { |
||||||
|
id: IRI; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/street
|
||||||
|
*/ |
||||||
|
street: string; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/houseNumber
|
||||||
|
*/ |
||||||
|
houseNumber: string; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/floor
|
||||||
|
*/ |
||||||
|
floor: number; |
||||||
|
}; |
||||||
|
} |
@ -0,0 +1,70 @@ |
|||||||
|
import type { CompactSchema } from "@ldo/ldo"; |
||||||
|
|
||||||
|
/** |
||||||
|
* ============================================================================= |
||||||
|
* personShapeSchema: Compact Schema for personShape |
||||||
|
* ============================================================================= |
||||||
|
*/ |
||||||
|
export const personShapeSchema: CompactSchema = { |
||||||
|
"http://example.org/Person": { |
||||||
|
iri: "http://example.org/Person", |
||||||
|
predicates: [ |
||||||
|
{ |
||||||
|
type: "literal", |
||||||
|
literalValue: ["Person"], |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", |
||||||
|
readablePredicate: "type", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "string", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/name", |
||||||
|
readablePredicate: "name", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "nested", |
||||||
|
nestedSchema: "http://example.org/Person::http://example.org/address", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/address", |
||||||
|
readablePredicate: "address", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "boolean", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/hasChildren", |
||||||
|
readablePredicate: "hasChildren", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "number", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/numberOfHouses", |
||||||
|
readablePredicate: "numberOfHouses", |
||||||
|
}, |
||||||
|
], |
||||||
|
}, |
||||||
|
"http://example.org/Person::http://example.org/address": { |
||||||
|
iri: "http://example.org/Person::http://example.org/address", |
||||||
|
predicates: [ |
||||||
|
{ |
||||||
|
type: "string", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/street", |
||||||
|
readablePredicate: "street", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "string", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/houseNumber", |
||||||
|
readablePredicate: "houseNumber", |
||||||
|
}, |
||||||
|
], |
||||||
|
}, |
||||||
|
}; |
@ -0,0 +1,9 @@ |
|||||||
|
import type { CompactShapeType } from "@ldo/ldo"; |
||||||
|
import { personShapeSchema } from "./personShape.schema"; |
||||||
|
import type { Person } from "./personShape.typings"; |
||||||
|
|
||||||
|
// Compact ShapeTypes for personShape
|
||||||
|
export const PersonShapeType: CompactShapeType<Person> = { |
||||||
|
schema: personShapeSchema, |
||||||
|
shape: "http://example.org/Person", |
||||||
|
}; |
@ -0,0 +1,44 @@ |
|||||||
|
export type IRI = string; |
||||||
|
|
||||||
|
/** |
||||||
|
* ============================================================================= |
||||||
|
* Typescript Typings for personShape |
||||||
|
* ============================================================================= |
||||||
|
*/ |
||||||
|
|
||||||
|
/** |
||||||
|
* Person Type |
||||||
|
*/ |
||||||
|
export interface Person { |
||||||
|
id: IRI; |
||||||
|
/** |
||||||
|
* Original IRI: http://www.w3.org/1999/02/22-rdf-syntax-ns#type
|
||||||
|
*/ |
||||||
|
type: "Person"; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/name
|
||||||
|
*/ |
||||||
|
name: string; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/address
|
||||||
|
*/ |
||||||
|
address: { |
||||||
|
id: IRI; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/street
|
||||||
|
*/ |
||||||
|
street: string; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/houseNumber
|
||||||
|
*/ |
||||||
|
houseNumber: string; |
||||||
|
}; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/hasChildren
|
||||||
|
*/ |
||||||
|
hasChildren: boolean; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/numberOfHouses
|
||||||
|
*/ |
||||||
|
numberOfHouses: number; |
||||||
|
} |
@ -0,0 +1,121 @@ |
|||||||
|
import type { CompactSchema } from "@ldo/ldo"; |
||||||
|
|
||||||
|
/** |
||||||
|
* ============================================================================= |
||||||
|
* testShapeSchema: Compact Schema for testShape |
||||||
|
* ============================================================================= |
||||||
|
*/ |
||||||
|
export const testShapeSchema: CompactSchema = { |
||||||
|
"http://example.org/TestObject": { |
||||||
|
iri: "http://example.org/TestObject", |
||||||
|
predicates: [ |
||||||
|
{ |
||||||
|
type: "literal", |
||||||
|
literalValue: ["TestObject"], |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type", |
||||||
|
readablePredicate: "type", |
||||||
|
extra: true, |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "string", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/stringValue", |
||||||
|
readablePredicate: "stringValue", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "number", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/numValue", |
||||||
|
readablePredicate: "numValue", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "boolean", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/boolValue", |
||||||
|
readablePredicate: "boolValue", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "number", |
||||||
|
maxCardinality: -1, |
||||||
|
minCardinality: 0, |
||||||
|
predicateUri: "http://example.org/arrayValue", |
||||||
|
readablePredicate: "arrayValue", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "nested", |
||||||
|
nestedSchema: |
||||||
|
"http://example.org/TestObject::http://example.org/objectValue", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/objectValue", |
||||||
|
readablePredicate: "objectValue", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "nested", |
||||||
|
nestedSchema: |
||||||
|
"http://example.org/TestObject::http://example.org/anotherObject", |
||||||
|
maxCardinality: -1, |
||||||
|
minCardinality: 0, |
||||||
|
predicateUri: "http://example.org/anotherObject", |
||||||
|
readablePredicate: "anotherObject", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "string", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/numOrStr", |
||||||
|
readablePredicate: "numOrStr", |
||||||
|
}, |
||||||
|
], |
||||||
|
}, |
||||||
|
"http://example.org/TestObject::http://example.org/objectValue": { |
||||||
|
iri: "http://example.org/TestObject::http://example.org/objectValue", |
||||||
|
predicates: [ |
||||||
|
{ |
||||||
|
type: "string", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/nestedString", |
||||||
|
readablePredicate: "nestedString", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "number", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/nestedNum", |
||||||
|
readablePredicate: "nestedNum", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "number", |
||||||
|
maxCardinality: -1, |
||||||
|
minCardinality: 0, |
||||||
|
predicateUri: "http://example.org/nestedArray", |
||||||
|
readablePredicate: "nestedArray", |
||||||
|
}, |
||||||
|
], |
||||||
|
}, |
||||||
|
"http://example.org/TestObject::http://example.org/anotherObject": { |
||||||
|
iri: "http://example.org/TestObject::http://example.org/anotherObject", |
||||||
|
predicates: [ |
||||||
|
{ |
||||||
|
type: "string", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/prop1", |
||||||
|
readablePredicate: "prop1", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "number", |
||||||
|
maxCardinality: 1, |
||||||
|
minCardinality: 1, |
||||||
|
predicateUri: "http://example.org/prop2", |
||||||
|
readablePredicate: "prop2", |
||||||
|
}, |
||||||
|
], |
||||||
|
}, |
||||||
|
}; |
@ -0,0 +1,9 @@ |
|||||||
|
import type { CompactShapeType } from "@ldo/ldo"; |
||||||
|
import { testShapeSchema } from "./testShape.schema"; |
||||||
|
import type { TestObject } from "./testShape.typings"; |
||||||
|
|
||||||
|
// Compact ShapeTypes for testShape
|
||||||
|
export const TestObjectShapeType: CompactShapeType<TestObject> = { |
||||||
|
schema: testShapeSchema, |
||||||
|
shape: "http://example.org/TestObject", |
||||||
|
}; |
@ -0,0 +1,73 @@ |
|||||||
|
export type IRI = string; |
||||||
|
|
||||||
|
/** |
||||||
|
* ============================================================================= |
||||||
|
* Typescript Typings for testShape |
||||||
|
* ============================================================================= |
||||||
|
*/ |
||||||
|
|
||||||
|
/** |
||||||
|
* TestObject Type |
||||||
|
*/ |
||||||
|
export interface TestObject { |
||||||
|
id: IRI; |
||||||
|
/** |
||||||
|
* Original IRI: http://www.w3.org/1999/02/22-rdf-syntax-ns#type
|
||||||
|
*/ |
||||||
|
type: "TestObject"; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/stringValue
|
||||||
|
*/ |
||||||
|
stringValue: string; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/numValue
|
||||||
|
*/ |
||||||
|
numValue: number; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/boolValue
|
||||||
|
*/ |
||||||
|
boolValue: boolean; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/arrayValue
|
||||||
|
*/ |
||||||
|
arrayValue?: Set<number>; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/objectValue
|
||||||
|
*/ |
||||||
|
objectValue: { |
||||||
|
id: IRI; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/nestedString
|
||||||
|
*/ |
||||||
|
nestedString: string; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/nestedNum
|
||||||
|
*/ |
||||||
|
nestedNum: number; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/nestedArray
|
||||||
|
*/ |
||||||
|
nestedArray?: Set<number>; |
||||||
|
}; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/anotherObject
|
||||||
|
*/ |
||||||
|
anotherObject?: Record< |
||||||
|
IRI, |
||||||
|
{ |
||||||
|
id: IRI; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/prop1
|
||||||
|
*/ |
||||||
|
prop1: string; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/prop2
|
||||||
|
*/ |
||||||
|
prop2: number; |
||||||
|
} |
||||||
|
>; |
||||||
|
/** |
||||||
|
* Original IRI: http://example.org/numOrStr
|
||||||
|
*/ |
||||||
|
numOrStr: string; |
||||||
|
} |
@ -0,0 +1,15 @@ |
|||||||
|
PREFIX ex: <http://example.org/> |
||||||
|
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#> |
||||||
|
|
||||||
|
ex:Cat { |
||||||
|
a ["Cat"] ; |
||||||
|
ex:name xsd:string ; |
||||||
|
ex:age xsd:integer ; |
||||||
|
ex:numberOfHomes xsd:integer ; |
||||||
|
ex:address { |
||||||
|
ex:street xsd:string ; |
||||||
|
ex:houseNumber xsd:string ; |
||||||
|
ex:floor xsd:integer |
||||||
|
} |
||||||
|
} |
||||||
|
|
@ -0,0 +1,14 @@ |
|||||||
|
PREFIX ex: <http://example.org/> |
||||||
|
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#> |
||||||
|
|
||||||
|
ex:Person { |
||||||
|
a ["Person"] ; |
||||||
|
ex:name xsd:string ; |
||||||
|
ex:address { |
||||||
|
ex:street xsd:string ; |
||||||
|
ex:houseNumber xsd:string |
||||||
|
} ; |
||||||
|
ex:hasChildren xsd:boolean ; |
||||||
|
ex:numberOfHouses xsd:integer ; |
||||||
|
} |
||||||
|
|
@ -0,0 +1,21 @@ |
|||||||
|
PREFIX ex: <http://example.org/> |
||||||
|
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#> |
||||||
|
|
||||||
|
ex:TestObject EXTRA a { |
||||||
|
a ["TestObject"] ; |
||||||
|
ex:stringValue xsd:string ; |
||||||
|
ex:numValue xsd:integer ; |
||||||
|
ex:boolValue xsd:boolean ; |
||||||
|
ex:arrayValue xsd:integer* ; |
||||||
|
ex:objectValue { |
||||||
|
ex:nestedString xsd:string ; |
||||||
|
ex:nestedNum xsd:integer ; |
||||||
|
ex:nestedArray xsd:integer* ; |
||||||
|
} ; |
||||||
|
ex:anotherObject { |
||||||
|
ex:prop1 xsd:string; |
||||||
|
ex:prop2 xsd:integer ; |
||||||
|
} * ; |
||||||
|
ex:numOrStr xsd:string; |
||||||
|
# TODO: ShapeOr -- | ex:numOrStr xsd:integer |
||||||
|
} |
@ -0,0 +1,5 @@ |
|||||||
|
import { vitePreprocess } from "@astrojs/svelte"; |
||||||
|
|
||||||
|
export default { |
||||||
|
preprocess: vitePreprocess(), |
||||||
|
}; |
@ -0,0 +1,10 @@ |
|||||||
|
{ |
||||||
|
"extends": "astro/tsconfigs/strict", |
||||||
|
"compilerOptions": { |
||||||
|
"jsx": "react-jsx", |
||||||
|
"jsxImportSource": "react", |
||||||
|
"baseUrl": "." |
||||||
|
}, |
||||||
|
"include": [".astro/types.d.ts", "**/*"], |
||||||
|
"exclude": ["dist"] |
||||||
|
} |
@ -0,0 +1,24 @@ |
|||||||
|
# Logs |
||||||
|
logs |
||||||
|
*.log |
||||||
|
npm-debug.log* |
||||||
|
yarn-debug.log* |
||||||
|
yarn-error.log* |
||||||
|
pnpm-debug.log* |
||||||
|
lerna-debug.log* |
||||||
|
|
||||||
|
node_modules |
||||||
|
dist |
||||||
|
dist-ssr |
||||||
|
*.local |
||||||
|
|
||||||
|
# Editor directories and files |
||||||
|
.vscode/* |
||||||
|
!.vscode/extensions.json |
||||||
|
.idea |
||||||
|
.DS_Store |
||||||
|
*.suo |
||||||
|
*.sln |
||||||
|
*.sw? |
||||||
|
|
||||||
|
coverage |
@ -0,0 +1,168 @@ |
|||||||
|
# alien-deepsignals |
||||||
|
|
||||||
|
Deep structural reactivity for plain objects / arrays / Sets built on top of `alien-signals`. |
||||||
|
|
||||||
|
Core idea: wrap a data tree in a `Proxy` that lazily creates per-property signals the first time you read them. Accessing a property returns the plain value; accessing `$prop` returns the underlying signal function. Deep mutations emit compact batched patch objects you can observe with `watch()`. |
||||||
|
|
||||||
|
## Features |
||||||
|
|
||||||
|
* Lazy: signals & child proxies created only when touched. |
||||||
|
* Deep: nested objects, arrays, Sets proxied on demand. |
||||||
|
* [ ] TODO: Methods might not be proxied (e.g. array.push)? |
||||||
|
* Per-property signals: fine‑grained invalidation without traversal on each change. |
||||||
|
* Patch stream: microtask‑batched granular mutations (paths + op) for syncing external stores / framework adapters. |
||||||
|
* Getter => computed: property getters become derived (readonly) signals automatically. |
||||||
|
* `$` accessors: TypeScript exposes `$prop` for each non‑function key plus `$` / `$length` for arrays. |
||||||
|
* Sets: structural `add/delete/clear` emit patches; object entries get synthetic stable ids (prefers `id` / `@id` fields or auto‑generated blank IDs). |
||||||
|
* Shallow escape hatch: wrap sub-objects with `shallow(obj)` to track only reference replacement. |
||||||
|
|
||||||
|
## Install |
||||||
|
|
||||||
|
```bash |
||||||
|
pnpm add alien-deepsignals |
||||||
|
# or |
||||||
|
npm i alien-deepsignals |
||||||
|
``` |
||||||
|
|
||||||
|
## Quick start |
||||||
|
|
||||||
|
```ts |
||||||
|
import { deepSignal } from 'alien-deepsignals' |
||||||
|
|
||||||
|
const state = deepSignal({ |
||||||
|
count: 0, |
||||||
|
user: { name: 'Ada' }, |
||||||
|
items: [{ id: 'i1', qty: 1 }], |
||||||
|
settings: new Set(['dark']) |
||||||
|
}) |
||||||
|
|
||||||
|
state.count++ // mutate normally |
||||||
|
state.user.name = 'Grace' // nested write |
||||||
|
state.items.push({ id: 'i2', qty: 2 }) |
||||||
|
state.settings.add('beta') |
||||||
|
|
||||||
|
// Direct signal access |
||||||
|
state.$count!.set(5) // update via signal |
||||||
|
console.log(state.$count!()) // read via signal function |
||||||
|
``` |
||||||
|
|
||||||
|
## Watching patches |
||||||
|
|
||||||
|
`watch(root, cb, options?)` observes a deepSignal root and invokes your callback with microtask‑batched mutation patches plus snapshots. |
||||||
|
|
||||||
|
```ts |
||||||
|
import { watch } from 'alien-deepsignals' |
||||||
|
|
||||||
|
const stop = watch(state, ({ patches, oldValue, newValue }) => { |
||||||
|
for (const p of patches) { |
||||||
|
console.log(p.op, p.path.join('.'), 'value' in p ? p.value : p.type) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
state.user.name = 'Lin' |
||||||
|
state.items[0].qty = 3 |
||||||
|
await Promise.resolve() // flush microtask |
||||||
|
stop() |
||||||
|
``` |
||||||
|
|
||||||
|
### Callback event shape |
||||||
|
|
||||||
|
```ts |
||||||
|
type WatchPatchEvent<T> = { |
||||||
|
patches: DeepPatch[] // empty only on immediate |
||||||
|
oldValue: T | undefined // deep-cloned snapshot before batch |
||||||
|
newValue: T // live proxy (already mutated) |
||||||
|
registerCleanup(fn): void // register disposer for next batch/stop |
||||||
|
stopListening(): void // unsubscribe |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
### Options |
||||||
|
|
||||||
|
| Option | Type | Default | Description | |
||||||
|
|--------|------|---------|-------------| |
||||||
|
| `immediate` | boolean | false | Fire once right away with `patches: []`. | |
||||||
|
| `once` | boolean | false | Auto stop after first callback (immediate counts). | |
||||||
|
|
||||||
|
`observe()` is an alias of `watch()`. |
||||||
|
|
||||||
|
## DeepPatch format |
||||||
|
|
||||||
|
```ts |
||||||
|
type DeepPatch = { |
||||||
|
root: symbol // stable id per deepSignal root |
||||||
|
path: (string | number)[] // root-relative segments |
||||||
|
} & ( |
||||||
|
| { op: 'add'; type: 'object' } // assigned object/array/Set entry object |
||||||
|
| { op: 'add'; value: string | number | boolean } // primitive write |
||||||
|
| { op: 'remove' } // deletion |
||||||
|
| { op: 'add'; type: 'set'; value: [] } // Set.clear() |
||||||
|
| { op: 'add'; type: 'set'; value: (string|number|boolean)[] | { [id: string]: object } } // (reserved) |
||||||
|
) |
||||||
|
``` |
||||||
|
|
||||||
|
Notes: |
||||||
|
* `type:'object'` omits value to avoid deep cloning; read from `newValue` if needed. |
||||||
|
* `Set.add(entry)` emits object vs primitive form depending on entry type; path ends with synthetic id. |
||||||
|
* `Set.clear()` emits one structural patch and suppresses per‑entry removals in same batch. |
||||||
|
|
||||||
|
## Sets & synthetic ids |
||||||
|
|
||||||
|
Object entries inside Sets need a stable key. Priority: |
||||||
|
1. `entry.id` |
||||||
|
2. `entry['@id']` |
||||||
|
3. Custom via `setSetEntrySyntheticId(entry, 'myId')` before `add` |
||||||
|
4. Auto `_bN` blank id |
||||||
|
|
||||||
|
Helpers: |
||||||
|
```ts |
||||||
|
import { addWithId, setSetEntrySyntheticId } from 'alien-deepsignals' |
||||||
|
|
||||||
|
setSetEntrySyntheticId(obj, 'custom') |
||||||
|
state.settings.add(obj) |
||||||
|
addWithId(state.settings as any, { x:1 }, 'x1') |
||||||
|
``` |
||||||
|
|
||||||
|
## Shallow |
||||||
|
|
||||||
|
Skip deep proxying of a subtree (only reference replacement tracked): |
||||||
|
```ts |
||||||
|
import { shallow } from 'alien-deepsignals' |
||||||
|
state.config = shallow({ huge: { blob: true } }) |
||||||
|
``` |
||||||
|
|
||||||
|
## TypeScript ergonomics |
||||||
|
|
||||||
|
`DeepSignal<T>` exposes both plain properties and optional `$prop` signal accessors (excluded for function members). Arrays add `$` (index signal map) and `$length`. |
||||||
|
|
||||||
|
```ts |
||||||
|
const state = deepSignal({ count: 0, user: { name: 'A' } }) |
||||||
|
state.count++ // ok |
||||||
|
state.$count!.set(9) // write via signal |
||||||
|
const n: number = state.$count!() // typed number |
||||||
|
``` |
||||||
|
|
||||||
|
## API surface |
||||||
|
|
||||||
|
| Function | Description | |
||||||
|
|----------|-------------| |
||||||
|
| `deepSignal(obj)` | Create (or reuse) reactive deep proxy. | |
||||||
|
| `watch(root, cb, opts?)` | Observe batched deep mutations. | |
||||||
|
| `observe(root, cb, opts?)` | Alias of `watch`. | |
||||||
|
| `peek(obj,key)` | Untracked property read. | |
||||||
|
| `shallow(obj)` | Mark object to skip deep proxying. | |
||||||
|
| `isDeepSignal(val)` | Runtime predicate. | |
||||||
|
| `isShallow(val)` | Was value marked shallow. | |
||||||
|
| `setSetEntrySyntheticId(obj,id)` | Assign custom Set entry id. | |
||||||
|
| `addWithId(set, entry, id)` | Insert with desired synthetic id. | |
||||||
|
| `subscribeDeepMutations(root, cb)` | Low-level patch stream (used by watch). | |
||||||
|
|
||||||
|
## Credits |
||||||
|
|
||||||
|
Inspired by [deepsignal](https://github.com/luisherranz/deepsignal) – thanks to @luisherranz. Re-imagined with patch batching & Set support. |
||||||
|
|
||||||
|
## License |
||||||
|
|
||||||
|
MIT |
||||||
|
|
||||||
|
|
@ -0,0 +1,53 @@ |
|||||||
|
{ |
||||||
|
"name": "@nextgraph-monorepo/ng-alien-deepsignals", |
||||||
|
"version": "0.1.0", |
||||||
|
"private": false, |
||||||
|
"author": "CCherry07", |
||||||
|
"license": "MIT", |
||||||
|
"main": "dist/index.js", |
||||||
|
"module": "dist/index.mjs", |
||||||
|
"types": "dist/index.d.ts", |
||||||
|
"files": [ |
||||||
|
"dist" |
||||||
|
], |
||||||
|
"exports": { |
||||||
|
".": { |
||||||
|
"import": "./dist/index.mjs", |
||||||
|
"require": "./dist/index.js" |
||||||
|
} |
||||||
|
}, |
||||||
|
"description": "AlienDeepSignals 🧶 -alien signals, but using regular JavaScript objects", |
||||||
|
"scripts": { |
||||||
|
"test": "vitest --coverage", |
||||||
|
"dev": "tsup --watch src", |
||||||
|
"build": "tsup", |
||||||
|
"release": "bumpp && npm run build && npm publish --registry=https://registry.npmjs.org/" |
||||||
|
}, |
||||||
|
"dependencies": { |
||||||
|
"alien-signals": "^2.0.7" |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"@types/node": "^22.10.9", |
||||||
|
"@vitest/coverage-v8": "3.0.2", |
||||||
|
"bumpp": "^9.9.2", |
||||||
|
"tsup": "^8.3.5", |
||||||
|
"typescript": "^5.4.3", |
||||||
|
"vitest": "^3.0.2" |
||||||
|
}, |
||||||
|
"repository": { |
||||||
|
"type": "git", |
||||||
|
"url": "git+https://github.com/CCherry07/alien-deepsignals.git" |
||||||
|
}, |
||||||
|
"keywords": [ |
||||||
|
"signal", |
||||||
|
"signals", |
||||||
|
"deepsignals", |
||||||
|
"alien-signals", |
||||||
|
"alien-deepsignals" |
||||||
|
], |
||||||
|
"bugs": { |
||||||
|
"url": "https://github.com/CCherry07/alien-deepsignals/issues" |
||||||
|
}, |
||||||
|
"homepage": "https://github.com/CCherry07/alien-deepsignals#readme", |
||||||
|
"packageManager": "pnpm@9.14.2+sha512.6e2baf77d06b9362294152c851c4f278ede37ab1eba3a55fda317a4a17b209f4dbb973fb250a77abc463a341fcb1f17f17cfa24091c4eb319cda0d9b84278387" |
||||||
|
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,5 @@ |
|||||||
|
export enum ReactiveFlags { |
||||||
|
IS_SIGNAL = '__v_isSignal', |
||||||
|
SKIP = "__v_skip", |
||||||
|
IS_SHALLOW = "__v_isShallow", |
||||||
|
} |
@ -0,0 +1,163 @@ |
|||||||
|
/** Lightweight façade adding ergonomic helpers (.value/.peek/.get/.set) to native alien-signals function signals. */ |
||||||
|
// Native re-exports for advanced usage.
|
||||||
|
export { |
||||||
|
signal as _rawSignal, |
||||||
|
computed as _rawComputed, |
||||||
|
effect, |
||||||
|
startBatch, |
||||||
|
endBatch, |
||||||
|
getCurrentSub, |
||||||
|
setCurrentSub, |
||||||
|
} from "alien-signals"; |
||||||
|
|
||||||
|
import { |
||||||
|
signal as alienSignal, |
||||||
|
computed as alienComputed, |
||||||
|
effect as alienEffect, |
||||||
|
startBatch as alienStartBatch, |
||||||
|
endBatch as alienEndBatch, |
||||||
|
} from "alien-signals"; |
||||||
|
import { ReactiveFlags as ReactiveFlags_ } from "./contents"; |
||||||
|
import { isFunction } from "./utils"; |
||||||
|
|
||||||
|
// Nominal constructor removal: we no longer expose classes; signals are plain tagged functions.
|
||||||
|
|
||||||
|
/** Internal shape of a tagged writable signal after adding ergonomic helpers. */ |
||||||
|
type TaggedSignal<T> = ReturnType<typeof alienSignal<T>> & { |
||||||
|
/** Tracking read / write via property syntax */ |
||||||
|
value: T; |
||||||
|
/** Non-tracking read */ |
||||||
|
peek(): T; |
||||||
|
/** Alias for tracking read */ |
||||||
|
get(): T; |
||||||
|
/** Write helper */ |
||||||
|
set(v: T): void; |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* Decorate a native signal function with legacy helpers & identity. |
||||||
|
*/ |
||||||
|
function tagSignal(fn: any): TaggedSignal<any> { |
||||||
|
Object.defineProperty(fn, ReactiveFlags_.IS_SIGNAL, { value: true }); |
||||||
|
Object.defineProperty(fn, "value", { |
||||||
|
get: () => fn(), |
||||||
|
set: (v) => fn(v), |
||||||
|
}); |
||||||
|
// Add peek to mirror old API (non-tracking read)
|
||||||
|
if (!fn.peek) Object.defineProperty(fn, "peek", { value: () => fn() }); |
||||||
|
if (!fn.get) Object.defineProperty(fn, "get", { value: () => fn() }); |
||||||
|
if (!fn.set) Object.defineProperty(fn, "set", { value: (v: any) => fn(v) }); |
||||||
|
return fn; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Decorate a native computed function similarly (readonly value accessor). |
||||||
|
*/ |
||||||
|
function tagComputed(fn: any) { |
||||||
|
Object.defineProperty(fn, ReactiveFlags_.IS_SIGNAL, { value: true }); |
||||||
|
Object.defineProperty(fn, "value", { get: () => fn() }); |
||||||
|
if (!fn.peek) Object.defineProperty(fn, "peek", { value: () => fn() }); |
||||||
|
if (!fn.get) Object.defineProperty(fn, "get", { value: () => fn() }); |
||||||
|
return fn; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create a new writable function-form signal enhanced with `.value`, `.peek()`, `.get()`, `.set()`. |
||||||
|
* |
||||||
|
* @example |
||||||
|
* const count = signal(0); |
||||||
|
* count(); // 0 (track)
|
||||||
|
* count(1); // write
|
||||||
|
* count.value; // 1 (track)
|
||||||
|
* count.peek(); // 1 (non-tracking)
|
||||||
|
*/ |
||||||
|
export const signal = <T>(v?: T) => tagSignal(alienSignal(v)); |
||||||
|
/** |
||||||
|
* Create a lazy computed (readonly) signal derived from other signals. |
||||||
|
* The returned function is tagged with `.value` and `.peek()` for convenience. |
||||||
|
*/ |
||||||
|
export const computed = <T>(getter: () => T) => |
||||||
|
tagComputed(alienComputed(getter)); |
||||||
|
|
||||||
|
/** Union allowing a plain value or a writable signal wrapping that value. */ |
||||||
|
export type MaybeSignal<T = any> = T | ReturnType<typeof signal>; |
||||||
|
/** Union allowing value, writable signal, computed signal or plain getter function. */ |
||||||
|
export type MaybeSignalOrGetter<T = any> = |
||||||
|
| MaybeSignal<T> |
||||||
|
| ReturnType<typeof computed> |
||||||
|
| (() => T); |
||||||
|
/** Runtime guard that an unknown value is one of our tagged signals/computeds. */ |
||||||
|
export const isSignal = (s: any): boolean => |
||||||
|
typeof s === "function" && !!s && !!s[ReactiveFlags_.IS_SIGNAL]; |
||||||
|
|
||||||
|
/** |
||||||
|
* Minimal Effect wrapper for legacy watch implementation. |
||||||
|
* Provides: active, dirty, scheduler hook, run() & stop(). |
||||||
|
*/ |
||||||
|
/** |
||||||
|
* Minimal Effect wrapper mimicking the legacy interface used by the watch implementation. |
||||||
|
* |
||||||
|
* Each instance wraps a native alien `effect`, setting `dirty=true` on invalidation and invoking |
||||||
|
* the provided scheduler callback. Consumers may manually `run()` the getter (marks clean) or `stop()` |
||||||
|
* to dispose the underlying reactive subscription. |
||||||
|
*/ |
||||||
|
export class Effect { |
||||||
|
public active = true; |
||||||
|
public dirty = true; |
||||||
|
public scheduler: (immediateFirstRun?: boolean) => void = () => {}; |
||||||
|
private _runner: any; |
||||||
|
constructor(private _getter: () => any) { |
||||||
|
const self = this; |
||||||
|
this._runner = alienEffect(function wrapped() { |
||||||
|
self.dirty = true; |
||||||
|
self._getter(); |
||||||
|
self.scheduler(); |
||||||
|
}); |
||||||
|
} |
||||||
|
run() { |
||||||
|
this.dirty = false; |
||||||
|
return this._getter(); |
||||||
|
} |
||||||
|
stop() { |
||||||
|
if (this.active) { |
||||||
|
this._runner(); |
||||||
|
this.active = false; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
/** Resolve a plain value, a signal/computed or a getter function to its current value. */ |
||||||
|
// Lightweight direct resolver (inlined former toValue/unSignal logic)
|
||||||
|
/** |
||||||
|
* Resolve a possibly reactive input to its current value. |
||||||
|
* Accepts: plain value, writable signal, computed signal, or getter function. |
||||||
|
* Signals & getters are invoked once; plain values are returned directly. |
||||||
|
*/ |
||||||
|
export function toValue<T>(src: MaybeSignalOrGetter<T>): T { |
||||||
|
return isFunction(src) |
||||||
|
? (src as any)() |
||||||
|
: isSignal(src) |
||||||
|
? (src as any)() |
||||||
|
: (src as any); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Execute multiple signal writes in a single batched update frame. |
||||||
|
* All downstream computed/effect re-evaluations are deferred until the function exits. |
||||||
|
* |
||||||
|
* IMPORTANT: The callback MUST be synchronous. If it returns a Promise the batch will |
||||||
|
* still end immediately after scheduling, possibly causing mid-async flushes. |
||||||
|
* |
||||||
|
* @example |
||||||
|
* batch(() => { |
||||||
|
* count(count() + 1); |
||||||
|
* other(other() + 2); |
||||||
|
* }); // effects observing both run only once
|
||||||
|
*/ |
||||||
|
export function batch<T>(fn: () => T): T { |
||||||
|
alienStartBatch(); |
||||||
|
try { |
||||||
|
return fn(); |
||||||
|
} finally { |
||||||
|
alienEndBatch(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,833 @@ |
|||||||
|
import { ReactiveFlags } from "./contents"; |
||||||
|
import { computed, signal, isSignal } from "./core"; |
||||||
|
/** |
||||||
|
* deepSignal: wrap an object / array / Set graph in lazy per-property signals plus an optional deep patch stream. |
||||||
|
* - `$prop` returns a signal; plain prop returns its current value. |
||||||
|
* - Getter props become computed signals. |
||||||
|
* - Arrays expose `$` (index signals) & `$length`; Sets emit structural entry patches with synthetic ids. |
||||||
|
* - subscribeDeepMutations(root, cb) batches set/delete ops per microtask (DeepPatch[]). |
||||||
|
* - shallow(obj) skips deep proxying of a subtree. |
||||||
|
*/ |
||||||
|
|
||||||
|
/** A batched deep mutation (set/add/remove) from a deepSignal root. */ |
||||||
|
export type DeepPatch = { |
||||||
|
/** Unique identifier for the deep signal root which produced this patch. */ |
||||||
|
root: symbol; |
||||||
|
/** Property path (array indices, object keys, synthetic Set entry ids) from the root to the mutated location. */ |
||||||
|
path: (string | number)[]; |
||||||
|
} & ( |
||||||
|
| DeepSetAddPatch |
||||||
|
| DeepSetRemovePatch |
||||||
|
| DeepObjectAddPatch |
||||||
|
| DeepRemovePatch |
||||||
|
| DeepLiteralAddPatch |
||||||
|
); |
||||||
|
export interface DeepSetAddPatch { |
||||||
|
/** Mutation kind applied at the resolved `path`. */ |
||||||
|
op: "add"; |
||||||
|
type: "set"; |
||||||
|
/** New value for `set` mutations (omitted for `delete`). */ |
||||||
|
value: (number | string | boolean)[] | { [id: string]: object }; |
||||||
|
} |
||||||
|
export interface DeepSetRemovePatch { |
||||||
|
/** Mutation kind applied at the resolved `path`. */ |
||||||
|
op: "remove"; |
||||||
|
type: "set"; |
||||||
|
/** The value to be removed from the set. Either a literal or the key (id) of an object. */ |
||||||
|
value: string | number | boolean; |
||||||
|
} |
||||||
|
export interface DeepObjectAddPatch { |
||||||
|
/** Mutation kind applied at the resolved `path`. */ |
||||||
|
op: "add"; |
||||||
|
type: "object"; |
||||||
|
} |
||||||
|
|
||||||
|
export interface DeepRemovePatch { |
||||||
|
/** Mutation kind applied at the resolved `path`. */ |
||||||
|
op: "remove"; |
||||||
|
} |
||||||
|
export interface DeepLiteralAddPatch { |
||||||
|
/** Mutation kind applied at the resolved `path` */ |
||||||
|
op: "add"; |
||||||
|
/** The literal value to be added at the resolved `path` */ |
||||||
|
value: string | number | boolean; |
||||||
|
} |
||||||
|
|
||||||
|
/** Callback signature for subscribeDeepMutations. */ |
||||||
|
export type DeepPatchSubscriber = (patches: DeepPatch[]) => void; |
||||||
|
|
||||||
|
/** Minimal per-proxy metadata for path reconstruction. */ |
||||||
|
interface ProxyMeta { |
||||||
|
/** Parent proxy in the object graph (undefined for root). */ |
||||||
|
parent?: object; |
||||||
|
/** Key within the parent pointing to this proxy (undefined for root). */ |
||||||
|
key?: string | number; |
||||||
|
/** Stable root id symbol shared by the entire deepSignal tree. */ |
||||||
|
root: symbol; |
||||||
|
} |
||||||
|
|
||||||
|
// Proxy -> metadata
|
||||||
|
const proxyMeta = new WeakMap<object, ProxyMeta>(); |
||||||
|
// Root symbol -> subscribers
|
||||||
|
const mutationSubscribers = new Map<symbol, Set<DeepPatchSubscriber>>(); |
||||||
|
// Pending patches grouped per root (flushed once per microtask)
|
||||||
|
let pendingPatches: Map<symbol, DeepPatch[]> | null = null; |
||||||
|
let microtaskScheduled = false; |
||||||
|
|
||||||
|
/** Sentinel symbol; get concrete root id via getDeepSignalRootId(proxy). */ |
||||||
|
export const DEEP_SIGNAL_ROOT_ID = Symbol("alienDeepSignalRootId"); |
||||||
|
|
||||||
|
function buildPath( |
||||||
|
startProxy: object, |
||||||
|
leafKey: string | number |
||||||
|
): (string | number)[] { |
||||||
|
const path: (string | number)[] = [leafKey]; |
||||||
|
let cur: object | undefined = startProxy; |
||||||
|
while (cur) { |
||||||
|
const meta = proxyMeta.get(cur); |
||||||
|
if (!meta) break; // Defensive: metadata should always exist.
|
||||||
|
if (meta.key === undefined) break; // Reached root (no key recorded).
|
||||||
|
path.unshift(meta.key); |
||||||
|
cur = meta.parent; |
||||||
|
} |
||||||
|
return path; |
||||||
|
} |
||||||
|
|
||||||
|
function queuePatch(patch: DeepPatch) { |
||||||
|
if (!pendingPatches) pendingPatches = new Map(); |
||||||
|
const root = patch.root; |
||||||
|
let list = pendingPatches.get(root); |
||||||
|
if (!list) { |
||||||
|
list = []; |
||||||
|
pendingPatches.set(root, list); |
||||||
|
} |
||||||
|
list.push(patch); |
||||||
|
if (!microtaskScheduled) { |
||||||
|
microtaskScheduled = true; |
||||||
|
queueMicrotask(() => { |
||||||
|
microtaskScheduled = false; |
||||||
|
const groups = pendingPatches; |
||||||
|
pendingPatches = null; |
||||||
|
if (!groups) return; |
||||||
|
for (const [rootId, patches] of groups) { |
||||||
|
if (!patches.length) continue; |
||||||
|
const subs = mutationSubscribers.get(rootId); |
||||||
|
if (subs) subs.forEach((cb) => cb(patches)); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** Subscribe to microtask-batched deep patches for a root (returns unsubscribe). */ |
||||||
|
export function subscribeDeepMutations( |
||||||
|
root: object | symbol, |
||||||
|
sub: DeepPatchSubscriber |
||||||
|
): () => void { |
||||||
|
const rootId = typeof root === "symbol" ? root : getDeepSignalRootId(root); |
||||||
|
if (!rootId) |
||||||
|
throw new Error( |
||||||
|
"subscribeDeepMutations() expects a deepSignal root proxy or root id symbol" |
||||||
|
); |
||||||
|
let set = mutationSubscribers.get(rootId); |
||||||
|
if (!set) { |
||||||
|
set = new Set(); |
||||||
|
mutationSubscribers.set(rootId, set); |
||||||
|
} |
||||||
|
set.add(sub); |
||||||
|
return () => { |
||||||
|
const bucket = mutationSubscribers.get(rootId); |
||||||
|
if (!bucket) return; |
||||||
|
bucket.delete(sub); |
||||||
|
if (bucket.size === 0) mutationSubscribers.delete(rootId); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** Return the stable root symbol for any deepSignal proxy (undefined if not one). */ |
||||||
|
export function getDeepSignalRootId(obj: any): symbol | undefined { |
||||||
|
return proxyMeta.get(obj)?.root; |
||||||
|
} |
||||||
|
|
||||||
|
// Proxy -> Map of property name -> signal function
|
||||||
|
/** Proxy -> Map<propertyName, signalFn> (lazy). */ |
||||||
|
const proxyToSignals = new WeakMap(); |
||||||
|
// Raw object/array/Set -> stable proxy
|
||||||
|
const objToProxy = new WeakMap(); |
||||||
|
// Raw array -> `$` meta proxy with index signals
|
||||||
|
const arrayToArrayOfSignals = new WeakMap(); |
||||||
|
// Objects already proxied or marked shallow
|
||||||
|
const ignore = new WeakSet(); |
||||||
|
// Object -> signal counter for enumeration invalidation
|
||||||
|
const objToIterable = new WeakMap(); |
||||||
|
const rg = /^\$/; |
||||||
|
const descriptor = Object.getOwnPropertyDescriptor; |
||||||
|
let peeking = false; |
||||||
|
|
||||||
|
// Deep array interface refining callback parameter types.
|
||||||
|
type DeepArray<T> = Array<T> & { |
||||||
|
map: <U>( |
||||||
|
callbackfn: ( |
||||||
|
value: DeepSignal<T>, |
||||||
|
index: number, |
||||||
|
array: DeepSignalArray<T[]> |
||||||
|
) => U, |
||||||
|
thisArg?: any |
||||||
|
) => U[]; |
||||||
|
forEach: ( |
||||||
|
callbackfn: ( |
||||||
|
value: DeepSignal<T>, |
||||||
|
index: number, |
||||||
|
array: DeepSignalArray<T[]> |
||||||
|
) => void, |
||||||
|
thisArg?: any |
||||||
|
) => void; |
||||||
|
concat(...items: ConcatArray<T>[]): DeepSignalArray<T[]>; |
||||||
|
concat(...items: (T | ConcatArray<T>)[]): DeepSignalArray<T[]>; |
||||||
|
reverse(): DeepSignalArray<T[]>; |
||||||
|
shift(): DeepSignal<T> | undefined; |
||||||
|
slice(start?: number, end?: number): DeepSignalArray<T[]>; |
||||||
|
splice(start: number, deleteCount?: number): DeepSignalArray<T[]>; |
||||||
|
splice( |
||||||
|
start: number, |
||||||
|
deleteCount: number, |
||||||
|
...items: T[] |
||||||
|
): DeepSignalArray<T[]>; |
||||||
|
filter<S extends T>( |
||||||
|
predicate: ( |
||||||
|
value: DeepSignal<T>, |
||||||
|
index: number, |
||||||
|
array: DeepSignalArray<T[]> |
||||||
|
) => value is DeepSignal<S>, |
||||||
|
thisArg?: any |
||||||
|
): DeepSignalArray<S[]>; |
||||||
|
filter( |
||||||
|
predicate: ( |
||||||
|
value: DeepSignal<T>, |
||||||
|
index: number, |
||||||
|
array: DeepSignalArray<T[]> |
||||||
|
) => unknown, |
||||||
|
thisArg?: any |
||||||
|
): DeepSignalArray<T[]>; |
||||||
|
reduce( |
||||||
|
callbackfn: ( |
||||||
|
previousValue: DeepSignal<T>, |
||||||
|
currentValue: DeepSignal<T>, |
||||||
|
currentIndex: number, |
||||||
|
array: DeepSignalArray<T[]> |
||||||
|
) => T |
||||||
|
): DeepSignal<T>; |
||||||
|
reduce( |
||||||
|
callbackfn: ( |
||||||
|
previousValue: DeepSignal<T>, |
||||||
|
currentValue: DeepSignal<T>, |
||||||
|
currentIndex: number, |
||||||
|
array: DeepSignalArray<T[]> |
||||||
|
) => DeepSignal<T>, |
||||||
|
initialValue: T |
||||||
|
): DeepSignal<T>; |
||||||
|
reduce<U>( |
||||||
|
callbackfn: ( |
||||||
|
previousValue: U, |
||||||
|
currentValue: DeepSignal<T>, |
||||||
|
currentIndex: number, |
||||||
|
array: DeepSignalArray<T[]> |
||||||
|
) => U, |
||||||
|
initialValue: U |
||||||
|
): U; |
||||||
|
reduceRight( |
||||||
|
callbackfn: ( |
||||||
|
previousValue: DeepSignal<T>, |
||||||
|
currentValue: DeepSignal<T>, |
||||||
|
currentIndex: number, |
||||||
|
array: DeepSignalArray<T[]> |
||||||
|
) => T |
||||||
|
): DeepSignal<T>; |
||||||
|
reduceRight( |
||||||
|
callbackfn: ( |
||||||
|
previousValue: DeepSignal<T>, |
||||||
|
currentValue: DeepSignal<T>, |
||||||
|
currentIndex: number, |
||||||
|
array: DeepSignalArray<T[]> |
||||||
|
) => DeepSignal<T>, |
||||||
|
initialValue: T |
||||||
|
): DeepSignal<T>; |
||||||
|
reduceRight<U>( |
||||||
|
callbackfn: ( |
||||||
|
previousValue: U, |
||||||
|
currentValue: DeepSignal<T>, |
||||||
|
currentIndex: number, |
||||||
|
array: DeepSignalArray<T[]> |
||||||
|
) => U, |
||||||
|
initialValue: U |
||||||
|
): U; |
||||||
|
}; |
||||||
|
// Synthetic ids for Set entry objects (stable key for patches)
|
||||||
|
let __blankNodeCounter = 0; |
||||||
|
const setObjectIds = new WeakMap<object, string>(); |
||||||
|
const assignBlankNodeId = (obj: any) => { |
||||||
|
if (setObjectIds.has(obj)) return setObjectIds.get(obj)!; |
||||||
|
const id = `_b${++__blankNodeCounter}`; |
||||||
|
setObjectIds.set(obj, id); |
||||||
|
return id; |
||||||
|
}; |
||||||
|
/** Assign (or override) synthetic id before Set.add(). */ |
||||||
|
export function setSetEntrySyntheticId(obj: object, id: string | number) { |
||||||
|
setObjectIds.set(obj, String(id)); |
||||||
|
} |
||||||
|
const getSetEntryKey = (val: any): string | number => { |
||||||
|
if (val && typeof val === "object") { |
||||||
|
if (setObjectIds.has(val)) return setObjectIds.get(val)!; |
||||||
|
if ( |
||||||
|
typeof (val as any).id === "string" || |
||||||
|
typeof (val as any).id === "number" |
||||||
|
) |
||||||
|
return (val as any).id; |
||||||
|
if ( |
||||||
|
typeof (val as any)["@id"] === "string" || |
||||||
|
typeof (val as any)["@id"] === "number" |
||||||
|
) |
||||||
|
return (val as any)["@id"]; |
||||||
|
return assignBlankNodeId(val); |
||||||
|
} |
||||||
|
return val as any; |
||||||
|
}; |
||||||
|
/** Add entry with synthetic id; returns proxied object if applicable. */ |
||||||
|
export function addWithId<T extends object>( |
||||||
|
set: Set<T>, |
||||||
|
entry: T, |
||||||
|
id: string | number |
||||||
|
): DeepSignal<T>; |
||||||
|
export function addWithId<T>(set: Set<T>, entry: T, id: string | number): T; |
||||||
|
export function addWithId(set: Set<any>, entry: any, id: string | number) { |
||||||
|
if (entry && typeof entry === "object") setSetEntrySyntheticId(entry, id); |
||||||
|
(set as any).add(entry); |
||||||
|
if (entry && typeof entry === "object" && objToProxy.has(entry)) |
||||||
|
return objToProxy.get(entry); |
||||||
|
return entry; |
||||||
|
} |
||||||
|
|
||||||
|
/** Is value a deepSignal-managed proxy? */ |
||||||
|
export const isDeepSignal = (source: any) => { |
||||||
|
return proxyToSignals.has(source); |
||||||
|
}; |
||||||
|
|
||||||
|
/** Was value explicitly marked shallow? */ |
||||||
|
export const isShallow = (source: any) => { |
||||||
|
return ignore.has(source); |
||||||
|
}; |
||||||
|
|
||||||
|
/** Create (or reuse) a deep reactive proxy for an object / array / Set. */ |
||||||
|
export const deepSignal = <T extends object>(obj: T): DeepSignal<T> => { |
||||||
|
if (!shouldProxy(obj)) throw new Error("This object can't be observed."); |
||||||
|
if (!objToProxy.has(obj)) { |
||||||
|
// Create a unique root id symbol to identify this deep signal tree in patches.
|
||||||
|
const rootId = Symbol("deepSignalRoot"); |
||||||
|
const proxy = createProxy(obj, objectHandlers, rootId) as DeepSignal<T>; |
||||||
|
const meta = proxyMeta.get(proxy)!; |
||||||
|
meta.parent = undefined; // root has no parent
|
||||||
|
meta.key = undefined; // root not addressed by a key
|
||||||
|
meta.root = rootId; // ensure root id stored (explicit)
|
||||||
|
// Pre-register an empty signals map so isDeepSignal() is true before any property access.
|
||||||
|
if (!proxyToSignals.has(proxy)) proxyToSignals.set(proxy, new Map()); |
||||||
|
objToProxy.set(obj, proxy); |
||||||
|
} |
||||||
|
return objToProxy.get(obj); |
||||||
|
}; |
||||||
|
|
||||||
|
/** Read property without tracking (untracked read). */ |
||||||
|
export const peek = < |
||||||
|
T extends DeepSignalObject<object>, |
||||||
|
K extends keyof RevertDeepSignalObject<T> |
||||||
|
>( |
||||||
|
obj: T, |
||||||
|
key: K |
||||||
|
): RevertDeepSignal<RevertDeepSignalObject<T>[K]> => { |
||||||
|
peeking = true; |
||||||
|
const value = obj[key]; |
||||||
|
try { |
||||||
|
peeking = false; |
||||||
|
} catch (e) {} |
||||||
|
return value as RevertDeepSignal<RevertDeepSignalObject<T>[K]>; |
||||||
|
}; |
||||||
|
|
||||||
|
const shallowFlag = Symbol(ReactiveFlags.IS_SHALLOW); |
||||||
|
/** Mark object to skip deep proxying (only reference changes tracked). */ |
||||||
|
export function shallow<T extends object>(obj: T): Shallow<T> { |
||||||
|
ignore.add(obj); |
||||||
|
return obj as Shallow<T>; |
||||||
|
} |
||||||
|
|
||||||
|
const createProxy = ( |
||||||
|
target: object, |
||||||
|
handlers: ProxyHandler<object>, |
||||||
|
rootId?: symbol |
||||||
|
) => { |
||||||
|
const proxy = new Proxy(target, handlers); |
||||||
|
ignore.add(proxy); |
||||||
|
// Initialize proxy metadata if not present. Root proxies provide a stable root id.
|
||||||
|
if (!proxyMeta.has(proxy)) { |
||||||
|
proxyMeta.set(proxy, { root: rootId || Symbol("deepSignalDetachedRoot") }); |
||||||
|
} |
||||||
|
return proxy; |
||||||
|
}; |
||||||
|
|
||||||
|
// Set-specific access & structural patch emission.
|
||||||
|
function getFromSet( |
||||||
|
raw: Set<any>, |
||||||
|
key: string | symbol, |
||||||
|
receiver: object |
||||||
|
): any { |
||||||
|
const meta = proxyMeta.get(receiver); |
||||||
|
// Helper to proxy a single entry (object) & assign synthetic id if needed.
|
||||||
|
const ensureEntryProxy = (entry: any) => { |
||||||
|
if ( |
||||||
|
entry && |
||||||
|
typeof entry === "object" && |
||||||
|
shouldProxy(entry) && |
||||||
|
!objToProxy.has(entry) |
||||||
|
) { |
||||||
|
const synthetic = getSetEntryKey(entry); |
||||||
|
const childProxy = createProxy(entry, objectHandlers, meta!.root); |
||||||
|
const childMeta = proxyMeta.get(childProxy)!; |
||||||
|
childMeta.parent = receiver; |
||||||
|
childMeta.key = synthetic; |
||||||
|
objToProxy.set(entry, childProxy); |
||||||
|
return childProxy; |
||||||
|
} |
||||||
|
if (objToProxy.has(entry)) return objToProxy.get(entry); |
||||||
|
return entry; |
||||||
|
}; |
||||||
|
// Pre-pass to ensure any existing non-proxied object entries are proxied (enables deep patches after iteration)
|
||||||
|
if (meta) raw.forEach(ensureEntryProxy); |
||||||
|
if (key === "add" || key === "delete" || key === "clear") { |
||||||
|
const fn: Function = (raw as any)[key]; |
||||||
|
return function (this: any, ...args: any[]) { |
||||||
|
const sizeBefore = raw.size; |
||||||
|
const result = fn.apply(raw, args); |
||||||
|
if (raw.size !== sizeBefore) { |
||||||
|
const metaNow = proxyMeta.get(receiver); |
||||||
|
if ( |
||||||
|
metaNow && |
||||||
|
metaNow.parent !== undefined && |
||||||
|
metaNow.key !== undefined |
||||||
|
) { |
||||||
|
const containerPath = buildPath(metaNow.parent, metaNow.key); |
||||||
|
if (key === "add") { |
||||||
|
const entry = args[0]; |
||||||
|
let synthetic = getSetEntryKey(entry); |
||||||
|
if (entry && typeof entry === "object") { |
||||||
|
for (const existing of raw.values()) { |
||||||
|
if (existing === entry) continue; |
||||||
|
if (getSetEntryKey(existing) === synthetic) { |
||||||
|
synthetic = assignBlankNodeId(entry); |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
let entryVal = entry; |
||||||
|
if ( |
||||||
|
entryVal && |
||||||
|
typeof entryVal === "object" && |
||||||
|
shouldProxy(entryVal) && |
||||||
|
!objToProxy.has(entryVal) |
||||||
|
) { |
||||||
|
const childProxy = createProxy( |
||||||
|
entryVal, |
||||||
|
objectHandlers, |
||||||
|
metaNow.root |
||||||
|
); |
||||||
|
const childMeta = proxyMeta.get(childProxy)!; |
||||||
|
childMeta.parent = receiver; |
||||||
|
childMeta.key = synthetic; |
||||||
|
objToProxy.set(entryVal, childProxy); |
||||||
|
entryVal = childProxy; |
||||||
|
} |
||||||
|
// Set entry add: emit object vs literal variant.
|
||||||
|
if (entryVal && typeof entryVal === "object") { |
||||||
|
queuePatch({ |
||||||
|
root: metaNow.root, |
||||||
|
path: [...containerPath, synthetic], |
||||||
|
op: "add", |
||||||
|
type: "object", |
||||||
|
}); |
||||||
|
} else { |
||||||
|
queuePatch({ |
||||||
|
root: metaNow.root, |
||||||
|
path: [...containerPath, synthetic], |
||||||
|
op: "add", |
||||||
|
value: entryVal, |
||||||
|
}); |
||||||
|
} |
||||||
|
} else if (key === "delete") { |
||||||
|
const entry = args[0]; |
||||||
|
const synthetic = getSetEntryKey(entry); |
||||||
|
queuePatch({ |
||||||
|
root: metaNow.root, |
||||||
|
path: [...containerPath, synthetic], |
||||||
|
op: "remove", |
||||||
|
}); |
||||||
|
} else if (key === "clear") { |
||||||
|
// Structural clear: remove prior entry-level patches for this Set this tick.
|
||||||
|
if (pendingPatches) { |
||||||
|
const group = pendingPatches.get(metaNow.root); |
||||||
|
if (group && group.length) { |
||||||
|
for (let i = group.length - 1; i >= 0; i--) { |
||||||
|
const p = group[i]; |
||||||
|
if ( |
||||||
|
p.path.length === containerPath.length + 1 && |
||||||
|
containerPath.every((seg, idx) => p.path[idx] === seg) |
||||||
|
) { |
||||||
|
group.splice(i, 1); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
queuePatch({ |
||||||
|
root: metaNow.root, |
||||||
|
path: containerPath, |
||||||
|
op: "add", |
||||||
|
type: "set", |
||||||
|
value: [], |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return result; |
||||||
|
}; |
||||||
|
} |
||||||
|
const makeIterator = (pair: boolean) => { |
||||||
|
return function thisIter(this: any) { |
||||||
|
const iterable = raw.values(); |
||||||
|
return { |
||||||
|
[Symbol.iterator]() { |
||||||
|
return { |
||||||
|
next() { |
||||||
|
const n = iterable.next(); |
||||||
|
if (n.done) return n; |
||||||
|
const entry = ensureEntryProxy(n.value); |
||||||
|
return { value: pair ? [entry, entry] : entry, done: false }; |
||||||
|
}, |
||||||
|
}; |
||||||
|
}, |
||||||
|
} as Iterable<any>; |
||||||
|
}; |
||||||
|
}; |
||||||
|
if (key === "values" || key === "keys") return makeIterator(false); |
||||||
|
if (key === "entries") return makeIterator(true); |
||||||
|
if (key === "forEach") { |
||||||
|
return function thisForEach(this: any, cb: any, thisArg?: any) { |
||||||
|
raw.forEach((entry: any) => { |
||||||
|
cb.call(thisArg, ensureEntryProxy(entry), ensureEntryProxy(entry), raw); |
||||||
|
}); |
||||||
|
}; |
||||||
|
} |
||||||
|
// Properly handle native iteration (for..of, Array.from, spread) by binding to the raw Set.
|
||||||
|
if (key === Symbol.iterator) { |
||||||
|
// Return a function whose `this` is the raw Set (avoids brand check failure on the proxy).
|
||||||
|
return function (this: any) { |
||||||
|
// Use raw.values() so we can still ensure child entries are proxied lazily.
|
||||||
|
const iterable = raw.values(); |
||||||
|
return { |
||||||
|
[Symbol.iterator]() { |
||||||
|
return this; |
||||||
|
}, |
||||||
|
next() { |
||||||
|
const n = iterable.next(); |
||||||
|
if (n.done) return n; |
||||||
|
const entry = ensureEntryProxy(n.value); |
||||||
|
return { value: entry, done: false }; |
||||||
|
}, |
||||||
|
} as Iterator<any>; |
||||||
|
}; |
||||||
|
} |
||||||
|
if (key === Symbol.iterator.toString()) { |
||||||
|
// string form access of iterator symbol; pass through (rare path)
|
||||||
|
} |
||||||
|
const val = (raw as any)[key]; |
||||||
|
if (typeof val === "function") return val.bind(raw); |
||||||
|
return val; |
||||||
|
} |
||||||
|
|
||||||
|
const throwOnMutation = () => { |
||||||
|
throw new Error( |
||||||
|
"Don't mutate the signals directly (use the underlying property/value instead)." |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
// Does target define a getter for key?
|
||||||
|
function hasGetter(target: any, key: any) { |
||||||
|
return typeof descriptor(target, key)?.get === "function"; |
||||||
|
} |
||||||
|
|
||||||
|
// Lazily allocate / fetch signal map for a proxy receiver.
|
||||||
|
function getSignals(receiver: object) { |
||||||
|
if (!proxyToSignals.has(receiver)) proxyToSignals.set(receiver, new Map()); |
||||||
|
return proxyToSignals.get(receiver)!; |
||||||
|
} |
||||||
|
|
||||||
|
// Wrap & link child object/array/Set if needed.
|
||||||
|
function ensureChildProxy(value: any, parent: object, key: string | number) { |
||||||
|
if (!shouldProxy(value)) return value; |
||||||
|
if (!objToProxy.has(value)) { |
||||||
|
const parentMeta = proxyMeta.get(parent)!; |
||||||
|
const childProxy = createProxy(value, objectHandlers, parentMeta.root); |
||||||
|
const childMeta = proxyMeta.get(childProxy)!; |
||||||
|
childMeta.parent = parent; |
||||||
|
childMeta.key = key as string; |
||||||
|
objToProxy.set(value, childProxy); |
||||||
|
} |
||||||
|
return objToProxy.get(value); |
||||||
|
} |
||||||
|
|
||||||
|
// Normalize raw property key (handles $-prefix & array meta) -> { key, returnSignal }
|
||||||
|
function normalizeKey( |
||||||
|
target: any, |
||||||
|
fullKey: string, |
||||||
|
isArrayMeta: boolean, |
||||||
|
receiver: object |
||||||
|
) { |
||||||
|
let returnSignal = isArrayMeta || fullKey[0] === "$"; |
||||||
|
if (!isArrayMeta && Array.isArray(target) && returnSignal) { |
||||||
|
if (fullKey === "$") { |
||||||
|
// Provide $ meta proxy for array index signals
|
||||||
|
if (!arrayToArrayOfSignals.has(target)) { |
||||||
|
arrayToArrayOfSignals.set( |
||||||
|
target, |
||||||
|
createProxy(target, arrayHandlers, proxyMeta.get(receiver)?.root) |
||||||
|
); |
||||||
|
} |
||||||
|
return { shortCircuit: arrayToArrayOfSignals.get(target) }; |
||||||
|
} |
||||||
|
returnSignal = fullKey === "$length"; |
||||||
|
} |
||||||
|
const key = returnSignal ? fullKey.replace(rg, "") : fullKey; |
||||||
|
return { key, returnSignal } as any; |
||||||
|
} |
||||||
|
|
||||||
|
// Create computed signal for getter property if needed.
|
||||||
|
function ensureComputed( |
||||||
|
signals: Map<any, any>, |
||||||
|
target: any, |
||||||
|
key: any, |
||||||
|
receiver: any |
||||||
|
) { |
||||||
|
if (!signals.has(key) && hasGetter(target, key)) { |
||||||
|
signals.set( |
||||||
|
key, |
||||||
|
computed(() => Reflect.get(target, key, receiver)) |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Unified get trap factory (object / array meta variant)
|
||||||
|
const get = |
||||||
|
(isArrayMeta: boolean) => |
||||||
|
(target: object, fullKey: string, receiver: object): unknown => { |
||||||
|
if (peeking) return Reflect.get(target, fullKey, receiver); |
||||||
|
// Set handling delegated completely.
|
||||||
|
if (target instanceof Set) { |
||||||
|
return getFromSet(target as Set<any>, fullKey as any, receiver); |
||||||
|
} |
||||||
|
const norm = normalizeKey(target, fullKey, isArrayMeta, receiver); |
||||||
|
if ((norm as any).shortCircuit) return (norm as any).shortCircuit; // returned meta proxy
|
||||||
|
const { key, returnSignal } = norm as { |
||||||
|
key: string; |
||||||
|
returnSignal: boolean; |
||||||
|
}; |
||||||
|
// Symbol fast-path
|
||||||
|
if (typeof key === "symbol" && wellKnownSymbols.has(key)) |
||||||
|
return Reflect.get(target, key, receiver); |
||||||
|
const signals = getSignals(receiver); |
||||||
|
ensureComputed(signals, target, key, receiver); |
||||||
|
if (!signals.has(key)) { |
||||||
|
let value = Reflect.get(target, key, receiver); |
||||||
|
if (returnSignal && typeof value === "function") return; // user asked for signal wrapper of function => ignore
|
||||||
|
value = ensureChildProxy(value, receiver, key); |
||||||
|
signals.set(key, signal(value)); |
||||||
|
} |
||||||
|
const sig = signals.get(key); |
||||||
|
return returnSignal ? sig : sig(); |
||||||
|
}; |
||||||
|
|
||||||
|
// Standard object / array handlers
|
||||||
|
const objectHandlers = { |
||||||
|
get: get(false), |
||||||
|
set(target: object, fullKey: string, val: any, receiver: object): boolean { |
||||||
|
// Respect original getter/setter semantics
|
||||||
|
if (typeof descriptor(target, fullKey)?.set === "function") |
||||||
|
return Reflect.set(target, fullKey, val, receiver); |
||||||
|
if (!proxyToSignals.has(receiver)) proxyToSignals.set(receiver, new Map()); |
||||||
|
const signals = proxyToSignals.get(receiver); |
||||||
|
if (fullKey[0] === "$") { |
||||||
|
if (!isSignal(val)) throwOnMutation(); |
||||||
|
const key = fullKey.replace(rg, ""); |
||||||
|
signals.set(key, val); |
||||||
|
return Reflect.set(target, key, val.peek(), receiver); |
||||||
|
} else { |
||||||
|
let internal = val; |
||||||
|
if (shouldProxy(val)) { |
||||||
|
if (!objToProxy.has(val)) { |
||||||
|
// Link newly wrapped child to its parent for path reconstruction.
|
||||||
|
// In some edge cases parent metadata might not yet be initialized (e.g.,
|
||||||
|
// if a proxied structure was reconstructed in a way that bypassed the
|
||||||
|
// original deepSignal root path). Fall back to creating/assigning it.
|
||||||
|
let parentMeta = proxyMeta.get(receiver); |
||||||
|
if (!parentMeta) { |
||||||
|
// Assign a root id (new symbol) so downstream patches remain groupable.
|
||||||
|
const created: ProxyMeta = { |
||||||
|
root: Symbol("deepSignalRootAuto"), |
||||||
|
} as ProxyMeta; |
||||||
|
proxyMeta.set(receiver, created); |
||||||
|
parentMeta = created; |
||||||
|
} |
||||||
|
const childProxy = createProxy(val, objectHandlers, parentMeta!.root); |
||||||
|
const childMeta = proxyMeta.get(childProxy)!; |
||||||
|
childMeta.parent = receiver; |
||||||
|
childMeta.key = fullKey; |
||||||
|
objToProxy.set(val, childProxy); |
||||||
|
} |
||||||
|
internal = objToProxy.get(val); |
||||||
|
} |
||||||
|
const isNew = !(fullKey in target); |
||||||
|
const result = Reflect.set(target, fullKey, val, receiver); |
||||||
|
|
||||||
|
if (!signals.has(fullKey)) { |
||||||
|
// First write after structure change -> create signal.
|
||||||
|
signals.set(fullKey, signal(internal)); |
||||||
|
} else { |
||||||
|
// Subsequent writes -> update underlying signal.
|
||||||
|
signals.get(fullKey).set(internal); |
||||||
|
} |
||||||
|
if (isNew && objToIterable.has(target)) objToIterable.get(target).value++; |
||||||
|
if (Array.isArray(target) && signals.has("length")) |
||||||
|
signals.get("length").set(target.length); |
||||||
|
// Emit patch (after mutation) so subscribers get final value snapshot.
|
||||||
|
const meta = proxyMeta.get(receiver); |
||||||
|
if (meta) { |
||||||
|
// Object/Array/Set assignment at property path.
|
||||||
|
if (val && typeof val === "object") { |
||||||
|
queuePatch({ |
||||||
|
root: meta.root, |
||||||
|
path: buildPath(receiver, fullKey), |
||||||
|
op: "add", |
||||||
|
type: "object", |
||||||
|
}); |
||||||
|
} else { |
||||||
|
queuePatch({ |
||||||
|
root: meta.root, |
||||||
|
path: buildPath(receiver, fullKey), |
||||||
|
op: "add", |
||||||
|
value: val, |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
return result; |
||||||
|
} |
||||||
|
}, |
||||||
|
deleteProperty(target: object, key: string): boolean { |
||||||
|
if (key[0] === "$") throwOnMutation(); |
||||||
|
const signals = proxyToSignals.get(objToProxy.get(target)); |
||||||
|
const result = Reflect.deleteProperty(target, key); |
||||||
|
if (signals && signals.has(key)) signals.get(key).value = undefined; |
||||||
|
objToIterable.has(target) && objToIterable.get(target).value++; |
||||||
|
// Emit deletion patch
|
||||||
|
const receiverProxy = objToProxy.get(target); |
||||||
|
const meta = receiverProxy && proxyMeta.get(receiverProxy); |
||||||
|
if (meta) { |
||||||
|
queuePatch({ |
||||||
|
root: meta.root, |
||||||
|
path: buildPath(receiverProxy, key), |
||||||
|
op: "remove", |
||||||
|
}); |
||||||
|
} |
||||||
|
return result; |
||||||
|
}, |
||||||
|
ownKeys(target: object): (string | symbol)[] { |
||||||
|
if (!objToIterable.has(target)) objToIterable.set(target, signal(0)); |
||||||
|
(objToIterable as any)._ = objToIterable.get(target).get(); |
||||||
|
return Reflect.ownKeys(target); |
||||||
|
}, |
||||||
|
}; |
||||||
|
|
||||||
|
// Array `$` meta proxy handlers (index signals only)
|
||||||
|
const arrayHandlers = { |
||||||
|
get: get(true), |
||||||
|
set: throwOnMutation, |
||||||
|
deleteProperty: throwOnMutation, |
||||||
|
}; |
||||||
|
|
||||||
|
const wellKnownSymbols = new Set( |
||||||
|
Object.getOwnPropertyNames(Symbol) |
||||||
|
.map((key) => Symbol[key as WellKnownSymbols]) |
||||||
|
.filter((value) => typeof value === "symbol") |
||||||
|
); |
||||||
|
// Supported constructors (Map intentionally excluded for now)
|
||||||
|
const supported = new Set([Object, Array, Set]); |
||||||
|
const shouldProxy = (val: any): boolean => { |
||||||
|
if (typeof val !== "object" || val === null) return false; |
||||||
|
return supported.has(val.constructor) && !ignore.has(val); |
||||||
|
}; |
||||||
|
|
||||||
|
/** TYPES **/ // Structural deep reactive view of an input type.
|
||||||
|
export type DeepSignal<T> = T extends Function |
||||||
|
? T |
||||||
|
: T extends { [shallowFlag]: true } |
||||||
|
? T |
||||||
|
: T extends Array<unknown> |
||||||
|
? DeepSignalArray<T> |
||||||
|
: T extends object |
||||||
|
? DeepSignalObject<T> |
||||||
|
: T; |
||||||
|
|
||||||
|
/** Recursive mapped type converting an object graph into its deepSignal proxy shape. */ |
||||||
|
export type DeepSignalObject<T extends object> = { |
||||||
|
[P in keyof T & string as `$${P}`]?: T[P] extends Function |
||||||
|
? never |
||||||
|
: ReturnType<typeof signal<T[P]>>; |
||||||
|
} & { |
||||||
|
[P in keyof T]: DeepSignal<T[P]>; |
||||||
|
}; |
||||||
|
|
||||||
|
/** Extract element type from an array. */ |
||||||
|
type ArrayType<T> = T extends Array<infer I> ? I : T; |
||||||
|
/** DeepSignal-enhanced array type (numeric indices & `$` meta accessors). */ |
||||||
|
type DeepSignalArray<T> = DeepArray<ArrayType<T>> & { |
||||||
|
[key: number]: DeepSignal<ArrayType<T>>; |
||||||
|
$?: { [key: number]: ReturnType<typeof signal<ArrayType<T>>> }; |
||||||
|
$length?: ReturnType<typeof signal<number>>; |
||||||
|
}; |
||||||
|
|
||||||
|
/** Marker utility type for objects passed through without deep proxying. */ |
||||||
|
export type Shallow<T extends object> = T & { [shallowFlag]: true }; |
||||||
|
|
||||||
|
/** Framework adapter hook returning a DeepSignal proxy. */ |
||||||
|
export declare const useDeepSignal: <T extends object>(obj: T) => DeepSignal<T>; |
||||||
|
// @ts-ignore
|
||||||
|
// Strip `$`-prefixed synthetic signal accessors from key union.
|
||||||
|
type FilterSignals<K> = K extends `$${string}` ? never : K; |
||||||
|
/** Reverse of DeepSignalObject: remove signal accessors to obtain original object shape. */ |
||||||
|
type RevertDeepSignalObject<T> = Pick<T, FilterSignals<keyof T>>; |
||||||
|
/** Reverse of DeepSignalArray: omit meta accessors. */ |
||||||
|
type RevertDeepSignalArray<T> = Omit<T, "$" | "$length">; |
||||||
|
|
||||||
|
/** Inverse mapped type removing deepSignal wrapper affordances. */ |
||||||
|
export type RevertDeepSignal<T> = T extends Array<unknown> |
||||||
|
? RevertDeepSignalArray<T> |
||||||
|
: T extends object |
||||||
|
? RevertDeepSignalObject<T> |
||||||
|
: T; |
||||||
|
|
||||||
|
/** Subset of ECMAScript well-known symbols we explicitly pass through without proxy wrapping. */ |
||||||
|
type WellKnownSymbols = |
||||||
|
| "asyncIterator" |
||||||
|
| "hasInstance" |
||||||
|
| "isConcatSpreadable" |
||||||
|
| "iterator" |
||||||
|
| "match" |
||||||
|
| "matchAll" |
||||||
|
| "replace" |
||||||
|
| "search" |
||||||
|
| "species" |
||||||
|
| "split" |
||||||
|
| "toPrimitive" |
||||||
|
| "toStringTag" |
||||||
|
| "unscopables"; |
@ -0,0 +1,17 @@ |
|||||||
|
export * from "./core"; |
||||||
|
export * from "./deepSignal"; |
||||||
|
export * from "./watch"; |
||||||
|
export * from "./watchEffect"; |
||||||
|
export { |
||||||
|
isArray, |
||||||
|
isDate, |
||||||
|
isFunction, |
||||||
|
isMap, |
||||||
|
isObject, |
||||||
|
isPlainObject, |
||||||
|
isPromise, |
||||||
|
isRegExp, |
||||||
|
isSet, |
||||||
|
isString, |
||||||
|
isSymbol, |
||||||
|
} from "./utils"; |
@ -0,0 +1,81 @@ |
|||||||
|
import { describe, it, expect } from "vitest"; |
||||||
|
import { signal, computed, isSignal, Effect, toValue } from "../core"; |
||||||
|
import { deepSignal } from "../deepSignal"; |
||||||
|
|
||||||
|
describe("core.ts coverage", () => { |
||||||
|
it("signal tagging helpers (.value/.peek/.get/.set)", () => { |
||||||
|
const s: any = signal(1); |
||||||
|
expect(isSignal(s)).toBe(true); |
||||||
|
expect(s.value).toBe(1); |
||||||
|
expect(s.peek()).toBe(1); |
||||||
|
expect(s.get()).toBe(1); |
||||||
|
s.set(2); |
||||||
|
expect(s.value).toBe(2); |
||||||
|
s.value = 3; |
||||||
|
expect(s.peek()).toBe(3); |
||||||
|
}); |
||||||
|
|
||||||
|
it("computed tagging helpers (.value/.peek/.get)", () => { |
||||||
|
const s: any = signal(2); |
||||||
|
const c: any = computed(() => s.value * 2); |
||||||
|
expect(isSignal(c)).toBe(true); |
||||||
|
expect(c.value).toBe(4); |
||||||
|
expect(c.peek()).toBe(4); |
||||||
|
expect(c.get()).toBe(4); |
||||||
|
s.value = 3; |
||||||
|
expect(c.value).toBe(6); |
||||||
|
}); |
||||||
|
|
||||||
|
it("toValue resolves function, signal and plain value", () => { |
||||||
|
const s: any = signal(5); |
||||||
|
const fn = () => 10; |
||||||
|
expect(toValue(fn)).toBe(10); |
||||||
|
expect(toValue(s)).toBe(5); |
||||||
|
expect(toValue(42)).toBe(42); |
||||||
|
}); |
||||||
|
|
||||||
|
it("Effect wrapper run/stop behavior", () => { |
||||||
|
let runs = 0; |
||||||
|
const eff = new Effect(() => { |
||||||
|
runs++; |
||||||
|
}); |
||||||
|
// Constructing Effect registers alienEffect and schedules first run immediately when dependency accessed (none here), run() executes getter
|
||||||
|
eff.run(); |
||||||
|
// Construction may trigger an initial scheduler pass; ensure at least 1
|
||||||
|
expect(runs).toBeGreaterThanOrEqual(1); |
||||||
|
// Add scheduler side effect and dependency in second effect
|
||||||
|
const dep = signal(0); |
||||||
|
const eff2 = new Effect(() => { |
||||||
|
dep(); |
||||||
|
runs++; |
||||||
|
}); |
||||||
|
const base = runs; |
||||||
|
dep.set(1); // triggers wrapped effect, increments runs again
|
||||||
|
expect(runs).toBeGreaterThan(base); |
||||||
|
eff2.stop(); |
||||||
|
const prev = runs; |
||||||
|
dep.set(2); // no further increment after stop
|
||||||
|
expect(runs).toBe(prev); |
||||||
|
// stopping already stopped effect has no effect
|
||||||
|
eff2.stop(); |
||||||
|
expect(runs).toBe(prev); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe("deepSignal.ts extra branches", () => { |
||||||
|
it("access well-known symbol property returns raw value and not a signal", () => { |
||||||
|
const tag = Symbol.toStringTag; |
||||||
|
const ds = deepSignal({ [tag]: "Custom", x: 1 }) as any; |
||||||
|
const val = ds[tag]; |
||||||
|
expect(val).toBe("Custom"); |
||||||
|
}); |
||||||
|
|
||||||
|
it("access Set Symbol.iterator.toString() key path (skip branch)", () => { |
||||||
|
const ds = deepSignal({ set: new Set([1]) }) as any; |
||||||
|
const iterKey = Symbol.iterator.toString(); // 'Symbol(Symbol.iterator)'
|
||||||
|
// Accessing this string property triggers skip branch (no special handling needed)
|
||||||
|
const maybe = ds.set[iterKey]; |
||||||
|
// underlying Set likely has undefined for that string key
|
||||||
|
expect(maybe).toBeUndefined(); |
||||||
|
}); |
||||||
|
}); |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,47 @@ |
|||||||
|
import { describe, it, expect, beforeEach } from "vitest"; |
||||||
|
import { deepSignal } from "../deepSignal"; |
||||||
|
import { watch } from "../watch"; |
||||||
|
|
||||||
|
// Goal: demonstrate that patchOptimized deep watch performs fewer traversals
|
||||||
|
// than standard deep watch for the same batch of nested mutations.
|
||||||
|
// We use the exported __traverseCount instrumentation to measure how many
|
||||||
|
// times traverse() executes under each strategy.
|
||||||
|
|
||||||
|
describe("watch patch-only simplified performance placeholder", () => { |
||||||
|
let store: any; |
||||||
|
const build = (breadth = 3, depth = 3) => { |
||||||
|
const make = (d: number): any => { |
||||||
|
if (d === 0) return { v: 0 }; |
||||||
|
const obj: any = {}; |
||||||
|
for (let i = 0; i < breadth; i++) obj["k" + i] = make(d - 1); |
||||||
|
return obj; |
||||||
|
}; |
||||||
|
return make(depth); |
||||||
|
}; |
||||||
|
|
||||||
|
beforeEach(() => { |
||||||
|
store = deepSignal(build()); |
||||||
|
}); |
||||||
|
|
||||||
|
function mutateAll(breadth = 3, depth = 3) { |
||||||
|
const visit = (node: any, d: number) => { |
||||||
|
if (d === 0) { |
||||||
|
node.v++; |
||||||
|
return; |
||||||
|
} |
||||||
|
for (let i = 0; i < breadth; i++) visit(node["k" + i], d - 1); |
||||||
|
}; |
||||||
|
visit(store, depth); |
||||||
|
} |
||||||
|
|
||||||
|
it("receives a single batch of patches after deep mutations", async () => { |
||||||
|
let batches = 0; |
||||||
|
const { stopListening: stop } = watch(store, ({ patches }) => { |
||||||
|
if (patches.length) batches++; |
||||||
|
}); |
||||||
|
mutateAll(); |
||||||
|
await Promise.resolve(); |
||||||
|
expect(batches).toBe(1); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,148 @@ |
|||||||
|
import { describe, it, expect } from "vitest"; |
||||||
|
import { deepSignal, getDeepSignalRootId, peek } from "../deepSignal"; |
||||||
|
import { |
||||||
|
watch, |
||||||
|
__traverseCount, |
||||||
|
__resetTraverseCount, |
||||||
|
traverse, |
||||||
|
} from "../watch"; |
||||||
|
import { effect } from "../core"; |
||||||
|
|
||||||
|
describe("watch advanced", () => { |
||||||
|
it("basic patch watcher fires on deep mutations", async () => { |
||||||
|
const st = deepSignal({ a: { b: { c: 1 } } }); |
||||||
|
let batches: number = 0; |
||||||
|
watch(st, ({ patches }) => { |
||||||
|
if (patches.length) batches++; |
||||||
|
}); |
||||||
|
st.a.b.c = 2; |
||||||
|
st.a.b = { c: 3 } as any; |
||||||
|
await Promise.resolve(); |
||||||
|
expect(batches).toBeGreaterThan(0); |
||||||
|
}); |
||||||
|
|
||||||
|
// multi-source value mode removed; patch-only now - skip equivalent
|
||||||
|
|
||||||
|
// getter source value mode removed in patch-only watcher
|
||||||
|
|
||||||
|
it("watch once option still stops after first batch", async () => { |
||||||
|
const st = deepSignal({ a: 1 }); |
||||||
|
let count = 0; |
||||||
|
watch( |
||||||
|
st, |
||||||
|
() => { |
||||||
|
count++; |
||||||
|
}, |
||||||
|
{ once: true, immediate: true } |
||||||
|
); |
||||||
|
st.a = 2; |
||||||
|
st.a = 3; |
||||||
|
await Promise.resolve(); |
||||||
|
expect(count).toBe(1); |
||||||
|
}); |
||||||
|
|
||||||
|
// observe value mode removed; observe is alias of watch
|
||||||
|
}); |
||||||
|
|
||||||
|
describe("patches & root ids", () => { |
||||||
|
it("root ids are unique", () => { |
||||||
|
const a = deepSignal({}); |
||||||
|
const b = deepSignal({}); |
||||||
|
expect(getDeepSignalRootId(a)).not.toBe(getDeepSignalRootId(b)); |
||||||
|
}); |
||||||
|
|
||||||
|
// legacy watchPatches API removed; patch mode only valid for deepSignal roots
|
||||||
|
it("watch throws on non-deepSignal input", () => { |
||||||
|
expect(() => watch({} as any, () => {})).toThrow(); |
||||||
|
}); |
||||||
|
|
||||||
|
it("Map unsupported does not emit patches", async () => { |
||||||
|
const m = new Map<string, number>(); |
||||||
|
const st = deepSignal({ m }); |
||||||
|
const patches: any[] = []; |
||||||
|
const { stopListening: stop } = watch(st, ({ patches: batch }) => |
||||||
|
patches.push(batch) |
||||||
|
); |
||||||
|
m.set("a", 1); |
||||||
|
await Promise.resolve(); |
||||||
|
await Promise.resolve(); |
||||||
|
expect(patches.length).toBe(0); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe("tier3: Set iteration variants", () => { |
||||||
|
it("entries() iteration proxies nested mutation", async () => { |
||||||
|
const st = deepSignal({ s: new Set<any>() }); |
||||||
|
st.s.add({ id: "eEnt", inner: { v: 1 } }); |
||||||
|
const paths: string[] = []; |
||||||
|
const { stopListening: stop } = watch(st, ({ patches }) => |
||||||
|
paths.push(...patches.map((pp: any) => pp.path.join("."))) |
||||||
|
); |
||||||
|
for (const [val] of st.s.entries()) { |
||||||
|
(val as any).inner.v; |
||||||
|
} // ensure proxy
|
||||||
|
for (const [val] of st.s.entries()) { |
||||||
|
(val as any).inner.v = 2; |
||||||
|
} |
||||||
|
await Promise.resolve(); |
||||||
|
await Promise.resolve(); |
||||||
|
expect(paths.some((p) => p.endsWith("eEnt.inner.v"))).toBe(true); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
|
||||||
|
it("forEach iteration proxies nested mutation", async () => { |
||||||
|
const st = deepSignal({ s: new Set<any>() }); |
||||||
|
st.s.add({ id: "fe1", data: { n: 1 } }); |
||||||
|
const { stopListening: stop } = watch(st, () => {}); |
||||||
|
st.s.forEach((e) => (e as any).data.n); // access
|
||||||
|
st.s.forEach((e) => { |
||||||
|
(e as any).data.n = 2; |
||||||
|
}); |
||||||
|
await Promise.resolve(); |
||||||
|
await Promise.resolve(); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
|
||||||
|
it("keys() iteration returns proxies", async () => { |
||||||
|
const st = deepSignal({ s: new Set<any>() }); |
||||||
|
st.s.add({ id: "k1", foo: { x: 1 } }); |
||||||
|
const { stopListening: stop } = watch(st, () => {}); |
||||||
|
for (const e of st.s.keys()) { |
||||||
|
(e as any).foo.x = 2; |
||||||
|
} |
||||||
|
await Promise.resolve(); |
||||||
|
await Promise.resolve(); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe("tier3: peek behavior", () => { |
||||||
|
it("peek does not create reactive dependency on property", async () => { |
||||||
|
const st = deepSignal({ a: 1 }); |
||||||
|
let runs = 0; |
||||||
|
effect(() => { |
||||||
|
runs++; |
||||||
|
peek(st, "a"); |
||||||
|
}); |
||||||
|
expect(runs).toBe(1); |
||||||
|
st.a = 2; |
||||||
|
// Flush microtasks
|
||||||
|
await Promise.resolve(); |
||||||
|
await Promise.resolve(); |
||||||
|
expect(runs).toBe(1); // no rerun
|
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe("tier3: traverse helper direct calls (symbols & sets)", () => { |
||||||
|
it("traverse counts and respects depth param", () => { |
||||||
|
__resetTraverseCount(); |
||||||
|
const obj: any = { a: { b: { c: 1 } } }; |
||||||
|
traverse(obj, 1); |
||||||
|
const shallowCount = __traverseCount; |
||||||
|
__resetTraverseCount(); |
||||||
|
traverse(obj, 3); |
||||||
|
const deepCount = __traverseCount; |
||||||
|
expect(deepCount).toBeGreaterThan(shallowCount); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,91 @@ |
|||||||
|
import { describe, expect, it } from "vitest"; |
||||||
|
import { deepSignal } from "../deepSignal"; |
||||||
|
import { watch } from "../watch"; |
||||||
|
import { watchEffect } from "../watchEffect"; |
||||||
|
|
||||||
|
describe("watch", () => { |
||||||
|
it("watch immediate", () => { |
||||||
|
const store = deepSignal({ |
||||||
|
userinfo: { |
||||||
|
name: "tom", |
||||||
|
}, |
||||||
|
}); |
||||||
|
let val!: string; |
||||||
|
watch( |
||||||
|
store, |
||||||
|
({ newValue }) => { |
||||||
|
val = newValue.userinfo.name; |
||||||
|
}, |
||||||
|
{ immediate: true } |
||||||
|
); |
||||||
|
expect(val).toEqual("tom"); |
||||||
|
}); |
||||||
|
it("watch deep", () => { |
||||||
|
const store = deepSignal({ |
||||||
|
userinfo: { |
||||||
|
name: "tom", |
||||||
|
}, |
||||||
|
}); |
||||||
|
let val!: string; |
||||||
|
watch( |
||||||
|
store, |
||||||
|
({ newValue }) => { |
||||||
|
val = newValue.userinfo.name; |
||||||
|
}, |
||||||
|
{ immediate: true } |
||||||
|
); |
||||||
|
let value2!: string; |
||||||
|
watch( |
||||||
|
store, |
||||||
|
({ newValue }) => { |
||||||
|
value2 = newValue.userinfo.name; |
||||||
|
}, |
||||||
|
{ immediate: true } |
||||||
|
); |
||||||
|
expect(val).toEqual("tom"); |
||||||
|
store.userinfo.name = "jon"; |
||||||
|
// patch delivery async (microtask)
|
||||||
|
return Promise.resolve().then(() => { |
||||||
|
expect(val).toEqual("jon"); |
||||||
|
// With refactored watch using native effect, shallow watcher now also updates root reference
|
||||||
|
expect(value2).toEqual("jon"); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it("watch once", () => { |
||||||
|
const store = deepSignal({ |
||||||
|
userinfo: { |
||||||
|
name: "tom", |
||||||
|
}, |
||||||
|
}); |
||||||
|
let val!: string; |
||||||
|
watch( |
||||||
|
store, |
||||||
|
({ newValue }) => { |
||||||
|
val = newValue.userinfo.name; |
||||||
|
}, |
||||||
|
{ immediate: true, once: true } |
||||||
|
); |
||||||
|
|
||||||
|
expect(val).toEqual("tom"); |
||||||
|
store.userinfo.name = "jon"; |
||||||
|
// once watcher shouldn't update after first run
|
||||||
|
expect(val).toEqual("tom"); |
||||||
|
}); |
||||||
|
|
||||||
|
it("watch effect", () => { |
||||||
|
const store = deepSignal({ |
||||||
|
userinfo: { |
||||||
|
name: "tom", |
||||||
|
}, |
||||||
|
}); |
||||||
|
let x = undefined; |
||||||
|
watchEffect(() => { |
||||||
|
x = store.userinfo.name; |
||||||
|
}); |
||||||
|
|
||||||
|
expect(x).toEqual("tom"); |
||||||
|
store.userinfo.name = "jon"; |
||||||
|
expect(x).toEqual("jon"); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,357 @@ |
|||||||
|
import { describe, it, expect } from "vitest"; |
||||||
|
import { |
||||||
|
deepSignal, |
||||||
|
setSetEntrySyntheticId, |
||||||
|
addWithId, |
||||||
|
DeepPatch, |
||||||
|
} from "../deepSignal"; |
||||||
|
import { watch, observe } from "../watch"; |
||||||
|
|
||||||
|
describe("watch (patch mode)", () => { |
||||||
|
it("emits set patches with correct paths and batching", async () => { |
||||||
|
const state = deepSignal({ a: { b: 1 }, arr: [1, { x: 2 }] }); |
||||||
|
const received: DeepPatch[][] = []; |
||||||
|
const { stopListening: stop } = watch(state, ({ patches }) => { |
||||||
|
received.push(patches); |
||||||
|
}); |
||||||
|
state.a.b = 2; |
||||||
|
(state.arr[1] as any).x = 3; |
||||||
|
state.arr.push(5); |
||||||
|
await Promise.resolve(); |
||||||
|
expect(received.length).toBe(1); |
||||||
|
const batch = received[0]; |
||||||
|
const paths = batch.map((p) => p.path.join(".")).sort(); |
||||||
|
expect(paths).toContain("a.b"); |
||||||
|
expect(paths).toContain("arr.1.x"); |
||||||
|
expect(paths).toContain("arr.2"); |
||||||
|
const addOps = batch.filter((p) => p.op === "add").length; |
||||||
|
expect(addOps).toBe(batch.length); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
|
||||||
|
it("emits delete patches without value", async () => { |
||||||
|
const state = deepSignal<{ a: { b?: number }; c?: number }>({ |
||||||
|
a: { b: 1 }, |
||||||
|
c: 2, |
||||||
|
}); |
||||||
|
const out: DeepPatch[][] = []; |
||||||
|
const { stopListening: stop } = watch(state, ({ patches }) => |
||||||
|
out.push(patches) |
||||||
|
); |
||||||
|
delete state.a.b; |
||||||
|
delete state.c; |
||||||
|
await Promise.resolve(); |
||||||
|
expect(out.length).toBe(1); |
||||||
|
const [batch] = out; |
||||||
|
const deletePatches = batch.filter((p) => p.op === "remove"); |
||||||
|
const delPaths = deletePatches.map((p) => p.path.join(".")).sort(); |
||||||
|
expect(delPaths).toEqual(["a.b", "c"]); |
||||||
|
deletePatches.forEach((p: any) => expect(p.value).toBeUndefined()); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
|
||||||
|
it("observe patch mode mirrors watch patch mode", async () => { |
||||||
|
const state = deepSignal({ a: 1 }); |
||||||
|
const wp: DeepPatch[][] = []; |
||||||
|
const ob: DeepPatch[][] = []; |
||||||
|
const { stopListening: stop1 } = watch(state, ({ patches }) => |
||||||
|
wp.push(patches) |
||||||
|
); |
||||||
|
const { stopListening: stop2 } = observe(state, ({ patches }) => |
||||||
|
ob.push(patches) |
||||||
|
); |
||||||
|
state.a = 2; |
||||||
|
await Promise.resolve(); |
||||||
|
expect(wp.length).toBe(1); |
||||||
|
expect(ob.length).toBe(1); |
||||||
|
expect(wp[0][0].path.join(".")).toBe("a"); |
||||||
|
stop1(); |
||||||
|
stop2(); |
||||||
|
}); |
||||||
|
|
||||||
|
it("filters out patches from other roots", async () => { |
||||||
|
const a = deepSignal({ x: 1 }); |
||||||
|
const b = deepSignal({ y: 2 }); |
||||||
|
const out: DeepPatch[][] = []; |
||||||
|
const { stopListening: stop } = watch(a, ({ patches }) => |
||||||
|
out.push(patches) |
||||||
|
); |
||||||
|
b.y = 3; |
||||||
|
a.x = 2; |
||||||
|
await Promise.resolve(); |
||||||
|
expect(out.length).toBe(1); |
||||||
|
expect(out[0][0].path.join(".")).toBe("x"); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
|
||||||
|
it("emits patches for Set structural mutations (add/delete)", async () => { |
||||||
|
const state = deepSignal<{ s: Set<number> }>({ s: new Set([1, 2]) }); |
||||||
|
const batches: DeepPatch[][] = []; |
||||||
|
const { stopListening: stop } = watch(state, ({ patches }) => |
||||||
|
batches.push(patches) |
||||||
|
); |
||||||
|
state.s.add(3); |
||||||
|
state.s.delete(1); |
||||||
|
await Promise.resolve(); |
||||||
|
expect(batches.length >= 1).toBe(true); |
||||||
|
const allPaths = batches.flatMap((b) => b.map((p) => p.path.join("."))); |
||||||
|
expect(allPaths.some((p) => p.startsWith("s."))).toBe(true); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
|
||||||
|
it("emits patches for nested objects added after initialization", async () => { |
||||||
|
const state = deepSignal<{ root: any }>({ root: {} }); |
||||||
|
const patches: DeepPatch[][] = []; |
||||||
|
const { stopListening: stop } = watch(state, ({ patches: batch }) => |
||||||
|
patches.push(batch) |
||||||
|
); |
||||||
|
state.root.child = { level: { value: 1 } }; |
||||||
|
state.root.child.level.value = 2; |
||||||
|
await Promise.resolve(); |
||||||
|
const flat = patches.flat().map((p) => p.path.join(".")); |
||||||
|
expect(flat).toContain("root.child"); |
||||||
|
expect(flat).toContain("root.child.level.value"); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
|
||||||
|
it("emits structural patches for sets of sets", async () => { |
||||||
|
const innerA = new Set<any>([{ id: "node1", x: 1 }]); |
||||||
|
const s = new Set<any>([innerA]); |
||||||
|
const state = deepSignal<{ graph: Set<any> }>({ graph: s }); |
||||||
|
const batches: DeepPatch[][] = []; |
||||||
|
const { stopListening: stop } = watch(state, ({ patches }) => |
||||||
|
batches.push(patches) |
||||||
|
); |
||||||
|
const innerB = new Set<any>([{ id: "node2", x: 5 }]); |
||||||
|
state.graph.add(innerB); |
||||||
|
([...innerA][0] as any).x = 2; |
||||||
|
await Promise.resolve(); |
||||||
|
const pathStrings = batches.flat().map((p) => p.path.join(".")); |
||||||
|
expect(pathStrings.some((p) => p.startsWith("graph."))).toBe(true); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
|
||||||
|
it("tracks deep nested object mutation inside a Set entry after iteration", async () => { |
||||||
|
const rawEntry = { id: "n1", data: { val: 1 } }; |
||||||
|
const st = deepSignal({ bag: new Set<any>([rawEntry]) }); |
||||||
|
const collected: DeepPatch[][] = []; |
||||||
|
const { stopListening: stop } = watch(st, ({ patches }) => |
||||||
|
collected.push(patches) |
||||||
|
); |
||||||
|
let proxied: any; |
||||||
|
for (const e of st.bag.values()) { |
||||||
|
proxied = e; |
||||||
|
e.data.val; |
||||||
|
} |
||||||
|
proxied.data.val = 2; |
||||||
|
await Promise.resolve(); |
||||||
|
const flat = collected.flat().map((p: DeepPatch) => p.path.join(".")); |
||||||
|
expect(flat.some((p: string) => p.endsWith("n1.data.val"))).toBe(true); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
|
||||||
|
it("allows custom synthetic id for Set entry", async () => { |
||||||
|
const node = { name: "x" }; |
||||||
|
const state = deepSignal({ s: new Set<any>() }); |
||||||
|
const collected2: DeepPatch[][] = []; |
||||||
|
const { stopListening: stop } = watch(state, ({ patches }) => |
||||||
|
collected2.push(patches) |
||||||
|
); |
||||||
|
addWithId(state.s as any, node, "custom123"); |
||||||
|
await Promise.resolve(); |
||||||
|
const flat = collected2.flat().map((p: DeepPatch) => p.path.join(".")); |
||||||
|
expect(flat.some((p: string) => p === "s.custom123")).toBe(true); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
|
||||||
|
describe("Set", () => { |
||||||
|
it("emits single structural patch on Set.clear()", async () => { |
||||||
|
const st = deepSignal({ s: new Set<any>() }); |
||||||
|
addWithId(st.s as any, { id: "a", x: 1 }, "a"); |
||||||
|
addWithId(st.s as any, { id: "b", x: 2 }, "b"); |
||||||
|
const batches: DeepPatch[][] = []; |
||||||
|
const { stopListening: stop } = watch(st, ({ patches }) => |
||||||
|
batches.push(patches) |
||||||
|
); |
||||||
|
st.s.clear(); |
||||||
|
await Promise.resolve(); |
||||||
|
const all = batches.flat().map((p) => p.path.join(".")); |
||||||
|
expect(all).toEqual(["s"]); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
it("emits delete patch for object entry", async () => { |
||||||
|
const st = deepSignal({ s: new Set<any>() }); |
||||||
|
const obj = { id: "n1", x: 1 }; |
||||||
|
const patches: DeepPatch[][] = []; |
||||||
|
const { stopListening: stop } = watch(st, ({ patches: batch }) => |
||||||
|
patches.push(batch) |
||||||
|
); |
||||||
|
st.s.add(obj); |
||||||
|
st.s.delete(obj); |
||||||
|
await Promise.resolve(); |
||||||
|
const all = patches |
||||||
|
.flat() |
||||||
|
.filter((p) => p.op === "remove") |
||||||
|
.map((p) => p.path.join(".")); |
||||||
|
expect(all).toContain("s.n1"); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
it("does not emit patch for duplicate add", async () => { |
||||||
|
const st = deepSignal({ s: new Set<number>([1]) }); |
||||||
|
const patches: DeepPatch[][] = []; |
||||||
|
const { stopListening: stop } = watch(st, ({ patches: batch }) => |
||||||
|
patches.push(batch) |
||||||
|
); |
||||||
|
st.s.add(1); |
||||||
|
await Promise.resolve(); |
||||||
|
expect(patches.length).toBe(0); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
it("does not emit patch deleting non-existent entry", async () => { |
||||||
|
const st = deepSignal({ s: new Set<number>([1]) }); |
||||||
|
const patches: DeepPatch[][] = []; |
||||||
|
const { stopListening: stop } = watch(st, ({ patches: batch }) => |
||||||
|
patches.push(batch) |
||||||
|
); |
||||||
|
st.s.delete(2); |
||||||
|
await Promise.resolve(); |
||||||
|
expect(patches.length).toBe(0); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
it("addWithId primitive returns primitive and emits patch with primitive key", async () => { |
||||||
|
const st = deepSignal({ s: new Set<any>() }); |
||||||
|
const patches: DeepPatch[][] = []; |
||||||
|
const { stopListening: stop } = watch(st, ({ patches: batch }) => |
||||||
|
patches.push(batch) |
||||||
|
); |
||||||
|
const ret = addWithId(st.s as any, 5, "ignored"); |
||||||
|
expect(ret).toBe(5); |
||||||
|
await Promise.resolve(); |
||||||
|
const paths = patches.flat().map((p) => p.path.join(".")); |
||||||
|
expect(paths).toContain("s.5"); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
it("setSetEntrySyntheticId applies custom id without helper", async () => { |
||||||
|
const st = deepSignal({ s: new Set<any>() }); |
||||||
|
const obj = { name: "x" }; |
||||||
|
setSetEntrySyntheticId(obj, "customX"); |
||||||
|
const patches: DeepPatch[][] = []; |
||||||
|
const { stopListening: stop } = watch(st, ({ patches: batch }) => |
||||||
|
patches.push(batch) |
||||||
|
); |
||||||
|
st.s.add(obj); |
||||||
|
await Promise.resolve(); |
||||||
|
const paths = patches.flat().map((p) => p.path.join(".")); |
||||||
|
expect(paths).toContain("s.customX"); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
it("values/entries/forEach proxy nested mutation", async () => { |
||||||
|
const st = deepSignal({ s: new Set<any>() }); |
||||||
|
const entry = addWithId(st.s as any, { id: "e1", inner: { v: 1 } }, "e1"); |
||||||
|
const batches: DeepPatch[][] = []; |
||||||
|
const { stopListening: stop } = watch(st, ({ patches }) => |
||||||
|
batches.push(patches) |
||||||
|
); |
||||||
|
for (const e of st.s.values()) { |
||||||
|
e.inner.v; |
||||||
|
} |
||||||
|
entry.inner.v = 2; |
||||||
|
await Promise.resolve(); |
||||||
|
const vPaths = batches.flat().map((p) => p.path.join(".")); |
||||||
|
expect(vPaths.some((p) => p.endsWith("e1.inner.v"))).toBe(true); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
it("raw reference mutation produces no deep patch while proxied does", async () => { |
||||||
|
const raw = { id: "id1", data: { x: 1 } }; |
||||||
|
const st = deepSignal({ s: new Set<any>([raw]) }); |
||||||
|
const batches: DeepPatch[][] = []; |
||||||
|
const { stopListening: stop } = watch(st, ({ patches }) => |
||||||
|
batches.push(patches) |
||||||
|
); |
||||||
|
raw.data.x = 2; |
||||||
|
await Promise.resolve(); |
||||||
|
const afterRaw = batches.flat().map((p) => p.path.join(".")); |
||||||
|
expect(afterRaw.some((p) => p.endsWith("id1.data.x"))).toBe(false); |
||||||
|
let proxied: any; |
||||||
|
for (const e of st.s.values()) proxied = e; |
||||||
|
proxied.data.x = 3; |
||||||
|
await Promise.resolve(); |
||||||
|
const afterProxied = batches.flat().map((p) => p.path.join(".")); |
||||||
|
expect(afterProxied.some((p) => p.endsWith("id1.data.x"))).toBe(true); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
it("synthetic id collision assigns unique blank node id", async () => { |
||||||
|
const st = deepSignal({ s: new Set<any>() }); |
||||||
|
const a1 = { id: "dup", v: 1 }; |
||||||
|
const a2 = { id: "dup", v: 2 }; |
||||||
|
const patches: DeepPatch[][] = []; |
||||||
|
const { stopListening: stop } = watch(st, ({ patches: batch }) => |
||||||
|
patches.push(batch) |
||||||
|
); |
||||||
|
st.s.add(a1); |
||||||
|
st.s.add(a2); |
||||||
|
await Promise.resolve(); |
||||||
|
const keys = patches |
||||||
|
.flat() |
||||||
|
.filter((p) => p.op === "add") |
||||||
|
.map((p) => p.path.slice(-1)[0]); |
||||||
|
expect(new Set(keys).size).toBe(2); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
|
||||||
|
it("allows Array.from() and spread on Set without brand errors and tracks nested mutation", async () => { |
||||||
|
const st = deepSignal({ |
||||||
|
s: new Set<any>([{ id: "eIter", inner: { v: 1 } }]), |
||||||
|
}); |
||||||
|
// Regression: previously 'values method called on incompatible Proxy' was thrown here.
|
||||||
|
const arr = Array.from(st.s); |
||||||
|
expect(arr.length).toBe(1); |
||||||
|
expect(arr[0].inner.v).toBe(1); |
||||||
|
const spread = [...st.s]; |
||||||
|
expect(spread[0].inner.v).toBe(1); |
||||||
|
const batches: DeepPatch[][] = []; |
||||||
|
const { stopListening: stop } = watch(st, ({ patches }) => |
||||||
|
batches.push(patches) |
||||||
|
); |
||||||
|
spread[0].inner.v = 2; // mutate nested field of iterated (proxied) entry
|
||||||
|
await Promise.resolve(); |
||||||
|
const flat = batches.flat().map((p) => p.path.join(".")); |
||||||
|
expect(flat.some((p) => p.endsWith("eIter.inner.v"))).toBe(true); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
describe("Arrays & mixed batch", () => { |
||||||
|
it("emits patches for splice/unshift/shift in single batch", async () => { |
||||||
|
const st = deepSignal({ arr: [1, 2, 3] }); |
||||||
|
const batches: DeepPatch[][] = []; |
||||||
|
const { stopListening: stop } = watch(st, ({ patches }) => |
||||||
|
batches.push(patches) |
||||||
|
); |
||||||
|
st.arr.splice(1, 1, 99, 100); |
||||||
|
st.arr.unshift(0); |
||||||
|
st.arr.shift(); |
||||||
|
await Promise.resolve(); |
||||||
|
const paths = batches.flat().map((p) => p.path.join(".")); |
||||||
|
expect(paths.some((p) => p.startsWith("arr."))).toBe(true); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
it("mixed object/array/Set mutations batch together", async () => { |
||||||
|
const st = deepSignal({ o: { a: 1 }, arr: [1], s: new Set<any>() }); |
||||||
|
const batches: DeepPatch[][] = []; |
||||||
|
const { stopListening: stop } = watch(st, ({ patches }) => |
||||||
|
batches.push(patches) |
||||||
|
); |
||||||
|
st.o.a = 2; |
||||||
|
st.arr.push(2); |
||||||
|
addWithId(st.s as any, { id: "z", v: 1 }, "z"); |
||||||
|
await Promise.resolve(); |
||||||
|
expect(batches.length).toBe(1); |
||||||
|
const paths = batches[0].map((p) => p.path.join(".")); |
||||||
|
expect(paths).toContain("o.a"); |
||||||
|
expect(paths).toContain("arr.1"); |
||||||
|
expect(paths.some((p) => p.startsWith("s."))).toBe(true); |
||||||
|
stop(); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,41 @@ |
|||||||
|
export const objectToString: typeof Object.prototype.toString = |
||||||
|
Object.prototype.toString |
||||||
|
export const toTypeString = (value: unknown): string => |
||||||
|
objectToString.call(value) |
||||||
|
const hasOwnProperty = Object.prototype.hasOwnProperty |
||||||
|
export const hasOwn = ( |
||||||
|
val: object, |
||||||
|
key: string | symbol, |
||||||
|
): key is keyof typeof val => hasOwnProperty.call(val, key) |
||||||
|
|
||||||
|
export const isArray: typeof Array.isArray = Array.isArray |
||||||
|
export const isMap = (val: unknown): val is Map<any, any> => |
||||||
|
toTypeString(val) === '[object Map]' |
||||||
|
export const isSet = (val: unknown): val is Set<any> => |
||||||
|
toTypeString(val) === '[object Set]' |
||||||
|
|
||||||
|
export const isDate = (val: unknown): val is Date => |
||||||
|
toTypeString(val) === '[object Date]' |
||||||
|
export const isRegExp = (val: unknown): val is RegExp => |
||||||
|
toTypeString(val) === '[object RegExp]' |
||||||
|
export const isFunction = (val: unknown): val is Function => |
||||||
|
typeof val === 'function' |
||||||
|
export const isString = (val: unknown): val is string => typeof val === 'string' |
||||||
|
export const isSymbol = (val: unknown): val is symbol => typeof val === 'symbol' |
||||||
|
export const isObject = (val: unknown): val is Record<any, any> => |
||||||
|
val !== null && typeof val === 'object' |
||||||
|
|
||||||
|
export const isPromise = <T = any>(val: unknown): val is Promise<T> => { |
||||||
|
return ( |
||||||
|
(isObject(val) || isFunction(val)) && |
||||||
|
isFunction((val as any).then) && |
||||||
|
isFunction((val as any).catch) |
||||||
|
) |
||||||
|
} |
||||||
|
export const isPlainObject = (val: unknown): val is object => |
||||||
|
toTypeString(val) === '[object Object]' |
||||||
|
|
||||||
|
export const hasChanged = (value: any, oldValue: any): boolean => |
||||||
|
!Object.is(value, oldValue) |
||||||
|
|
||||||
|
export function NOOP() {} |
@ -0,0 +1,188 @@ |
|||||||
|
import { isObject, isPlainObject, isSet, isMap, isArray } from "./utils"; |
||||||
|
import { isSignal } from "./core"; |
||||||
|
import { |
||||||
|
isDeepSignal, |
||||||
|
subscribeDeepMutations, |
||||||
|
getDeepSignalRootId, |
||||||
|
DeepPatch, |
||||||
|
} from "./deepSignal"; |
||||||
|
import { ReactiveFlags } from "./contents"; |
||||||
|
|
||||||
|
/** Function provided to register a disposer (runs before next callback or on stop). */ |
||||||
|
export type RegisterCleanup = (cleanupFn: () => void) => void; |
||||||
|
/** Signature for watchEffect style sources receiving the cleanup registrar. */ |
||||||
|
export type WatchEffect = (registerCleanup: RegisterCleanup) => void; |
||||||
|
|
||||||
|
/** Options for {@link watch}. */ |
||||||
|
export interface WatchOptions { |
||||||
|
/** Trigger the callback immediately with the current value (default: false). */ |
||||||
|
immediate?: boolean; |
||||||
|
/** Auto-stop the watcher after the first callback run that delivers patches (or immediate call if no patches). */ |
||||||
|
once?: boolean; |
||||||
|
/** Allow legacy/unknown options (ignored) to avoid hard breaks while migrating. */ |
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
[legacy: string]: any; |
||||||
|
} |
||||||
|
|
||||||
|
export interface WatchPatchEvent<Root = any> { |
||||||
|
/** Patch batch that triggered this callback (may be empty for immediate). */ |
||||||
|
patches: DeepPatch[]; |
||||||
|
/** Previous snapshot (deep-cloned) of the root value before these patches. Undefined on first call. */ |
||||||
|
oldValue: Root | undefined; |
||||||
|
/** Current root value (live proxy). */ |
||||||
|
newValue: Root; |
||||||
|
} |
||||||
|
|
||||||
|
export type WatchPatchCallback<Root = any> = ( |
||||||
|
event: WatchPatchEvent<Root> |
||||||
|
) => any; |
||||||
|
|
||||||
|
// Internal helper kept for external compatibility.
|
||||||
|
export const remove = <T>(arr: T[], el: T): void => { |
||||||
|
const i = arr.indexOf(el); |
||||||
|
if (i > -1) arr.splice(i, 1); |
||||||
|
}; |
||||||
|
|
||||||
|
/** Observe patch batches on a deep signal root. */ |
||||||
|
export function watch<Root = any>( |
||||||
|
source: Root, |
||||||
|
callback: WatchPatchCallback<Root>, |
||||||
|
options: WatchOptions = {} |
||||||
|
) { |
||||||
|
if (!isDeepSignal(source)) { |
||||||
|
throw new Error( |
||||||
|
"watch() now only supports deepSignal roots (patch mode only)" |
||||||
|
); |
||||||
|
} |
||||||
|
const { immediate, once } = options; |
||||||
|
|
||||||
|
const rootId = getDeepSignalRootId(source as any)!; |
||||||
|
|
||||||
|
let active = true; |
||||||
|
let cleanup: (() => void) | undefined; |
||||||
|
const registerCleanup: RegisterCleanup = (fn) => { |
||||||
|
cleanup = fn; |
||||||
|
}; |
||||||
|
const runCleanup = () => { |
||||||
|
if (cleanup) { |
||||||
|
try { |
||||||
|
cleanup(); |
||||||
|
} catch { |
||||||
|
/* ignore */ |
||||||
|
} finally { |
||||||
|
cleanup = undefined; |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
// Deep clone snapshot helper (JSON clone sufficient for typical reactive plain data)
|
||||||
|
const clone = (v: any) => { |
||||||
|
try { |
||||||
|
return JSON.parse(JSON.stringify(v)); |
||||||
|
} catch { |
||||||
|
return undefined as any; |
||||||
|
} |
||||||
|
}; |
||||||
|
let lastSnapshot: Root | undefined = clone(source); |
||||||
|
|
||||||
|
const stopListening = () => { |
||||||
|
if (!active) return; |
||||||
|
active = false; |
||||||
|
runCleanup(); |
||||||
|
unsubscribe && unsubscribe(); |
||||||
|
}; |
||||||
|
|
||||||
|
const deliver = (patches: DeepPatch[]) => { |
||||||
|
if (!active) return; |
||||||
|
runCleanup(); |
||||||
|
const prev = lastSnapshot; |
||||||
|
const next = source as any as Root; // live proxy
|
||||||
|
try { |
||||||
|
callback({ |
||||||
|
patches, |
||||||
|
oldValue: prev, |
||||||
|
newValue: next, |
||||||
|
}); |
||||||
|
} finally { |
||||||
|
if (active) lastSnapshot = clone(next); |
||||||
|
if (once) stopListening(); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const unsubscribe = subscribeDeepMutations(rootId, (patches) => { |
||||||
|
if (!patches.length) return; // ignore empty batches
|
||||||
|
deliver(patches); |
||||||
|
}); |
||||||
|
|
||||||
|
if (immediate) { |
||||||
|
// Immediate call with empty patch list (snapshot only)
|
||||||
|
deliver([]); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
/** Stop listening to future patch batches; idempotent. */ |
||||||
|
stopListening, |
||||||
|
/** Register a cleanup callback run before the next invocation / stop. */ |
||||||
|
registerCleanup, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// observe alias
|
||||||
|
export function observe( |
||||||
|
source: any, |
||||||
|
cb: WatchPatchCallback, |
||||||
|
options?: WatchOptions |
||||||
|
) { |
||||||
|
return watch(source, cb, options); |
||||||
|
} |
||||||
|
|
||||||
|
// Instrumentation counter for performance tests (number of traverse invocations)
|
||||||
|
/** Instrumentation counter tracking total `traverse()` invocations (used in tests). */ |
||||||
|
export let __traverseCount = 0; // retained for external tooling/tests although watch no longer uses traversal
|
||||||
|
/** Reset the traversal instrumentation counter back to 0. */ |
||||||
|
export function __resetTraverseCount() { |
||||||
|
__traverseCount = 0; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Recursively touch (read) nested properties/entries/values of a reactive structure for dependency collection. |
||||||
|
* Depth-limited; protects against cycles via `seen` set; respects ReactiveFlags.SKIP opt-out. |
||||||
|
*/ |
||||||
|
export function traverse( |
||||||
|
value: unknown, |
||||||
|
depth: number = Infinity, |
||||||
|
seen?: Set<unknown> |
||||||
|
): unknown { |
||||||
|
__traverseCount++; |
||||||
|
if (depth <= 0 || !isObject(value) || (value as any)[ReactiveFlags.SKIP]) { |
||||||
|
return value; |
||||||
|
} |
||||||
|
|
||||||
|
seen = seen || new Set(); |
||||||
|
if (seen.has(value)) { |
||||||
|
return value; |
||||||
|
} |
||||||
|
seen.add(value); |
||||||
|
depth--; |
||||||
|
if (isSignal(value)) { |
||||||
|
traverse((value as any)(), depth, seen); |
||||||
|
} else if (isArray(value)) { |
||||||
|
for (let i = 0; i < value.length; i++) { |
||||||
|
traverse(value[i], depth, seen); |
||||||
|
} |
||||||
|
} else if (isSet(value) || isMap(value)) { |
||||||
|
value.forEach((v: any) => { |
||||||
|
traverse(v, depth, seen); |
||||||
|
}); |
||||||
|
} else if (isPlainObject(value)) { |
||||||
|
for (const key in value) { |
||||||
|
traverse(value[key], depth, seen); |
||||||
|
} |
||||||
|
for (const key of Object.getOwnPropertySymbols(value)) { |
||||||
|
if (Object.prototype.propertyIsEnumerable.call(value, key)) { |
||||||
|
traverse(value[key as any], depth, seen); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return value; |
||||||
|
} |
@ -0,0 +1,32 @@ |
|||||||
|
import { effect as coreEffect } from "./core"; |
||||||
|
/** Run a reactive function and re-run on its dependencies; supports cleanup. */ |
||||||
|
export function watchEffect( |
||||||
|
fn: (registerCleanup?: (cleanup: () => void) => void) => void |
||||||
|
) { |
||||||
|
let cleanup: (() => void) | undefined; |
||||||
|
const registerCleanup = (cb: () => void) => { |
||||||
|
cleanup = cb; |
||||||
|
}; |
||||||
|
const stop = coreEffect(() => { |
||||||
|
if (cleanup) { |
||||||
|
try { |
||||||
|
cleanup(); |
||||||
|
} catch { |
||||||
|
/* ignore */ |
||||||
|
} finally { |
||||||
|
cleanup = undefined; |
||||||
|
} |
||||||
|
} |
||||||
|
fn(registerCleanup); |
||||||
|
}); |
||||||
|
return () => { |
||||||
|
if (cleanup) { |
||||||
|
try { |
||||||
|
cleanup(); |
||||||
|
} catch { |
||||||
|
/* ignore */ |
||||||
|
} |
||||||
|
} |
||||||
|
stop(); |
||||||
|
}; |
||||||
|
} |
@ -0,0 +1,25 @@ |
|||||||
|
{ |
||||||
|
"compilerOptions": { |
||||||
|
"target": "ES2020", |
||||||
|
"useDefineForClassFields": true, |
||||||
|
"module": "ESNext", |
||||||
|
"lib": [ |
||||||
|
"ES2020" |
||||||
|
], |
||||||
|
"skipLibCheck": true, |
||||||
|
/* Bundler mode */ |
||||||
|
"moduleResolution": "bundler", |
||||||
|
"allowImportingTsExtensions": true, |
||||||
|
"resolveJsonModule": true, |
||||||
|
"isolatedModules": false, |
||||||
|
"noEmit": true, |
||||||
|
/* Linting */ |
||||||
|
"strict": true, |
||||||
|
"noUnusedLocals": true, |
||||||
|
"noUnusedParameters": true, |
||||||
|
"noFallthroughCasesInSwitch": true, |
||||||
|
}, |
||||||
|
"include": [ |
||||||
|
"src" |
||||||
|
] |
||||||
|
} |
@ -0,0 +1,14 @@ |
|||||||
|
import type { Options } from 'tsup' |
||||||
|
|
||||||
|
export const tsup: Options = { |
||||||
|
entry: [ |
||||||
|
'src/index.ts', |
||||||
|
], |
||||||
|
format: ['esm', 'cjs'], |
||||||
|
dts: true, |
||||||
|
splitting: true, |
||||||
|
clean: true, |
||||||
|
shims: false, |
||||||
|
minify: false, |
||||||
|
external: ['alien-signals'], |
||||||
|
} |
@ -0,0 +1,38 @@ |
|||||||
|
{ |
||||||
|
"parser": "@typescript-eslint/parser", |
||||||
|
"plugins": [ |
||||||
|
"@typescript-eslint", |
||||||
|
"prettier", |
||||||
|
"react" |
||||||
|
], |
||||||
|
"extends": [ |
||||||
|
"plugin:react/recommended", |
||||||
|
"plugin:@typescript-eslint/recommended", |
||||||
|
"prettier", |
||||||
|
"plugin:prettier/recommended" |
||||||
|
], |
||||||
|
"parserOptions": { |
||||||
|
"ecmaVersion": 2018, |
||||||
|
"sourceType": "module", |
||||||
|
"ecmaFeatures": { |
||||||
|
"jsx": true |
||||||
|
} |
||||||
|
}, |
||||||
|
"settings": { |
||||||
|
"react": { |
||||||
|
"version": "detect" |
||||||
|
} |
||||||
|
}, |
||||||
|
"rules": { |
||||||
|
"no-unused-vars": "off", |
||||||
|
"@typescript-eslint/consistent-type-imports": "error", |
||||||
|
"@typescript-eslint/no-unused-vars": [ |
||||||
|
"warn", |
||||||
|
{ |
||||||
|
"argsIgnorePattern": "^_", |
||||||
|
"varsIgnorePattern": "^_", |
||||||
|
"caughtErrorsIgnorePattern": "^_" |
||||||
|
} |
||||||
|
] |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,22 @@ |
|||||||
|
node_modules/ |
||||||
|
lerna-debug.log |
||||||
|
npm-debug.log |
||||||
|
packages/*/dist |
||||||
|
.idea |
||||||
|
|
||||||
|
.DS_Store |
||||||
|
.env |
||||||
|
.env.local |
||||||
|
.env.development.local |
||||||
|
.env.test.local |
||||||
|
.env.production.local |
||||||
|
|
||||||
|
npm-debug.log* |
||||||
|
yarn-debug.log* |
||||||
|
yarn-error.log* |
||||||
|
|
||||||
|
coverage/ |
||||||
|
|
||||||
|
docs/ |
||||||
|
|
||||||
|
.nx |
@ -0,0 +1,4 @@ |
|||||||
|
{ |
||||||
|
"typescript.preferences.importModuleSpecifierEnding": "js", |
||||||
|
"javascript.preferences.importModuleSpecifierEnding": "js" |
||||||
|
} |
@ -0,0 +1,21 @@ |
|||||||
|
MIT License |
||||||
|
|
||||||
|
Copyright (c) 2023 Jackson Morgan |
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||||
|
of this software and associated documentation files (the "Software"), to deal |
||||||
|
in the Software without restriction, including without limitation the rights |
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||||
|
copies of the Software, and to permit persons to whom the Software is |
||||||
|
furnished to do so, subject to the following conditions: |
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all |
||||||
|
copies or substantial portions of the Software. |
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||||
|
SOFTWARE. |
@ -0,0 +1,30 @@ |
|||||||
|
# LDO Monorepo |
||||||
|
|
||||||
|
This is a monorepo that contains all libraries associated with Linked Data Objects (LDO). |
||||||
|
|
||||||
|
## Documentation |
||||||
|
Full documentation can be found at [ldo.js.org](https://ldo.js.org). |
||||||
|
|
||||||
|
## Libraries |
||||||
|
The LDO monorepo contains the following |
||||||
|
- [@ldo/cli](./packages/cli/) |
||||||
|
- [@ldo/dataset](./packages/dataset/) |
||||||
|
- [@ldo/jsonld-dataset-proxy](./packages/jsonld-dataset-proxy/) |
||||||
|
- [@ldo/ldo](./packages/ldo/) |
||||||
|
- [@ldo/rdf-utils](./packages/rdf-utils/) |
||||||
|
- [@ldo/schema-converter-shex](./packages/schema-converter-shex/) |
||||||
|
- [@ldo/solid](./packages/solid/) |
||||||
|
- [@ldo/solid-react](./packages/solid-react/) |
||||||
|
- [@ldo/solid-type-index](./packages/solid-type-index/) |
||||||
|
- [@ldo/subscribable-dataset](./packages/subscribable-dataset/) |
||||||
|
- [@ldo/traverser-shexj](./packages/traverser-shexj/) |
||||||
|
- [@ldo/type-traverser](./packages/type-traverser/) |
||||||
|
|
||||||
|
## Sponsorship |
||||||
|
This project was made possible by a grant from NGI Zero Entrust via nlnet. Learn more on the [NLnet project page](https://nlnet.nl/project/SolidUsableApps/). |
||||||
|
|
||||||
|
[<img src="https://nlnet.nl/logo/banner.png" alt="nlnet foundation logo" width="300" />](https://nlnet.nl/) |
||||||
|
[<img src="https://nlnet.nl/image/logos/NGI0Entrust_tag.svg" alt="NGI Zero Entrust Logo" width="300" />](https://nlnet.nl/) |
||||||
|
|
||||||
|
## Liscense |
||||||
|
MIT |
@ -0,0 +1,30 @@ |
|||||||
|
/* eslint-disable @typescript-eslint/no-var-requires */ |
||||||
|
const path = require("path"); |
||||||
|
const monorepoRoot = path.resolve(__dirname); |
||||||
|
|
||||||
|
module.exports = { |
||||||
|
preset: "ts-jest/presets/js-with-ts", |
||||||
|
testEnvironment: "node", |
||||||
|
moduleNameMapper: { |
||||||
|
"^(\\.{1,2}/.*)\\.js$": "$1", |
||||||
|
"^@ldo/([^/]+)$": `${monorepoRoot}/packages/$1/src/index.ts`, |
||||||
|
"^@ldo/([^/]+)/(.*)$": `${monorepoRoot}/packages/$1/$2`, |
||||||
|
}, |
||||||
|
coveragePathIgnorePatterns: [ |
||||||
|
"/node_modules/", |
||||||
|
"/dist/", |
||||||
|
"/coverage/", |
||||||
|
"/test/", |
||||||
|
], |
||||||
|
transform: { |
||||||
|
"^.+\\.ts$": [ |
||||||
|
"ts-jest", |
||||||
|
{ |
||||||
|
tsconfig: "<rootDir>/tsconfig.cjs.json", |
||||||
|
}, |
||||||
|
], |
||||||
|
}, |
||||||
|
testPathIgnorePatterns: ["/node_modules/", "/dist/"], |
||||||
|
transformIgnorePatterns: ["/node_modules/", "/dist/"], |
||||||
|
modulePathIgnorePatterns: ["/dist/"], |
||||||
|
}; |
@ -0,0 +1,31 @@ |
|||||||
|
/* eslint-disable @typescript-eslint/no-var-requires */ |
||||||
|
// jest.esm.config.js
|
||||||
|
const path = require("path"); |
||||||
|
const monorepoRoot = path.resolve(__dirname); |
||||||
|
|
||||||
|
module.exports = { |
||||||
|
preset: "ts-jest/presets/default-esm", |
||||||
|
testEnvironment: "node", |
||||||
|
extensionsToTreatAsEsm: [".ts"], |
||||||
|
transform: { |
||||||
|
"^.+\\.ts$": [ |
||||||
|
"ts-jest", |
||||||
|
{ |
||||||
|
useESM: true, |
||||||
|
tsconfig: "<rootDir>/tsconfig.esm.json", |
||||||
|
}, |
||||||
|
], |
||||||
|
}, |
||||||
|
moduleNameMapper: { |
||||||
|
"^(\\.{1,2}/.*)\\.js$": "$1", |
||||||
|
}, |
||||||
|
transformIgnorePatterns: ["/node_modules/", "/dist/"], |
||||||
|
modulePathIgnorePatterns: ["/dist/"], |
||||||
|
testPathIgnorePatterns: ["/node_modules/", "/dist/"], |
||||||
|
coveragePathIgnorePatterns: [ |
||||||
|
"/node_modules/", |
||||||
|
"/dist/", |
||||||
|
"/coverage/", |
||||||
|
"/test/", |
||||||
|
], |
||||||
|
}; |
@ -0,0 +1,4 @@ |
|||||||
|
{ |
||||||
|
"$schema": "node_modules/lerna/schemas/lerna-schema.json", |
||||||
|
"version": "1.0.0-alpha.33" |
||||||
|
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,39 @@ |
|||||||
|
{ |
||||||
|
"name": "@nextgraph-monorepo/ng-ldo-compact", |
||||||
|
"private": true, |
||||||
|
"workspaces": [ |
||||||
|
"packages/*" |
||||||
|
], |
||||||
|
"scripts": { |
||||||
|
"test": "lerna run test", |
||||||
|
"build": "lerna run build", |
||||||
|
"lint": "lerna run lint", |
||||||
|
"clean": "lerna clean --yes && lerna run remove-dist && rimraf node_modules", |
||||||
|
"publish": "lerna publish --no-private" |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"@babel/preset-env": "^7.26.9", |
||||||
|
"@testing-library/react": "^16.3.0", |
||||||
|
"@testing-library/user-event": "^14.6.1", |
||||||
|
"@types/jest": "^27.5.2", |
||||||
|
"@types/node": "^20.5.7", |
||||||
|
"@typescript-eslint/eslint-plugin": "^6.5.0", |
||||||
|
"@typescript-eslint/parser": "^6.5.0", |
||||||
|
"@vitejs/plugin-react": "^4.4.1", |
||||||
|
"@vitest/coverage-istanbul": "^3.2.3", |
||||||
|
"eslint": "^8.48.0", |
||||||
|
"eslint-config-prettier": "^9.0.0", |
||||||
|
"eslint-plugin-prettier": "^5.0.0", |
||||||
|
"eslint-plugin-react": "^7.33.2", |
||||||
|
"jest": "^29.7.0", |
||||||
|
"jsdom": "^26.1.0", |
||||||
|
"lerna": "^7.2.0", |
||||||
|
"prettier": "3.0.3", |
||||||
|
"ts-jest": "^29.3.0", |
||||||
|
"typescript": "^5.2.2", |
||||||
|
"vitest": "^3.1.3" |
||||||
|
}, |
||||||
|
"dependencies": { |
||||||
|
"prettier-eslint": "^16.4.2" |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,3 @@ |
|||||||
|
{ |
||||||
|
"extends": ["../../.eslintrc"] |
||||||
|
} |
@ -0,0 +1,2 @@ |
|||||||
|
example-create |
||||||
|
example-init |
@ -0,0 +1,21 @@ |
|||||||
|
MIT License |
||||||
|
|
||||||
|
Copyright (c) 2023 Jackson Morgan |
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||||
|
of this software and associated documentation files (the "Software"), to deal |
||||||
|
in the Software without restriction, including without limitation the rights |
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||||
|
copies of the Software, and to permit persons to whom the Software is |
||||||
|
furnished to do so, subject to the following conditions: |
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all |
||||||
|
copies or substantial portions of the Software. |
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||||
|
SOFTWARE. |
@ -0,0 +1,83 @@ |
|||||||
|
# @ldo/cli |
||||||
|
|
||||||
|
The `@ldo/cli` is a command line interface for initializing LDO and building ShapeTypes. |
||||||
|
|
||||||
|
## Setup |
||||||
|
|
||||||
|
### Automatic Setup |
||||||
|
To setup LDO, `cd` into your typescript project and run `npx @ldo/cli init`. |
||||||
|
|
||||||
|
```bash |
||||||
|
cd my-typescript-project |
||||||
|
npx @ldo/cli init |
||||||
|
``` |
||||||
|
|
||||||
|
<details> |
||||||
|
<summary> |
||||||
|
Manual Setup |
||||||
|
</summary> |
||||||
|
The following is handled by the __automatic setup__: |
||||||
|
|
||||||
|
Install the LDO dependencies. |
||||||
|
```bash |
||||||
|
npm install @ldo/ldo |
||||||
|
npm install @ldo/cli --save-dev |
||||||
|
``` |
||||||
|
|
||||||
|
Create a folder to store your ShEx shapes: |
||||||
|
```bash |
||||||
|
mkdir shapes |
||||||
|
``` |
||||||
|
|
||||||
|
Create a script to build ShEx shapes and convert them into Linked Data Objects. You can put this script in `package.json` |
||||||
|
```json |
||||||
|
{ |
||||||
|
... |
||||||
|
scripts: { |
||||||
|
... |
||||||
|
"build:ldo": "ldo build --input ./shapes --output ./ldo" |
||||||
|
... |
||||||
|
} |
||||||
|
... |
||||||
|
} |
||||||
|
``` |
||||||
|
</details> |
||||||
|
|
||||||
|
## Generating a ShapeType |
||||||
|
|
||||||
|
@ldo/cli generates shape types using the `*.shex` files in the "input" folder. If you followed the instructions above, run the following command: |
||||||
|
|
||||||
|
```bash |
||||||
|
npm run build:ldo |
||||||
|
``` |
||||||
|
|
||||||
|
This will generate five files: |
||||||
|
- `./ldo/foafProfile.shapeTypes.ts` <-- This is the important file |
||||||
|
- `./ldo/foafProfile.typings.ts` |
||||||
|
- `./ldo/foafProfile.schema.ts` |
||||||
|
- `./ldo/foafProfile.context.ts` |
||||||
|
|
||||||
|
## Creating a new project to distribure shapes |
||||||
|
|
||||||
|
Sometimes, you might want to distribute shapes to others. The easiest way to do that is to deploy them to NPM. The LDO CLI has an easy-to-use command for generating a standalone project just for your shapes. |
||||||
|
|
||||||
|
```bash |
||||||
|
npx @ldo/cli create ./my-project |
||||||
|
``` |
||||||
|
|
||||||
|
This script will generate a project with a place to put your shapes. Running `npm publish` will build the shapes and push to project to NPM for you. |
||||||
|
|
||||||
|
## API Details |
||||||
|
- [`init` command](https://ldo.js.org/latest/api/cli/init/) |
||||||
|
- [`build` command](https://ldo.js.org/latest/api/cli/build/) |
||||||
|
- [`create` command](https://ldo.js.org/latest/api/cli/create/) |
||||||
|
|
||||||
|
|
||||||
|
## Sponsorship |
||||||
|
This project was made possible by a grant from NGI Zero Entrust via nlnet. Learn more on the [NLnet project page](https://nlnet.nl/project/SolidUsableApps/). |
||||||
|
|
||||||
|
[<img src="https://nlnet.nl/logo/banner.png" alt="nlnet foundation logo" width="300" />](https://nlnet.nl/) |
||||||
|
[<img src="https://nlnet.nl/image/logos/NGI0Entrust_tag.svg" alt="NGI Zero Entrust Logo" width="300" />](https://nlnet.nl/) |
||||||
|
|
||||||
|
## Liscense |
||||||
|
MIT |
@ -0,0 +1,11 @@ |
|||||||
|
{ |
||||||
|
"name": "example-init", |
||||||
|
"version": "1.0.0", |
||||||
|
"description": "", |
||||||
|
"keywords": [ |
||||||
|
"" |
||||||
|
], |
||||||
|
"author": "", |
||||||
|
"license": "MIT", |
||||||
|
"main": "./index.js" |
||||||
|
} |
@ -0,0 +1 @@ |
|||||||
|
console.log("hello"); |
@ -0,0 +1,6 @@ |
|||||||
|
/* eslint-disable @typescript-eslint/no-var-requires */ |
||||||
|
const sharedConfig = require("../../jest.config.js"); |
||||||
|
module.exports = { |
||||||
|
...sharedConfig, |
||||||
|
rootDir: "./", |
||||||
|
}; |
@ -0,0 +1,67 @@ |
|||||||
|
{ |
||||||
|
"name": "@ldo/cli", |
||||||
|
"version": "1.0.0-alpha.32", |
||||||
|
"description": "A Command Line Interface for Linked Data Objects", |
||||||
|
"main": "./dist/index.js", |
||||||
|
"type": "module", |
||||||
|
"bin": { |
||||||
|
"ldo": "./dist/index.js" |
||||||
|
}, |
||||||
|
"scripts": { |
||||||
|
"build": "npm run remove-dist && npm run build:ts && npm run copy-files && npm run update-permission", |
||||||
|
"build:ts": "tsc --project tsconfig.cjs.json", |
||||||
|
"remove-dist": "rimraf dist/", |
||||||
|
"copy-files": "copyfiles -u 1 \"./src/**/*.ejs\" dist/", |
||||||
|
"update-permission": "chmod +x ./dist/index.js", |
||||||
|
"test": "jest --coverage", |
||||||
|
"prepublishOnly": " npm run build", |
||||||
|
"lint": "eslint src/** --fix --no-error-on-unmatched-pattern", |
||||||
|
"test:init": "rm -rf ./example-init && cp -R ./example-init-placeholder ./example-init && cd ./example-init && ../dist/index.js init", |
||||||
|
"test:create": "rm -rf ./example-create && ./dist/index.js create ./example-create" |
||||||
|
}, |
||||||
|
"repository": { |
||||||
|
"type": "git", |
||||||
|
"url": "git+https://github.com/o-development/ldo.git" |
||||||
|
}, |
||||||
|
"author": "Jackson Morgan", |
||||||
|
"license": "MIT", |
||||||
|
"bugs": { |
||||||
|
"url": "https://github.com/o-development/ldo/issues" |
||||||
|
}, |
||||||
|
"homepage": "https://github.com/o-development/ldo/tree/main/packages/cli#readme", |
||||||
|
"devDependencies": { |
||||||
|
"@types/child-process-promise": "^2.2.2", |
||||||
|
"@types/ejs": "^3.1.1", |
||||||
|
"@types/fs-extra": "^9.0.13", |
||||||
|
"@types/jsonld": "^1.5.15", |
||||||
|
"@types/prompts": "^2.4.9", |
||||||
|
"@types/shexj": "^2.1.4", |
||||||
|
"copyfiles": "^2.4.1", |
||||||
|
"rimraf": "^3.0.2" |
||||||
|
}, |
||||||
|
"dependencies": { |
||||||
|
"@jeswr/shacl2shex": "^1.1.0", |
||||||
|
"@ldo/ldo": "^1.0.0-alpha.32", |
||||||
|
"@ldo/schema-converter-shex": "^1.0.0-alpha.32", |
||||||
|
"@shexjs/parser": "^1.0.0-alpha.24", |
||||||
|
"child-process-promise": "^2.2.1", |
||||||
|
"commander": "^9.3.0", |
||||||
|
"ejs": "^3.1.8", |
||||||
|
"fs-extra": "^10.1.0", |
||||||
|
"loading-cli": "^1.1.0", |
||||||
|
"prettier": "^3.0.3", |
||||||
|
"prompts": "^2.4.2", |
||||||
|
"rdf-dereference-store": "^1.4.0", |
||||||
|
"rdf-namespaces": "^1.13.1", |
||||||
|
"ts-morph": "^24.0.0", |
||||||
|
"type-fest": "^2.19.0" |
||||||
|
}, |
||||||
|
"files": [ |
||||||
|
"dist", |
||||||
|
"src" |
||||||
|
], |
||||||
|
"publishConfig": { |
||||||
|
"access": "public" |
||||||
|
}, |
||||||
|
"gitHead": "840910c56ec3f61416f031cc76771a5673af6757" |
||||||
|
} |
@ -0,0 +1,96 @@ |
|||||||
|
import fs from "fs-extra"; |
||||||
|
import path from "path"; |
||||||
|
import type { Schema } from "@ldo/traverser-shexj"; |
||||||
|
import parser from "@shexjs/parser"; |
||||||
|
import schemaConverterShex from "@ldo/schema-converter-shex"; |
||||||
|
import { renderFile } from "ejs"; |
||||||
|
import prettier from "prettier"; |
||||||
|
import loading from "loading-cli"; |
||||||
|
import { dirname } from "node:path"; |
||||||
|
import { fileURLToPath } from "node:url"; |
||||||
|
import { forAllShapes } from "./util/forAllShapes.js"; |
||||||
|
import { annotateReadablePredicates } from "@ldo/schema-converter-shex"; |
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url)); |
||||||
|
|
||||||
|
interface BuildOptions { |
||||||
|
input: string; |
||||||
|
output: string; |
||||||
|
format?: "ldo" | "compact"; |
||||||
|
} |
||||||
|
|
||||||
|
export async function build(options: BuildOptions) { |
||||||
|
const load = loading("Preparing Environment"); |
||||||
|
load.start(); |
||||||
|
// Prepare new folder by clearing/and/or creating it
|
||||||
|
if (fs.existsSync(options.output)) { |
||||||
|
await fs.promises.rm(options.output, { recursive: true }); |
||||||
|
} |
||||||
|
await fs.promises.mkdir(options.output); |
||||||
|
|
||||||
|
const format = options.format || "ldo"; |
||||||
|
const fileTemplates: string[] = []; |
||||||
|
|
||||||
|
if (format === "compact") { |
||||||
|
// Pre-annotate schema with readablePredicate to unify naming across outputs
|
||||||
|
fileTemplates.push("schema.compact", "typings", "shapeTypes.compact"); |
||||||
|
} else { |
||||||
|
fileTemplates.push("schema", "typings", "shapeTypes", "context"); |
||||||
|
} |
||||||
|
|
||||||
|
load.text = "Generating LDO Documents"; |
||||||
|
await forAllShapes(options.input, async (fileName, shexC) => { |
||||||
|
// Convert to ShexJ
|
||||||
|
let schema: Schema; |
||||||
|
try { |
||||||
|
// @ts-expect-error ...
|
||||||
|
schema = parser.construct("https://ldo.js.org/").parse(shexC); |
||||||
|
} catch (err) { |
||||||
|
const errMessage = |
||||||
|
err instanceof Error |
||||||
|
? err.message |
||||||
|
: typeof err === "string" |
||||||
|
? err |
||||||
|
: "Unknown Error"; |
||||||
|
console.error(`Error processing ${fileName}: ${errMessage}`); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Add readable predicates to schema as the single source of truth.
|
||||||
|
if (format === "compact") { |
||||||
|
// @ts-expect-error ...
|
||||||
|
annotateReadablePredicates(schema); |
||||||
|
} |
||||||
|
|
||||||
|
const [typings, context, compactSchema] = await schemaConverterShex( |
||||||
|
schema, |
||||||
|
{ |
||||||
|
format, |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
await Promise.all( |
||||||
|
fileTemplates.map(async (templateName) => { |
||||||
|
const finalContent = await renderFile( |
||||||
|
path.join(__dirname, "./templates", `${templateName}.ejs`), |
||||||
|
{ |
||||||
|
typings: typings.typings, |
||||||
|
fileName, |
||||||
|
schema: JSON.stringify(schema, null, 2), |
||||||
|
context: JSON.stringify(context, null, 2), |
||||||
|
compactSchema: JSON.stringify(compactSchema, null, 2), |
||||||
|
format, |
||||||
|
}, |
||||||
|
); |
||||||
|
await fs.promises.writeFile( |
||||||
|
path.join(options.output, `${fileName}.${templateName}.ts`), |
||||||
|
await prettier.format(finalContent, { parser: "typescript" }), |
||||||
|
); |
||||||
|
}), |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
load.stop(); |
||||||
|
} |
@ -0,0 +1,119 @@ |
|||||||
|
import { init } from "./init.js"; |
||||||
|
import { |
||||||
|
modifyPackageJson, |
||||||
|
savePackageJson, |
||||||
|
} from "./util/modifyPackageJson.js"; |
||||||
|
import { generateReadme } from "./generateReadme.js"; |
||||||
|
import path from "path"; |
||||||
|
import prompts from "prompts"; |
||||||
|
import type { PackageJson } from "type-fest"; |
||||||
|
import loading from "loading-cli"; |
||||||
|
import { promises as fs } from "fs"; |
||||||
|
import { dirname } from "node:path"; |
||||||
|
import { fileURLToPath } from "node:url"; |
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url)); |
||||||
|
|
||||||
|
export async function create(directory: string) { |
||||||
|
// Init the NPM Package
|
||||||
|
const responses = await prompts([ |
||||||
|
{ |
||||||
|
type: "text", |
||||||
|
name: "name", |
||||||
|
message: "Package name:", |
||||||
|
initial: path.basename(directory), |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "text", |
||||||
|
name: "version", |
||||||
|
message: "Version:", |
||||||
|
initial: "1.0.0", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "text", |
||||||
|
name: "description", |
||||||
|
message: "Description:", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "list", |
||||||
|
name: "keywords", |
||||||
|
message: "Keywords (comma separated):", |
||||||
|
separator: ",", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "text", |
||||||
|
name: "author", |
||||||
|
message: "Author:", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "text", |
||||||
|
name: "license", |
||||||
|
message: "License:", |
||||||
|
initial: "MIT", |
||||||
|
}, |
||||||
|
{ |
||||||
|
type: "text", |
||||||
|
name: "repository", |
||||||
|
message: "Git repository (optional):", |
||||||
|
}, |
||||||
|
]); |
||||||
|
|
||||||
|
const load = loading("Generating package.json"); |
||||||
|
|
||||||
|
const packageJson: PackageJson = { |
||||||
|
name: responses.name, |
||||||
|
version: responses.version, |
||||||
|
description: responses.description, |
||||||
|
keywords: responses.keywords, |
||||||
|
author: responses.author, |
||||||
|
license: responses.license, |
||||||
|
main: "./index.js", |
||||||
|
}; |
||||||
|
|
||||||
|
if (responses.repository) { |
||||||
|
packageJson.repository = { |
||||||
|
type: "git", |
||||||
|
url: responses.repository, |
||||||
|
}; |
||||||
|
packageJson.bugs = { |
||||||
|
url: `${responses.repository.replace(/\.git$/, "")}/issues`, |
||||||
|
}; |
||||||
|
packageJson.homepage = `${responses.repository.replace( |
||||||
|
/\.git$/, |
||||||
|
"", |
||||||
|
)}#readme`;
|
||||||
|
} |
||||||
|
|
||||||
|
await savePackageJson(directory, packageJson); |
||||||
|
|
||||||
|
// Init LDO
|
||||||
|
load.text = "Initializing LDO"; |
||||||
|
await init(directory); |
||||||
|
|
||||||
|
// Add prepublish script
|
||||||
|
await modifyPackageJson(directory, async (packageJson) => { |
||||||
|
if (!packageJson.scripts) packageJson.scripts = {}; |
||||||
|
packageJson.scripts.prepublish = |
||||||
|
"npm run build:ldo && npm run generate-readme"; |
||||||
|
packageJson.scripts[ |
||||||
|
"generate-readme" |
||||||
|
] = `ldo generate-readme --project ./ --shapes ./.shapes --ldo ./.ldo`; |
||||||
|
return packageJson; |
||||||
|
}); |
||||||
|
|
||||||
|
// Generate ReadMe
|
||||||
|
load.text = "Generating README"; |
||||||
|
await generateReadme({ |
||||||
|
project: directory, |
||||||
|
shapes: path.join(directory, ".shapes"), |
||||||
|
ldo: path.join(directory, ".ldo"), |
||||||
|
}); |
||||||
|
|
||||||
|
// Create .gitignore
|
||||||
|
load.text = "Create .gitignore"; |
||||||
|
await fs.writeFile(path.join(directory, ".gitignore"), "node_modules"); |
||||||
|
|
||||||
|
load.stop(); |
||||||
|
} |
@ -0,0 +1,107 @@ |
|||||||
|
import { getPackageJson } from "./util/modifyPackageJson.js"; |
||||||
|
import { forAllShapes } from "./util/forAllShapes.js"; |
||||||
|
import { promises as fs } from "fs"; |
||||||
|
import path from "path"; |
||||||
|
import { Project } from "ts-morph"; |
||||||
|
import { renderFile } from "ejs"; |
||||||
|
import { dirname } from "node:path"; |
||||||
|
import { fileURLToPath } from "node:url"; |
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url)); |
||||||
|
|
||||||
|
interface GenerateReadmeOptions { |
||||||
|
project: string; |
||||||
|
shapes: string; |
||||||
|
ldo: string; |
||||||
|
} |
||||||
|
|
||||||
|
interface ReadmeEjsOptions { |
||||||
|
projectName: string; |
||||||
|
projectDescription: string; |
||||||
|
shapes: { |
||||||
|
name: string; |
||||||
|
types: { |
||||||
|
typeName: string; |
||||||
|
shapeTypeName: string; |
||||||
|
}[]; |
||||||
|
shex: string; |
||||||
|
typescript: string; |
||||||
|
}[]; |
||||||
|
} |
||||||
|
|
||||||
|
export async function generateReadme(options: GenerateReadmeOptions) { |
||||||
|
const packageJson = await getPackageJson(options.project); |
||||||
|
const projectName = packageJson.name!; |
||||||
|
const projectDescription = packageJson.description!; |
||||||
|
const shapes: ReadmeEjsOptions["shapes"] = []; |
||||||
|
|
||||||
|
await forAllShapes(options.shapes, async (fileName, shexC) => { |
||||||
|
const typeFilePath = path.join(options.ldo, `${fileName}.typings.ts`); |
||||||
|
|
||||||
|
const typesRaw = await fs.readFile(typeFilePath, "utf8"); |
||||||
|
|
||||||
|
const shape: ReadmeEjsOptions["shapes"][0] = { |
||||||
|
name: fileName, |
||||||
|
shex: shexC, |
||||||
|
typescript: typesRaw, |
||||||
|
types: [], |
||||||
|
}; |
||||||
|
|
||||||
|
listInterfaces(typeFilePath).forEach((interfaceName) => { |
||||||
|
shape.types.push({ |
||||||
|
typeName: interfaceName, |
||||||
|
shapeTypeName: `${interfaceName}ShapeType`, |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
shapes.push(shape); |
||||||
|
}); |
||||||
|
|
||||||
|
const readmeEjsOptions: ReadmeEjsOptions = { |
||||||
|
projectName, |
||||||
|
projectDescription, |
||||||
|
shapes, |
||||||
|
}; |
||||||
|
|
||||||
|
// Save Readme
|
||||||
|
const finalContent = await renderFile( |
||||||
|
path.join(__dirname, "./templates/readme/", "main.ejs"), |
||||||
|
readmeEjsOptions, |
||||||
|
); |
||||||
|
// Save readme to document
|
||||||
|
await fs.writeFile(path.join(options.project, "README.md"), finalContent); |
||||||
|
|
||||||
|
await generateIndex({ project: options.project }); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Helper Function that lists all the interfaces in a typescript file |
||||||
|
*/ |
||||||
|
function listInterfaces(filePath: string): string[] { |
||||||
|
const project = new Project(); |
||||||
|
const sourceFile = project.addSourceFileAtPath(filePath); |
||||||
|
|
||||||
|
// Get all interfaces in the file
|
||||||
|
const interfaces = sourceFile.getInterfaces().map((iface) => iface.getName()); |
||||||
|
return interfaces; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Generate Index |
||||||
|
*/ |
||||||
|
interface GenerateIndexOptions { |
||||||
|
project: string; |
||||||
|
} |
||||||
|
|
||||||
|
export async function generateIndex(options: GenerateIndexOptions) { |
||||||
|
const ldoDir = await fs.readdir(path.join(options.project, "./.ldo"), { |
||||||
|
withFileTypes: true, |
||||||
|
}); |
||||||
|
const indexText = await renderFile( |
||||||
|
path.join(__dirname, "./templates/readme/projectIndex.ejs"), |
||||||
|
{ fileNames: ldoDir.map((file) => file.name) }, |
||||||
|
); |
||||||
|
await fs.writeFile(path.join(options.project, "index.js"), indexText); |
||||||
|
} |
@ -0,0 +1,58 @@ |
|||||||
|
#!/usr/bin/env node |
||||||
|
|
||||||
|
import { program } from "commander"; |
||||||
|
import { build } from "./build.js"; |
||||||
|
import { init } from "./init.js"; |
||||||
|
import { create } from "./create.js"; |
||||||
|
import { generateReadme } from "./generateReadme.js"; |
||||||
|
|
||||||
|
program |
||||||
|
.name("LDO-CLI") |
||||||
|
.description("CLI to some JavaScript string utilities") |
||||||
|
.version("3.0.1"); |
||||||
|
|
||||||
|
program |
||||||
|
.command("build") |
||||||
|
.description("Build contents of a shex folder into Shape Types") |
||||||
|
.option("-i, --input <inputPath>", "Provide the input path", "./.shapes") |
||||||
|
.option("-o, --output <outputPath>", "Provide the output path", "./.ldo") |
||||||
|
.option( |
||||||
|
"-f, --format <format>", |
||||||
|
'Typings format: "compact" (default) or "ldo"', |
||||||
|
"compact" |
||||||
|
) |
||||||
|
.action(build); |
||||||
|
|
||||||
|
program |
||||||
|
.command("init") |
||||||
|
.argument("[directory]", "A parent directory for ldo files") |
||||||
|
.description("Initializes a project for LDO.") |
||||||
|
.action(init); |
||||||
|
|
||||||
|
program |
||||||
|
.command("create") |
||||||
|
.argument("<directory>", "The package's directory") |
||||||
|
.description("Creates a standalone package for shapes to publish to NPM.") |
||||||
|
.action(create); |
||||||
|
|
||||||
|
program |
||||||
|
.command("generate-readme") |
||||||
|
.description("Create a ReadMe from the shapes and generated code.") |
||||||
|
.requiredOption( |
||||||
|
"-p, --project <projectPath>", |
||||||
|
"Provide the path to the root project", |
||||||
|
"./" |
||||||
|
) |
||||||
|
.requiredOption( |
||||||
|
"-s, --shapes <shapesPath>", |
||||||
|
"Provide the path to the shapes folder", |
||||||
|
"./.shapes" |
||||||
|
) |
||||||
|
.requiredOption( |
||||||
|
"-s, --ldo <ldoPath>", |
||||||
|
"Provide the path to the ldo folder", |
||||||
|
"./.ldo" |
||||||
|
) |
||||||
|
.action(generateReadme); |
||||||
|
|
||||||
|
program.parse(); |
@ -0,0 +1,78 @@ |
|||||||
|
import { exec } from "child-process-promise"; |
||||||
|
import fs from "fs-extra"; |
||||||
|
import path from "path"; |
||||||
|
import { renderFile } from "ejs"; |
||||||
|
import { modifyPackageJson } from "./util/modifyPackageJson.js"; |
||||||
|
import { dirname } from "node:path"; |
||||||
|
import { fileURLToPath } from "node:url"; |
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url)); |
||||||
|
|
||||||
|
const DEFAULT_SHAPES_FOLDER = "./.shapes"; |
||||||
|
const DEFAULT_LDO_FOLDER = "./.ldo"; |
||||||
|
const POTENTIAL_PARENT_DIRECTORIES = ["src", "lib", "bin"]; |
||||||
|
|
||||||
|
export async function init(directory?: string) { |
||||||
|
// Find folder to save to
|
||||||
|
const projectDirectory = directory ?? "./"; |
||||||
|
|
||||||
|
// Get the parent directory for the ldo files
|
||||||
|
let parentDirectory = projectDirectory; |
||||||
|
parentDirectory = "./"; |
||||||
|
const allDirectories = ( |
||||||
|
await fs.promises.readdir("./", { |
||||||
|
withFileTypes: true, |
||||||
|
}) |
||||||
|
).filter((file) => file.isDirectory()); |
||||||
|
for (let i = 0; i < POTENTIAL_PARENT_DIRECTORIES.length; i++) { |
||||||
|
if ( |
||||||
|
allDirectories.some((dir) => dir.name === POTENTIAL_PARENT_DIRECTORIES[i]) |
||||||
|
) { |
||||||
|
parentDirectory = POTENTIAL_PARENT_DIRECTORIES[i]; |
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Install dependencies
|
||||||
|
await exec(`cd ${projectDirectory} && npm install @ldo/ldo --save`); |
||||||
|
await exec( |
||||||
|
`cd ${projectDirectory} && npm install @ldo/cli @types/shexj @types/jsonld --save-dev`, |
||||||
|
); |
||||||
|
|
||||||
|
// Create "shapes" folder
|
||||||
|
const shapesFolderPath = path.join(parentDirectory, DEFAULT_SHAPES_FOLDER); |
||||||
|
await fs.promises.mkdir(shapesFolderPath); |
||||||
|
const defaultShapePaths = await fs.promises.readdir( |
||||||
|
path.join(__dirname, "./templates/defaultShapes"), |
||||||
|
); |
||||||
|
await Promise.all( |
||||||
|
defaultShapePaths.map(async (shapePath) => { |
||||||
|
const shapeContent = await renderFile( |
||||||
|
path.join(__dirname, "./templates/defaultShapes", shapePath), |
||||||
|
{}, |
||||||
|
); |
||||||
|
await fs.promises.writeFile( |
||||||
|
path.join(shapesFolderPath, `${path.parse(shapePath).name}.shex`), |
||||||
|
shapeContent, |
||||||
|
); |
||||||
|
}), |
||||||
|
); |
||||||
|
|
||||||
|
// Add build script
|
||||||
|
await modifyPackageJson("./", async (packageJson) => { |
||||||
|
if (!packageJson.scripts) { |
||||||
|
packageJson.scripts = {}; |
||||||
|
} |
||||||
|
const ldoFolder = path.join(parentDirectory, DEFAULT_LDO_FOLDER); |
||||||
|
packageJson.scripts["build:ldo"] = `ldo build --input ${path.relative( |
||||||
|
projectDirectory, |
||||||
|
shapesFolderPath, |
||||||
|
)} --output ${path.relative(projectDirectory, ldoFolder)}`;
|
||||||
|
return packageJson; |
||||||
|
}); |
||||||
|
|
||||||
|
// Build LDO
|
||||||
|
await exec(`cd ${projectDirectory} && npm run build:ldo`); |
||||||
|
} |
@ -0,0 +1,8 @@ |
|||||||
|
import { LdoJsonldContext } from "@ldo/ldo"; |
||||||
|
|
||||||
|
/** |
||||||
|
* ============================================================================= |
||||||
|
* <%- fileName %>Context: JSONLD Context for <%- fileName %> |
||||||
|
* ============================================================================= |
||||||
|
*/ |
||||||
|
export const <%- fileName %>Context: LdoJsonldContext = <%- context %>; |
@ -0,0 +1,19 @@ |
|||||||
|
# This shape is provided by default as an example |
||||||
|
# You can create your own shape to fit your needs using ShEx (https://shex.io) |
||||||
|
# Also check out https://shaperepo.com for examples of more shapes. |
||||||
|
|
||||||
|
PREFIX ex: <https://example.com/> |
||||||
|
PREFIX foaf: <http://xmlns.com/foaf/0.1/> |
||||||
|
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#> |
||||||
|
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#> |
||||||
|
|
||||||
|
ex:FoafProfile EXTRA a { |
||||||
|
a [ foaf:Person ] |
||||||
|
// rdfs:comment "Defines the node as a Person (from foaf)" ; |
||||||
|
foaf:name xsd:string ? |
||||||
|
// rdfs:comment "Define a person's name." ; |
||||||
|
foaf:img xsd:string ? |
||||||
|
// rdfs:comment "Photo link but in string form" ; |
||||||
|
foaf:knows @ex:FoafProfile * |
||||||
|
// rdfs:comment "A list of WebIds for all the people this user knows." ; |
||||||
|
} |
@ -0,0 +1,15 @@ |
|||||||
|
# <%= projectName %> |
||||||
|
|
||||||
|
<%- projectDescription %> |
||||||
|
|
||||||
|
This project includes shapes and generated files for [LDO](https://ldo.js.org). |
||||||
|
|
||||||
|
## Installation |
||||||
|
|
||||||
|
```bash |
||||||
|
npm i <%= projectName %> |
||||||
|
``` |
||||||
|
|
||||||
|
<% shapes.forEach(function(shape) { %> |
||||||
|
<%- include('shape', { shape: shape, projectName: projectName }) %> |
||||||
|
<% }); %> |
@ -0,0 +1,2 @@ |
|||||||
|
<% fileNames.forEach((fileName) => { %>export * from "./.ldo/<%- fileName %>"; |
||||||
|
<% }); %> |
@ -0,0 +1,27 @@ |
|||||||
|
## <%= shape.name %> |
||||||
|
|
||||||
|
### Usage with LDO |
||||||
|
|
||||||
|
```typescript |
||||||
|
import { createLdoDataset } from "@ldo/ldo"; |
||||||
|
import { <%= shape.types.map((type) => type.shapeTypeName).join(", ") %> } from "<%= projectName %>"; |
||||||
|
import type { <%= shape.types.map((type) => type.typeName).join(", ") %> } from "<%= projectName %>"; |
||||||
|
const ldoDataset = createLdoDataset(); |
||||||
|
<% shape.types.forEach(function(type, index) { %> |
||||||
|
const example<%= index %>: <%= type.typeName %> = ldoDataset |
||||||
|
.usingType(<%= type.shapeTypeName %>) |
||||||
|
.fromSubject("http://example.com/example<%= index %>"); |
||||||
|
<% }); %> |
||||||
|
``` |
||||||
|
|
||||||
|
### ShEx Typings |
||||||
|
|
||||||
|
```shex |
||||||
|
<%- shape.shex %> |
||||||
|
``` |
||||||
|
|
||||||
|
### TypeScript Typings |
||||||
|
|
||||||
|
```typescript |
||||||
|
<%- shape.typescript %> |
||||||
|
``` |
@ -0,0 +1,8 @@ |
|||||||
|
import type { CompactSchema } from "@ldo/ldo"; |
||||||
|
|
||||||
|
/** |
||||||
|
* ============================================================================= |
||||||
|
* <%- fileName %>Schema: Compact Schema for <%- fileName %> |
||||||
|
* ============================================================================= |
||||||
|
*/ |
||||||
|
export const <%- fileName %>Schema: CompactSchema = <%- compactSchema %>; |
@ -0,0 +1,8 @@ |
|||||||
|
import type { Schema } from "shexj"; |
||||||
|
|
||||||
|
/** |
||||||
|
* ============================================================================= |
||||||
|
* <%- fileName %>Schema: ShexJ Schema for <%- fileName %> |
||||||
|
* ============================================================================= |
||||||
|
*/ |
||||||
|
export const <%- fileName %>Schema: Schema = <%- schema %>; |
@ -0,0 +1,14 @@ |
|||||||
|
import type { CompactShapeType } from "@ldo/ldo"; |
||||||
|
import { <%- fileName %>Schema } from "./<%- fileName %>.schema"; |
||||||
|
import type { |
||||||
|
<% typings.forEach((typing)=> { if (!/Id$/.test(typing.dts.name)) { -%> |
||||||
|
<%- typing.dts.name %>, |
||||||
|
<% } }); -%>} from "./<%- fileName %>.typings"; |
||||||
|
|
||||||
|
// Compact ShapeTypes for <%- fileName %> |
||||||
|
<% typings.forEach((typing)=> { if (!/Id$/.test(typing.dts.name)) { -%> |
||||||
|
export const <%- typing.dts.name %>ShapeType: CompactShapeType<<%- typing.dts.name %>> = { |
||||||
|
schema: <%- fileName %>Schema, |
||||||
|
shape: "<%- typing.dts.shapeId %>", |
||||||
|
}; |
||||||
|
<% } }); -%> |
@ -0,0 +1,16 @@ |
|||||||
|
import { ShapeType } from "@ldo/ldo"; |
||||||
|
import { <%- fileName %>Schema } from "./<%- fileName %>.schema"; |
||||||
|
import { <%- fileName %>Context } from "./<%- fileName %>.context"; |
||||||
|
import { |
||||||
|
<% typings.forEach((typing)=> { if (!/Id$/.test(typing.dts.name)) { -%> |
||||||
|
<%- typing.dts.name %>, |
||||||
|
<% } }); -%>} from "./<%- fileName %>.typings"; |
||||||
|
|
||||||
|
// LDO ShapeTypes for <%- fileName %> |
||||||
|
<% typings.forEach((typing)=> { if (!/Id$/.test(typing.dts.name)) { -%> |
||||||
|
export const <%- typing.dts.name %>ShapeType: ShapeType<<%- typing.dts.name %>> = { |
||||||
|
schema: <%- fileName %>Schema, |
||||||
|
shape: "<%- typing.dts.shapeId %>", |
||||||
|
context: <%- fileName %>Context, |
||||||
|
}; |
||||||
|
<% } }); -%> |
@ -0,0 +1,18 @@ |
|||||||
|
<% if (format==='ldo' ) { -%> |
||||||
|
import { LdoJsonldContext, LdSet } from "@ldo/ldo"; |
||||||
|
<% } else { -%> |
||||||
|
export type IRI = string; |
||||||
|
<% } -%> |
||||||
|
|
||||||
|
/** |
||||||
|
* ============================================================================= |
||||||
|
* Typescript Typings for <%- fileName %> |
||||||
|
* ============================================================================= |
||||||
|
*/ |
||||||
|
|
||||||
|
<% typings.forEach((typing)=> { -%> |
||||||
|
/** |
||||||
|
* <%- typing.dts.name %> Type |
||||||
|
*/ |
||||||
|
export <%- typing.typingString -%> |
||||||
|
<% }); -%> |
@ -0,0 +1,78 @@ |
|||||||
|
import fs from "fs"; |
||||||
|
import path from "path"; |
||||||
|
import { shaclStoreToShexSchema, writeShexSchema } from "@jeswr/shacl2shex"; |
||||||
|
import { dereferenceToStore } from "rdf-dereference-store"; |
||||||
|
import type { Store } from "n3"; |
||||||
|
import { DataFactory as DF } from "n3"; |
||||||
|
import { rdf } from "rdf-namespaces"; |
||||||
|
|
||||||
|
function hasMatch(store: Store, predicate: string, object: string) { |
||||||
|
for (const _ in store.match( |
||||||
|
null, |
||||||
|
DF.namedNode(predicate), |
||||||
|
DF.namedNode(object), |
||||||
|
DF.defaultGraph(), |
||||||
|
)) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
export async function forAllShapes( |
||||||
|
shapePath: string, |
||||||
|
callback: (filename: string, shape: string) => Promise<void>, |
||||||
|
): Promise<void> { |
||||||
|
const shapeDir = await fs.promises.readdir(shapePath, { |
||||||
|
withFileTypes: true, |
||||||
|
}); |
||||||
|
// Filter out non-shex documents
|
||||||
|
const shexFiles = shapeDir.filter( |
||||||
|
(file) => file.isFile() && file.name.endsWith(".shex"), |
||||||
|
); |
||||||
|
const shexPromise = Promise.all( |
||||||
|
shexFiles.map(async (file) => { |
||||||
|
const fileName = path.parse(file.name).name; |
||||||
|
// Get the content of each document
|
||||||
|
const shexC = await fs.promises.readFile( |
||||||
|
path.join(shapePath, file.name), |
||||||
|
"utf8", |
||||||
|
); |
||||||
|
await callback(fileName, shexC); |
||||||
|
}), |
||||||
|
); |
||||||
|
|
||||||
|
const shaclPromise = Promise.all( |
||||||
|
shapeDir.map(async (file) => { |
||||||
|
if (file.isFile()) { |
||||||
|
let store: Awaited<ReturnType<typeof dereferenceToStore>>; |
||||||
|
try { |
||||||
|
store = await dereferenceToStore(path.join(shapePath, file.name), { |
||||||
|
localFiles: true, |
||||||
|
}); |
||||||
|
} catch (e) { |
||||||
|
return; |
||||||
|
} |
||||||
|
// Make sure the RDF file contains a SHACL shape
|
||||||
|
if ( |
||||||
|
hasMatch( |
||||||
|
store.store, |
||||||
|
rdf.type, |
||||||
|
"http://www.w3.org/ns/shacl#NodeShape", |
||||||
|
) || |
||||||
|
hasMatch( |
||||||
|
store.store, |
||||||
|
rdf.type, |
||||||
|
"http://www.w3.org/ns/shacl#PropertyShape", |
||||||
|
) |
||||||
|
) { |
||||||
|
const shex = await writeShexSchema( |
||||||
|
await shaclStoreToShexSchema(store.store), |
||||||
|
store.prefixes, |
||||||
|
); |
||||||
|
await callback(path.parse(file.name).name, shex); |
||||||
|
} |
||||||
|
} |
||||||
|
}), |
||||||
|
); |
||||||
|
await Promise.all([shexPromise, shaclPromise]); |
||||||
|
} |
@ -0,0 +1,33 @@ |
|||||||
|
import type { PackageJson } from "type-fest"; |
||||||
|
import fs from "fs-extra"; |
||||||
|
import path from "path"; |
||||||
|
|
||||||
|
export async function getPackageJson( |
||||||
|
projectFolder: string, |
||||||
|
): Promise<PackageJson> { |
||||||
|
return JSON.parse( |
||||||
|
( |
||||||
|
await fs.promises.readFile(path.join(projectFolder, "./package.json")) |
||||||
|
).toString(), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export async function savePackageJson( |
||||||
|
projectFolder: string, |
||||||
|
packageJson: PackageJson, |
||||||
|
): Promise<void> { |
||||||
|
await fs.promises.mkdir(projectFolder, { recursive: true }); |
||||||
|
await fs.promises.writeFile( |
||||||
|
path.join(projectFolder, "./package.json"), |
||||||
|
JSON.stringify(packageJson, null, 2), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export async function modifyPackageJson( |
||||||
|
projectFolder: string, |
||||||
|
modifyCallback: (packageJson: PackageJson) => Promise<PackageJson>, |
||||||
|
): Promise<void> { |
||||||
|
const packageJson: PackageJson = await getPackageJson(projectFolder); |
||||||
|
const newPackageJson = await modifyCallback(packageJson); |
||||||
|
await savePackageJson(projectFolder, newPackageJson); |
||||||
|
} |
@ -0,0 +1,5 @@ |
|||||||
|
describe("cli", () => { |
||||||
|
it("trivial", () => { |
||||||
|
expect(true).toBe(true); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,9 @@ |
|||||||
|
{ |
||||||
|
"extends": "../../tsconfig.esm.json", |
||||||
|
"compilerOptions": { |
||||||
|
"module": "ESNext", |
||||||
|
"moduleResolution": "node", |
||||||
|
"outDir": "./dist" |
||||||
|
}, |
||||||
|
"include": ["./src"] |
||||||
|
} |
@ -0,0 +1,3 @@ |
|||||||
|
{ |
||||||
|
"extends": ["../../.eslintrc"] |
||||||
|
} |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue