Add key casing modifiers (#618)

* Add casing modifiers to attributes

* Lower case key

* Refactor text utils

* cleanup ternary

* fix case example

* Finish

---------

Co-authored-by: Delaney Gillilan <delaneygillilan@gmail.com>
This commit is contained in:
Ben Croker 2025-02-05 17:39:52 -06:00 committed by GitHub
parent 4aafd4262f
commit d97cd5f40a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 129 additions and 77 deletions

View File

@ -4,8 +4,10 @@
### Added
- Added a `__case` modifier to the `data-signals-*`, `data-computed-*`, `data-ref-*`, `data-indicator-*`, `data-persist-*`, `data-bind-*`, `data-class-*`, and `data-on-*` attributes, allowing you to modify the casing of the key by adding `.kebab`, `.snake` or `.pascal`.
- Added a `retrying` event type that is dispatched when the SSE plugin is trying to reconnect ([#583](https://github.com/starfederation/datastar/issues/583)).
### Fixed
### Changed
- Fixed a bug in which class names were being converted to kebab case when used in `data-class-*` attributes ([#610](https://github.com/starfederation/datastar/issues/610)).
- Class names are no longer converted to kebab case when used in the `data-class-*` attribute ([#610](https://github.com/starfederation/datastar/issues/610)).
- Event names are no longer converted to kebab case when used in the `data-on-*` attribute.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
import { Hash, elUniqId } from '../utils/dom'
import { camelize, lcFirst } from '../utils/text'
import { camelize } from '../utils/text'
import { debounce } from '../utils/timing'
import { effect } from '../vendored/preact-core'
import { DSP, DSS } from './consts'
@ -217,10 +217,10 @@ export class Engine {
const hasKey = key.length > 0
if (hasKey) {
// Keys starting with a dash are not converted to camel case in the dataset
key = key.startsWith('-') ? key.slice(1) : lcFirst(key)
key = camelize(key)
}
const value = el.dataset[camelCasedKey] || ''
const hasValue = value.length > 0
// Create the runtime context
const that = this // I hate javascript
@ -251,7 +251,6 @@ export class Engine {
}
const valReq = plugin.valReq || Requirement.Allowed
const hasValue = value.length > 0
if (hasValue) {
if (valReq === Requirement.Denied) {
throw runtimeErr(`${plugin.name}ValueNotAllowed`, ctx)

View File

@ -1,4 +1,4 @@
import { kebabize } from '../utils/text'
import { snake } from '../utils/text'
import { DATASTAR } from './consts'
import { type InitContext, PluginType, type RuntimeContext } from './types'
@ -12,9 +12,8 @@ interface Metadata {
function dserr(type: string, reason: string, metadata: Metadata = {}) {
const e = new Error()
reason = reason[0].toUpperCase() + reason.slice(1)
e.name = `${DATASTAR} ${type} error`
const r = kebabize(reason).replaceAll('-', '_')
const r = snake(reason)
const q = new URLSearchParams({
metadata: JSON.stringify(metadata),
}).toString()

View File

@ -7,7 +7,7 @@ import {
PluginType,
Requirement,
} from '../../../../engine/types'
import { trimDollarSignPrefix } from '../../../../utils/text'
import { modifyCasing, trimDollarSignPrefix } from '../../../../utils/text'
import {
DATASTAR_SSE_EVENT,
type DatastarSSEEvent,
@ -20,8 +20,8 @@ export const Indicator: AttributePlugin = {
name: 'indicator',
keyReq: Requirement.Exclusive,
valReq: Requirement.Exclusive,
onLoad: ({ value, signals, el, key }) => {
const signalName = key ? key : trimDollarSignPrefix(value)
onLoad: ({ el, key, mods, signals, value }) => {
const signalName = key ? modifyCasing(key, mods) : trimDollarSignPrefix(value)
const signal = signals.upsertIfMissing(signalName, false)
const watcher = (event: CustomEvent<DatastarSSEEvent>) => {
const {

View File

@ -9,7 +9,7 @@ import {
type NestedValues,
PluginType,
} from '../../../../engine/types'
import { trimDollarSignPrefix } from '../../../../utils/text'
import { modifyCasing, trimDollarSignPrefix } from '../../../../utils/text'
const SESSION = 'session'
@ -17,10 +17,12 @@ export const Persist: AttributePlugin = {
type: PluginType.Attribute,
name: 'persist',
mods: new Set([SESSION]),
onLoad: ({ key, value, signals, effect, mods }) => {
onLoad: ({ key, effect, mods, signals, value }) => {
key = modifyCasing(key, mods)
if (key === '') {
key = DATASTAR
}
const storage = mods.has(SESSION) ? sessionStorage : localStorage
let paths = value.split(/\s+/).filter((p) => p !== '')
paths = paths.map((p) => trimDollarSignPrefix(p))

View File

@ -3,6 +3,7 @@ import {
PluginType,
Requirement,
} from '../../../../engine/types'
import { modifyCasing } from '../../../../utils/text'
const name = 'computed'
export const Computed: AttributePlugin = {
@ -10,7 +11,8 @@ export const Computed: AttributePlugin = {
name,
keyReq: Requirement.Must,
valReq: Requirement.Must,
onLoad: ({ key, signals, genRX }) => {
onLoad: ({ key, mods, signals, genRX }) => {
key = modifyCasing(key, mods)
const rx = genRX()
signals.setComputed(key, rx)
},

View File

@ -3,18 +3,20 @@ import {
type NestedValues,
PluginType,
} from '../../../../engine/types'
import { jsStrToObject } from '../../../../utils/text'
import { jsStrToObject, modifyCasing } from '../../../../utils/text'
export const Signals: AttributePlugin = {
type: PluginType.Attribute,
name: 'signals',
removeOnLoad: () => true,
onLoad: (ctx) => {
const { key, value, genRX, signals, mods } = ctx
const { key, mods, signals, value, genRX } = ctx
const ifMissing = mods.has('ifmissing')
// BEN: investigate use of `ifMissing` in the following line
if (key !== '' && !ifMissing) {
const k = modifyCasing(key, mods)
const v = value === '' ? value : genRX()()
signals.setValue(key, v)
signals.setValue(k, v)
} else {
const obj = jsStrToObject(ctx.value)
ctx.value = JSON.stringify(obj)

View File

@ -9,24 +9,27 @@ import {
PluginType,
Requirement,
} from '../../../../engine/types'
import { kebabize } from '../../../../utils/text'
import { kebab } from '../../../../utils/text'
export const Attr: AttributePlugin = {
type: PluginType.Attribute,
name: 'attr',
valReq: Requirement.Must,
onLoad: ({ el, genRX, key, effect }) => {
onLoad: ({ el, key, effect, genRX }) => {
const rx = genRX()
if (key === '') {
return effect(async () => {
const binds = rx<NestedValues>()
for (const [attr, val] of Object.entries(binds)) {
// BEN: add support for boolean attributes?
el.setAttribute(attr, val)
}
})
}
key = kebabize(key)
// Attributes are always kebab-case
key = kebab(key)
return effect(async () => {
let value = false
try {

View File

@ -9,7 +9,7 @@ import {
PluginType,
Requirement,
} from '../../../../engine/types'
import { trimDollarSignPrefix } from '../../../../utils/text'
import { modifyCasing, trimDollarSignPrefix } from '../../../../utils/text'
const dataURIRegex = /^data:(?<mime>[^;]+);base64,(?<contents>.*)$/
const updateEvents = ['change', 'input', 'keydown']
@ -20,8 +20,8 @@ export const Bind: AttributePlugin = {
keyReq: Requirement.Exclusive,
valReq: Requirement.Exclusive,
onLoad: (ctx) => {
const { el, value, key, signals, effect } = ctx
const signalName = key ? key : trimDollarSignPrefix(value)
const { el, key, mods, signals, value, effect } = ctx
const signalName = key ? modifyCasing(key, mods) : trimDollarSignPrefix(value)
let setFromSignal = () => {}
let el2sig = () => {}

View File

@ -8,12 +8,13 @@ import {
PluginType,
Requirement,
} from '../../../../engine/types'
import { modifyCasing } from '../../../../utils/text'
export const Class: AttributePlugin = {
type: PluginType.Attribute,
name: 'class',
valReq: Requirement.Must,
onLoad: ({ key, el, genRX, effect }) => {
onLoad: ({ el, key, mods, effect, genRX }) => {
const cl = el.classList
const rx = genRX()
return effect(() => {
@ -28,6 +29,7 @@ export const Class: AttributePlugin = {
}
}
} else {
key = modifyCasing(key, mods)
const shouldInclude = rx<boolean>()
if (shouldInclude) {
cl.add(key)

View File

@ -12,7 +12,7 @@ import {
} from '../../../../engine/types'
import { onElementRemoved } from '../../../../utils/dom'
import { tagHas, tagToMs } from '../../../../utils/tags'
import { kebabize } from '../../../../utils/text'
import { modifyCasing } from '../../../../utils/text'
import { debounce, delay, throttle } from '../../../../utils/timing'
const lastSignalsMarshalled = new Map<string, any>()
@ -25,7 +25,7 @@ export const On: AttributePlugin = {
valReq: Requirement.Must,
argNames: [EVT],
removeOnLoad: (rawKey: string) => rawKey.startsWith('onLoad'),
onLoad: ({ el, rawKey, key, value, genRX, mods }) => {
onLoad: ({ el, key, mods, rawKey, value, genRX }) => {
const rx = genRX()
let target: Element | Window | Document = el
if (mods.has('window')) target = window
@ -70,7 +70,7 @@ export const On: AttributePlugin = {
if (mods.has('passive')) evtListOpts.passive = true
if (mods.has('once')) evtListOpts.once = true
const eventName = kebabize(key).toLowerCase()
const eventName = modifyCasing(key, mods)
switch (eventName) {
case 'load': {
callback()

View File

@ -8,7 +8,7 @@ import {
PluginType,
Requirement,
} from '../../../../engine/types'
import { trimDollarSignPrefix } from '../../../../utils/text'
import { modifyCasing, trimDollarSignPrefix } from '../../../../utils/text'
// Sets the value of the element
export const Ref: AttributePlugin = {
@ -16,8 +16,8 @@ export const Ref: AttributePlugin = {
name: 'ref',
keyReq: Requirement.Exclusive,
valReq: Requirement.Exclusive,
onLoad: ({ el, key, value, signals }) => {
const signalName = key ? key : trimDollarSignPrefix(value)
onLoad: ({ el, key, mods, signals, value }) => {
const signalName = key ? modifyCasing(key, mods) : trimDollarSignPrefix(value)
signals.setValue(signalName, el)
return () => signals.setValue(signalName, null)
},

View File

@ -16,7 +16,7 @@ export const Text: AttributePlugin = {
keyReq: Requirement.Denied,
valReq: Requirement.Must,
onLoad: (ctx) => {
const { el, genRX, effect } = ctx
const { el, effect, genRX } = ctx
const rx = genRX()
if (!(el instanceof HTMLElement)) {
runtimeErr('TextInvalidElement', ctx)

View File

@ -1,19 +1,33 @@
import type { Modifiers } from '../engine/types'
export const isBoolString = (str: string) => str.trim() === 'true'
export const lcFirst = (str: string) =>
str[0].toLowerCase() + str.slice(1)
export const kebabize = (str: string) =>
export const kebab = (str: string) =>
str.replace(
/[A-Z]+(?![a-z])|[A-Z]/g,
($, ofs) => (ofs ? '-' : '') + $.toLowerCase(),
)
export const camelize = (str: string) =>
kebabize(str).replace(/-./g, (x) => x[1].toUpperCase())
kebab(str).replace(/-./g, (x) => x[1].toUpperCase())
export const snake = (str: string) => kebab(str).replace(/-/g, '_')
export const pascal = (str: string) =>
camelize(str).replace(/^./, (x) => x[0].toUpperCase())
export const jsStrToObject = (raw: string) =>
new Function(`return Object.assign({}, ${raw})`)()
export const trimDollarSignPrefix = (str: string) =>
str.startsWith('$') ? str.slice(1) : str
const caseFns: Record<string, (s: string) => string> = { kebab, snake, pascal }
export function modifyCasing(str: string, mods: Modifiers) {
for (const c of mods.get('case') || []) {
const fn = caseFns[c]
if (fn) str = fn(str)
}
return str
}

4
sdk/go/consts.go generated
View File

@ -7,8 +7,8 @@ import "time"
const (
DatastarKey = "datastar"
Version = "1.0.0-beta.3"
VersionClientByteSize = 40722
VersionClientByteSizeGzip = 14879
VersionClientByteSize = 40864
VersionClientByteSizeGzip = 14915
//region Default durations

View File

@ -107,6 +107,7 @@ func setupExamples(ctx context.Context, router chi.Router, signals sessions.Stor
{ID: "timing"},
{ID: "aliased_data_attributes"},
{ID: "merge_fragment"},
{ID: "key_casing"},
},
},
}

View File

@ -0,0 +1,24 @@
## Key Casing
## Demo
<div id="key-casing"
data-signals-foo-foo="1"
data-signals-foo-bar__case.kebab="1"
data-signals-foo-baz__case.snake="1"
data-signals-foo-boo__case.pascal="1"
>
<pre data-text="ctx.signals.JSON()"></pre>
</div>
## Explanation
```
<div
data-signals-foo-foo="1"
data-signals-foo-bar__case.kebab="1"
data-signals-foo-baz__case.snake="1"
data-signals-foo-boo__case.pascal="1"
>
</div>
```

View File

@ -64,7 +64,9 @@ The `data-signals` attribute can also be used to merge multiple signals using a
The value above is written in Javascript object notation, but JSON, which is a subset and which most templating languages have built-in support for, is also allowed.
Note that `data-*` attributes are case-insensitive. If you want to use uppercase characters in signal names, youll need to kebabize them or use the object syntax. So the signal name `mySignal` must be written as `data-signals-my-signal` or `data-signals="{mySignal: 1}"`.
Note that `data-*` attributes are case-insensitive. If you want to use uppercase characters in signal names, you'll need to kebabize them or use object syntax. So the signal name `mySignal` must be written as `data-signals-my-signal` or `data-signals="{mySignal: 1}"`.
You can further modify the casing of keys in `data-*` attributes using the `__case` modifier, followed by `.kebab`, `.snake`, or `.pascal`.
#### Modifiers