Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions src/htmx.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,12 @@ var htmx = (() => {
},
morphIgnore: ["data-htmx-powered"],
noSwap: [204, 304],
implicitInheritance: false
implicitInheritance: false,
relaxedJSON: true
}
let metaConfig = document.querySelector('meta[name="htmx:config"]');
if (metaConfig) {
let overrides = JSON.parse(metaConfig.content);
let overrides = this.parseJSON(metaConfig.content);
// Deep merge nested config objects
for (let key in overrides) {
let val = overrides[key];
Expand Down Expand Up @@ -351,7 +352,7 @@ var htmx = (() => {
// Apply hx-config overrides
let configAttr = this.__attributeValue(sourceElement, "hx-config");
if (configAttr) {
let configOverrides = JSON.parse(configAttr);
let configOverrides = this.parseJSON(configAttr);
let requestConfig = ctx.request;
for (let key in configOverrides) {
if (key.startsWith('+')) {
Expand Down Expand Up @@ -1577,6 +1578,15 @@ var htmx = (() => {
return isNaN(v) ? undefined : v;
}

parseJSON(s) {
if (this.config.relaxedJSON !== false) { // Auto-wrap with braces & convert unquoted keys to quoted
s = s.trim();
if (s[0] != '{') s = '{' + s + '}';
s = s.replace(/([{,]\s*)([a-zA-Z_$][\w$]*)\s*:/g, '$1"$2":');
}
return JSON.parse(s);
}

trigger(on, eventName, detail = {}, bubbles = true) {
on = this.__normalizeElement(on)
let evt = new CustomEvent(eventName, {
Expand Down
199 changes: 199 additions & 0 deletions test/tests/attributes/hx-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -245,4 +245,203 @@ describe('hx-config attribute', function() {
assert.equal(ctx.request.headers['X-Custom'], 'value')
assert.equal(ctx.request.headers['HX-Request'], 'true')
})

// JSON5-lite syntax tests
it('supports unwrapped config with unquoted keys', async function () {
mockResponse('GET', '/test', 'Done')
let ctx = null
document.addEventListener('htmx:config:request', function(e) {
ctx = e.detail.ctx
}, {once: true})

let btn = createProcessedHTML('<button hx-get="/test" hx-config=\'timeout: 5000\'>Click</button>');
btn.click()
await forRequest()
assert.equal(ctx.request.timeout, 5000)
})

it('supports multiple unwrapped properties', async function () {
mockResponse('GET', '/test', 'Done')
let ctx = null
document.addEventListener('htmx:config:request', function(e) {
ctx = e.detail.ctx
}, {once: true})

let btn = createProcessedHTML('<button hx-get="/test" hx-config=\'timeout: 5000, cache: "no-cache"\'>Click</button>');
btn.click()
await forRequest()
assert.equal(ctx.request.timeout, 5000)
assert.equal(ctx.request.cache, 'no-cache')
})

it('supports wrapped config with unquoted keys', async function () {
mockResponse('GET', '/test', 'Done')
let ctx = null
document.addEventListener('htmx:config:request', function(e) {
ctx = e.detail.ctx
}, {once: true})

let btn = createProcessedHTML('<button hx-get="/test" hx-config=\'{timeout: 5000}\'>Click</button>');
btn.click()
await forRequest()
assert.equal(ctx.request.timeout, 5000)
})

it('supports nested objects with unquoted keys', async function () {
mockResponse('GET', '/test', 'Done')
let ctx = null
document.addEventListener('htmx:config:request', function(e) {
ctx = e.detail.ctx
}, {once: true})

let btn = createProcessedHTML('<button hx-get="/test" hx-config=\'streams: {reconnect: true, reconnectDelay: 50}\'>Click</button>');
btn.click()
await forRequest()
assert.isTrue(ctx.request.streams.reconnect)
assert.equal(ctx.request.streams.reconnectDelay, 50)
})

it('supports action override with unquoted key', async function () {
mockResponse('GET', '/override', 'Overridden!')
let ctx = null
document.addEventListener('htmx:config:request', function(e) {
ctx = e.detail.ctx
}, {once: true})

let btn = createProcessedHTML('<button hx-get="/test" hx-trigger="click" hx-config=\'action: "/override"\'>Click</button>');
btn.click()
await forRequest()
playground().innerText.should.equal('Overridden!')
assert.isTrue(lastFetch().url.startsWith('/override'))
assert.equal(ctx.request.action, '/override')
})

it('supports method override with unquoted key', async function () {
mockResponse('PUT', '/test', 'Put request')
let btn = createProcessedHTML('<button hx-get="/test" hx-config=\'method: "PUT"\'>Click</button>');
btn.click()
await forRequest()
lastFetch().request.method.should.equal('PUT')
})

it('supports validate config with unquoted key', async function () {
mockResponse('POST', '/test', 'Submitted')
let ctx = null
document.addEventListener('htmx:config:request', function(e) {
ctx = e.detail.ctx
}, {once: true})

let form = createProcessedHTML('<form hx-post="/test" hx-config=\'validate: false\'><input required name="test" value="filled"><button>Submit</button></form>');
form.requestSubmit()
await forRequest()
assert.isFalse(ctx.request.validate)
})

it('supports complex unwrapped config', async function () {
mockResponse('GET', '/test', 'Done')
let ctx = null
document.addEventListener('htmx:config:request', function(e) {
ctx = e.detail.ctx
}, {once: true})

let btn = createProcessedHTML('<button hx-get="/test" hx-config=\'timeout: 5000, validate: false, cache: "no-cache"\'>Click</button>');
btn.click()
await forRequest()
assert.equal(ctx.request.timeout, 5000)
assert.isFalse(ctx.request.validate)
assert.equal(ctx.request.cache, 'no-cache')
})

it('supports + prefix with unquoted keys', async function () {
mockResponse('GET', '/test', 'Done')
let ctx = null
document.addEventListener('htmx:config:request', function(e) {
ctx = e.detail.ctx
}, {once: true})

let btn = createProcessedHTML('<button hx-get="/test" hx-config=\'{"+headers": {Accept: "application/json"}}\'>Click</button>');
btn.click()
await forRequest()
assert.equal(ctx.request.headers.Accept, 'application/json')
assert.equal(ctx.request.headers['HX-Request'], 'true')
})

it('maintains backward compatibility with standard JSON', async function () {
mockResponse('GET', '/override', 'JSON works')
let ctx = null
document.addEventListener('htmx:config:request', function(e) {
ctx = e.detail.ctx
}, {once: true})

let btn = createProcessedHTML('<button hx-get="/test" hx-trigger="click" hx-config=\'{"action": "/override"}\'>Click</button>');
btn.click()
await forRequest()
playground().innerText.should.equal('JSON works')
assert.isTrue(lastFetch().url.startsWith('/override'))
assert.equal(ctx.request.action, '/override')
})

it('supports inherited config with unquoted keys', async function () {
mockResponse('GET', '/inherited', 'Inherited config')
let ctx = null
document.addEventListener('htmx:config:request', function(e) {
ctx = e.detail.ctx
}, {once: true})
createProcessedHTML('<div hx-config:inherited=\'action: "/inherited"\'><button hx-get="/original" hx-trigger="click">Click</button></div>')
find('button').click()
await forRequest()
playground().innerText.should.equal('Inherited config')
assert.isTrue(lastFetch().url.startsWith('/inherited'))
assert.equal(ctx.request.action, '/inherited')
})

// relaxedJSON config option tests
it('respects config.relaxedJSON = false and requires strict JSON', function () {
const originalValue = htmx.config.relaxedJSON
try {
htmx.config.relaxedJSON = false

// parseJSON should throw with relaxed syntax when config is false
assert.throws(() => htmx.parseJSON('timeout: 5000'))
assert.throws(() => htmx.parseJSON('{timeout: 5000}'))
} finally {
htmx.config.relaxedJSON = originalValue
}
})

it('works with strict JSON when config.relaxedJSON = false', async function () {
const originalValue = htmx.config.relaxedJSON
try {
htmx.config.relaxedJSON = false
mockResponse('GET', '/test', 'Done')
let ctx = null
document.addEventListener('htmx:config:request', function(e) {
ctx = e.detail.ctx
}, {once: true})

// Strict JSON should work
let btn = createProcessedHTML('<button hx-get="/test" hx-config=\'{"timeout": 5000}\'>Click</button>')
btn.click()
await forRequest()
assert.equal(ctx.request.timeout, 5000)
} finally {
htmx.config.relaxedJSON = originalValue
}
})

it('defaults to relaxed JSON syntax by default', async function () {
// Verify default behavior (relaxedJSON should be true by default)
assert.isTrue(htmx.config.relaxedJSON !== false)

mockResponse('GET', '/test', 'Done')
let ctx = null
document.addEventListener('htmx:config:request', function(e) {
ctx = e.detail.ctx
}, {once: true})

let btn = createProcessedHTML('<button hx-get="/test" hx-config=\'timeout: 5000\'>Click</button>')
btn.click()
await forRequest()
assert.equal(ctx.request.timeout, 5000)
})
})
2 changes: 2 additions & 0 deletions test/tests/unit/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ describe('bootstrap unit tests', function() {
'on',
'onLoad',
'parseInterval',
'parseJSON',
'process',
'swap',
'takeClass',
Expand Down Expand Up @@ -275,4 +276,5 @@ describe('bootstrap unit tests', function() {
'Public properties have changed. Expected: ' + JSON.stringify(expectedPublicProperties) +
', Got: ' + JSON.stringify(ownProperties));
})

})
89 changes: 89 additions & 0 deletions test/tests/unit/htmx.config.relaxedJSON.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
describe('htmx.config.relaxedJSON functionality', function() {

beforeEach(function() {
setupTest(this);
});

afterEach(function() {
cleanupTest();
});

it("defaults to true", function() {
assert.equal(htmx.config.relaxedJSON, true)
})

it("can be disabled to require strict JSON", function() {
const originalValue = htmx.config.relaxedJSON

try {
htmx.config.relaxedJSON = false

// Now parseJSON should require strict JSON
assert.throws(() => htmx.parseJSON('timeout: 5000'))
assert.throws(() => htmx.parseJSON('{timeout: 5000}'))

// But strict JSON should work
const parsed = htmx.parseJSON('{"timeout": 5000}')
assert.equal(parsed.timeout, 5000)
} finally {
htmx.config.relaxedJSON = originalValue
}
})

it("supports relaxed JSON in meta config", function() {
// Verify parseJSON works with relaxed syntax for meta config content
const parsed = htmx.parseJSON('defaultSwap: "outerHTML", timeout: 30000')
assert.equal(parsed.defaultSwap, 'outerHTML')
assert.equal(parsed.timeout, 30000)
})

it("allows meta config to disable relaxedJSON for rest of page", function() {
const originalValue = htmx.config.relaxedJSON

try {
// Simulate what would happen if meta tag set relaxedJSON: false
htmx.config.relaxedJSON = false

// Now parseJSON should require strict JSON
assert.throws(() => htmx.parseJSON('timeout: 5000'))

// But strict JSON should work
const parsed = htmx.parseJSON('{"timeout": 5000}')
assert.equal(parsed.timeout, 5000)
} finally {
htmx.config.relaxedJSON = originalValue
}
})

it("works with hx-config when enabled", async function() {
mockResponse('GET', '/test', 'Done')
let ctx = null
document.addEventListener('htmx:config:request', function(e) {
ctx = e.detail.ctx
}, {once: true})

let btn = createProcessedHTML('<button hx-get="/test" hx-config=\'timeout: 5000\'>Click</button>')
btn.click()
await forRequest()
assert.equal(ctx.request.timeout, 5000)
})

it("parseJSON requires strict JSON when disabled", function() {
const originalValue = htmx.config.relaxedJSON

try {
htmx.config.relaxedJSON = false

// parseJSON should throw with relaxed syntax
assert.throws(() => htmx.parseJSON('timeout: 5000'))
assert.throws(() => htmx.parseJSON('{timeout: 5000}'))

// But strict JSON should work
const parsed = htmx.parseJSON('{"timeout": 5000}')
assert.equal(parsed.timeout, 5000)
} finally {
htmx.config.relaxedJSON = originalValue
}
})

});
Loading