@crombolo/render 0.0.0-stage → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +36 -4
- package/src/context.ts +112 -0
- package/src/form-script.ts +112 -0
- package/src/forms.ts +557 -0
- package/src/html.ts +285 -0
- package/src/index.ts +16 -0
- package/src/jsx-attributes.ts +91 -0
- package/src/jsx-runtime.ts +334 -0
- package/src/loose/jsx-runtime.ts +21 -0
- package/src/media.ts +205 -0
- package/src/model.ts +303 -0
- package/src/published-cache.ts +865 -0
- package/src/redirects.ts +131 -0
- package/src/renderer.ts +374 -0
- package/src/seo.ts +209 -0
- package/src/sitemap.ts +99 -0
- package/src/snapshots.ts +398 -0
- package/src/url-segment.ts +34 -0
- package/src/values.ts +325 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kineco
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
crombolo
|
|
2
|
+
Copyright (c) 2026 Kineco
|
|
3
|
+
|
|
4
|
+
This product includes third-party software. crombolo is an independent project.
|
|
5
|
+
It is not affiliated with, endorsed by, or sponsored by Umbraco A/S, which owns
|
|
6
|
+
the Umbraco trademark.
|
|
7
|
+
|
|
8
|
+
There are three places third-party material is redistributed, rather than merely
|
|
9
|
+
depended on:
|
|
10
|
+
|
|
11
|
+
@crombolo/backoffice-dist the built Umbraco backoffice and the browser
|
|
12
|
+
packages bundled into it, produced by
|
|
13
|
+
`bun run vendor:backoffice`
|
|
14
|
+
@crombolo/backoffice-dist three CSS files copied unmodified from the Umbraco
|
|
15
|
+
client source tree
|
|
16
|
+
@crombolo/contracts the Umbraco Management API OpenAPI document,
|
|
17
|
+
copied unmodified
|
|
18
|
+
|
|
19
|
+
No Umbraco trademark is redistributed. The Umbraco wordmark, the "U" roundel
|
|
20
|
+
favicon and the installer illustration are deliberately not carried in this
|
|
21
|
+
repository: MIT grants copyright permission and says nothing about trademarks, so
|
|
22
|
+
crombolo's own marks are served at those paths instead. See `BRANDED_ASSETS` in
|
|
23
|
+
`@crombolo/backoffice-host`.
|
|
24
|
+
|
|
25
|
+
The Umbraco backoffice
|
|
26
|
+
----------------------
|
|
27
|
+
@umbraco-cms/backoffice 18.2.0 — MIT — https://github.com/umbraco/Umbraco-CMS
|
|
28
|
+
@umbraco-ui/uui 2.0.2 — MIT — https://github.com/umbraco/Umbraco.UI
|
|
29
|
+
|
|
30
|
+
Static files from the Umbraco client source tree
|
|
31
|
+
------------------------------------------------
|
|
32
|
+
css/umb-css.css, css/rte-content.css, css/umbraco-blockgridlayout.css
|
|
33
|
+
— copied unmodified from Umbraco-CMS release-18.2.0 — MIT
|
|
34
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
35
|
+
Shipped in @crombolo/backoffice-dist under `upstream-static/`.
|
|
36
|
+
|
|
37
|
+
`rte-content.css` itself carries Tiptap's default editor styles, copied from
|
|
38
|
+
tiptap v2.11.7 (MIT); the file records this in its own header.
|
|
39
|
+
|
|
40
|
+
The Umbraco Management API contract
|
|
41
|
+
-----------------------------------
|
|
42
|
+
OpenApi.json — copied unmodified from Umbraco-CMS release-18.2.0
|
|
43
|
+
(src/Umbraco.Cms.Api.Management/OpenApi.json) — MIT
|
|
44
|
+
— https://github.com/umbraco/Umbraco-CMS
|
|
45
|
+
Shipped in @crombolo/contracts, which also ships the TypeScript types generated
|
|
46
|
+
from it.
|
|
47
|
+
|
|
48
|
+
Browser runtime dependencies bundled into the backoffice build
|
|
49
|
+
--------------------------------------------------------------
|
|
50
|
+
@heximal/expressions 0.1.5 — BSD-3-Clause
|
|
51
|
+
@microsoft/signalr 10.0.11 — MIT
|
|
52
|
+
@tiptap/* 3.31.3 — MIT
|
|
53
|
+
diff 9.0.0 — BSD-3-Clause
|
|
54
|
+
dompurify 3.4.16 — Apache-2.0, elected from "MPL-2.0 OR Apache-2.0"
|
|
55
|
+
element-internals-polyfill 3.0.2 — MIT
|
|
56
|
+
lit 3.3.3 — BSD-3-Clause
|
|
57
|
+
luxon 3.7.2 — MIT
|
|
58
|
+
marked 18.0.14 — MIT
|
|
59
|
+
monaco-editor 0.57.0 — MIT
|
|
60
|
+
rxjs 7.8.2 — Apache-2.0
|
|
61
|
+
uuid 14.0.2 — MIT
|
|
62
|
+
|
|
63
|
+
dompurify is offered under either licence; Apache-2.0 is elected here, so no
|
|
64
|
+
MPL-2.0 obligation attaches to the copy bundled into the backoffice build. Of the
|
|
65
|
+
Apache-2.0 packages above, none ships a NOTICE file of its own, so Apache-2.0
|
|
66
|
+
section 4(d) adds nothing to this file.
|
|
67
|
+
|
|
68
|
+
Each package's own licence text ships inside its npm distribution and applies
|
|
69
|
+
unchanged to the copies bundled here.
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/render",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "TSX server rendering and the published-content model for crombolo.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Crombolo",
|
|
7
|
+
"homepage": "https://github.com/kineco-au/crombolo-cms#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kineco-au/crombolo-cms.git",
|
|
11
|
+
"directory": "packages/render"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/kineco-au/crombolo-cms/issues"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"bun": ">=1.3.0"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./src/index.ts",
|
|
22
|
+
"./jsx-runtime": "./src/jsx-runtime.ts",
|
|
23
|
+
"./jsx-dev-runtime": "./src/jsx-runtime.ts",
|
|
24
|
+
"./loose/jsx-runtime": "./src/loose/jsx-runtime.ts",
|
|
25
|
+
"./loose/jsx-dev-runtime": "./src/loose/jsx-runtime.ts"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src",
|
|
29
|
+
"NOTICE"
|
|
30
|
+
],
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@crombolo/core": "0.10.0",
|
|
36
|
+
"@types/react": "^19.3.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context for page templates: the page being rendered from any component, a
|
|
3
|
+
* value of the template's own handed down without props, and ids unique to the
|
|
4
|
+
* render.
|
|
5
|
+
*
|
|
6
|
+
* React carries context down an element tree it renders later. This runtime
|
|
7
|
+
* renders as it goes — a component's children have rendered before it runs —
|
|
8
|
+
* so the values live in storage scoped to the render instead, which follows
|
|
9
|
+
* every call and every `await` inside it and nothing outside it. Two requests
|
|
10
|
+
* rendering at once each see their own.
|
|
11
|
+
*/
|
|
12
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
13
|
+
import type { Child } from './jsx-runtime.ts'
|
|
14
|
+
import type { PageProps } from './model.ts'
|
|
15
|
+
|
|
16
|
+
interface RenderScope {
|
|
17
|
+
page: PageProps | undefined
|
|
18
|
+
contexts: ReadonlyMap<symbol, unknown>
|
|
19
|
+
/** Shared by every scope a provider opens inside one render, so ids stay unique. */
|
|
20
|
+
ids: { next: number }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Kept on `globalThis` so that two copies of this module, a site's and the
|
|
25
|
+
* server's, share one store rather than each seeing nothing of the other's.
|
|
26
|
+
*/
|
|
27
|
+
const KEY = Symbol.for('crombolo.render-scope')
|
|
28
|
+
const shared = globalThis as Record<symbol, unknown>
|
|
29
|
+
if (!shared[KEY]) shared[KEY] = new AsyncLocalStorage<RenderScope>()
|
|
30
|
+
const store = shared[KEY] as AsyncLocalStorage<RenderScope>
|
|
31
|
+
|
|
32
|
+
/** Runs a render with `page` available to `usePage()`, and ids counted from zero. */
|
|
33
|
+
export function renderScope<T>(page: PageProps, render: () => T): T {
|
|
34
|
+
return store.run({ page, contexts: new Map(), ids: { next: 0 } }, render)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function within(caller: string): RenderScope {
|
|
38
|
+
const scope = store.getStore()
|
|
39
|
+
if (!scope)
|
|
40
|
+
throw new Error(
|
|
41
|
+
`${caller} can only be called while a page is rendering: in a component, not at the top of a module or later in a timer.`,
|
|
42
|
+
)
|
|
43
|
+
return scope
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The page being rendered, from any component: `model`, `nav`, `culture`,
|
|
48
|
+
* `member`, `signal` and the rest of what a template is given. Name the page's
|
|
49
|
+
* generated interface to type it: `usePage<HomePage>()`.
|
|
50
|
+
*/
|
|
51
|
+
// biome-ignore lint/suspicious/noExplicitAny: an untyped page, as `PageProps` defaults to.
|
|
52
|
+
export function usePage<Properties = any>(): PageProps<Properties> {
|
|
53
|
+
const { page } = within('usePage()')
|
|
54
|
+
if (!page)
|
|
55
|
+
throw new Error('usePage() was called inside a context Provider rendered outside a page.')
|
|
56
|
+
return page as PageProps<Properties>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* An id unique within this render, for tying a label to its input or an
|
|
61
|
+
* `aria-describedby` to its text. Not for CSS selectors: the colons need escaping.
|
|
62
|
+
*/
|
|
63
|
+
export function useId(): string {
|
|
64
|
+
return `:b${within('useId()').ids.next++}:`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ProviderProps<T> {
|
|
68
|
+
value: T
|
|
69
|
+
/**
|
|
70
|
+
* A function, unlike React: children written plainly have already rendered by
|
|
71
|
+
* the time the provider runs, so they could not see its value.
|
|
72
|
+
*/
|
|
73
|
+
children: () => Child
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A context: render it, or its `Provider`, to hand a value to everything inside. */
|
|
77
|
+
export interface Context<T> {
|
|
78
|
+
(props: ProviderProps<T>): Child
|
|
79
|
+
readonly Provider: (props: ProviderProps<T>) => Child
|
|
80
|
+
readonly defaultValue: T
|
|
81
|
+
readonly key: symbol
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A value handed down without props:
|
|
86
|
+
*
|
|
87
|
+
* ```tsx
|
|
88
|
+
* const Theme = createContext('light')
|
|
89
|
+
* <Theme.Provider value="dark">{() => <Sidebar />}</Theme.Provider>
|
|
90
|
+
* // and in Sidebar, however deep: useContext(Theme)
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export function createContext<T>(defaultValue: T): Context<T> {
|
|
94
|
+
const key = Symbol('context')
|
|
95
|
+
const Provider = ({ value, children }: ProviderProps<T>): Child => {
|
|
96
|
+
if (typeof children !== 'function')
|
|
97
|
+
throw new Error(
|
|
98
|
+
'A context Provider takes its children as a function, {() => <Sidebar />}. Written plainly they have already rendered, without its value.',
|
|
99
|
+
)
|
|
100
|
+
const outer = store.getStore()
|
|
101
|
+
const contexts = new Map(outer?.contexts)
|
|
102
|
+
contexts.set(key, value)
|
|
103
|
+
return store.run({ page: outer?.page, contexts, ids: outer?.ids ?? { next: 0 } }, children)
|
|
104
|
+
}
|
|
105
|
+
return Object.assign(Provider, { Provider, defaultValue, key }) as Context<T>
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The value of the nearest provider of `context` around this component, or its default. */
|
|
109
|
+
export function useContext<T>(context: Context<T>): T {
|
|
110
|
+
const contexts = store.getStore()?.contexts
|
|
111
|
+
return contexts?.has(context.key) ? (contexts.get(context.key) as T) : context.defaultValue
|
|
112
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The progressive-enhancement script for a rendered form.
|
|
3
|
+
*
|
|
4
|
+
* Held as a string rather than a file so it travels with the package and needs
|
|
5
|
+
* no build step or path resolution. It is small on purpose: the server
|
|
6
|
+
* re-evaluates every condition anyway, so this only decides what a visitor
|
|
7
|
+
* *sees* between keystrokes. A visitor without JavaScript gets the server's
|
|
8
|
+
* answer when they submit, which is the same answer.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors `isShown`/`evaluateRule` in `@crombolo/core`. The two are kept in
|
|
11
|
+
* step by `tests/forms-conditions.test.ts`, which runs the same cases through
|
|
12
|
+
* both.
|
|
13
|
+
*/
|
|
14
|
+
export const FORM_SCRIPT_PATH = '/crombolo/forms.js'
|
|
15
|
+
|
|
16
|
+
export const FORM_SCRIPT = `/* crombolo form conditions */
|
|
17
|
+
(function () {
|
|
18
|
+
if (window.__cromboloForms) return
|
|
19
|
+
|
|
20
|
+
function valuesOf(form, alias) {
|
|
21
|
+
var out = []
|
|
22
|
+
var inputs = form.querySelectorAll('[name="' + alias.replace(/"/g, '\\\\"') + '"]')
|
|
23
|
+
for (var i = 0; i < inputs.length; i++) {
|
|
24
|
+
var input = inputs[i]
|
|
25
|
+
if ((input.type === 'checkbox' || input.type === 'radio') && !input.checked) continue
|
|
26
|
+
if (input.value !== undefined && String(input.value).trim() !== '') out.push(String(input.value).trim())
|
|
27
|
+
}
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function numeric(a, b) {
|
|
32
|
+
var x = Number(a)
|
|
33
|
+
var y = Number(b)
|
|
34
|
+
return isFinite(x) && isFinite(y) ? x - y : undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rule(form, r) {
|
|
38
|
+
var values = valuesOf(form, r.field)
|
|
39
|
+
var target = r.value === undefined ? '' : r.value
|
|
40
|
+
var first = values.length > 0 ? values[0] : ''
|
|
41
|
+
switch (r.operator) {
|
|
42
|
+
case 'is': return values.indexOf(target) !== -1
|
|
43
|
+
case 'isNot': return values.indexOf(target) === -1
|
|
44
|
+
case 'contains': return values.some(function (v) { return v.indexOf(target) !== -1 })
|
|
45
|
+
case 'doesNotContain': return !values.some(function (v) { return v.indexOf(target) !== -1 })
|
|
46
|
+
case 'startsWith': return first.indexOf(target) === 0
|
|
47
|
+
case 'endsWith': return first.length >= target.length && first.slice(first.length - target.length) === target
|
|
48
|
+
case 'greaterThan': {
|
|
49
|
+
var g = numeric(first, target)
|
|
50
|
+
return g === undefined ? first > target : g > 0
|
|
51
|
+
}
|
|
52
|
+
case 'lessThan': {
|
|
53
|
+
var l = numeric(first, target)
|
|
54
|
+
return l === undefined ? first < target : l < 0
|
|
55
|
+
}
|
|
56
|
+
case 'isEmpty': return values.length === 0
|
|
57
|
+
case 'isNotEmpty': return values.length > 0
|
|
58
|
+
default: return true
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function shown(form, condition) {
|
|
63
|
+
if (!condition || !condition.rules || condition.rules.length === 0) return true
|
|
64
|
+
var matched = condition.match === 'any'
|
|
65
|
+
? condition.rules.some(function (r) { return rule(form, r) })
|
|
66
|
+
: condition.rules.every(function (r) { return rule(form, r) })
|
|
67
|
+
return condition.action === 'hide' ? !matched : matched
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function apply(form) {
|
|
71
|
+
var conditional = form.querySelectorAll('[data-condition]')
|
|
72
|
+
for (var i = 0; i < conditional.length; i++) {
|
|
73
|
+
var element = conditional[i]
|
|
74
|
+
var condition
|
|
75
|
+
try {
|
|
76
|
+
condition = JSON.parse(element.getAttribute('data-condition'))
|
|
77
|
+
} catch (error) {
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
var visible = shown(form, condition)
|
|
81
|
+
element.hidden = !visible
|
|
82
|
+
// A hidden field must not be required, or the browser refuses to submit a
|
|
83
|
+
// form over something nobody can see.
|
|
84
|
+
var wasRequired = element.getAttribute('data-required') === 'true'
|
|
85
|
+
var inputs = element.querySelectorAll('input, select, textarea')
|
|
86
|
+
for (var j = 0; j < inputs.length; j++) {
|
|
87
|
+
var input = inputs[j]
|
|
88
|
+
if (visible) {
|
|
89
|
+
if (input.dataset.cromboloRequired === 'true' || wasRequired) input.required = true
|
|
90
|
+
} else {
|
|
91
|
+
if (input.required) input.dataset.cromboloRequired = 'true'
|
|
92
|
+
input.required = false
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function attach(form) {
|
|
99
|
+
apply(form)
|
|
100
|
+
form.addEventListener('input', function () { apply(form) })
|
|
101
|
+
form.addEventListener('change', function () { apply(form) })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Also the guard against running twice, which two forms on a page would
|
|
105
|
+
// otherwise do. Exposed rather than a bare flag so the suite can hold these
|
|
106
|
+
// two functions against the server's own -- see tests/forms-conditions.
|
|
107
|
+
window.__cromboloForms = { rule: rule, shown: shown, apply: apply }
|
|
108
|
+
|
|
109
|
+
var forms = document.querySelectorAll('form.crombolo-form')
|
|
110
|
+
for (var i = 0; i < forms.length; i++) attach(forms[i])
|
|
111
|
+
})();
|
|
112
|
+
`
|