Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Allows passing encoding callbacks #20

Merged
merged 2 commits into from
Jan 15, 2024
Merged
Changes from 1 commit
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
Next Next commit
✨ Allows passing encoding callbacks
and base64, base64url encoders
ekwoka committed Jan 15, 2024

Verified

This commit was signed with the committer’s verified signature.
commit 4fef44235f189a5ebc7a1a47e1ca2aadba6c941a
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"vitest.commandLine": "pnpm exec vitest",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
"source.fixAll.eslint": "explicit"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
36 changes: 18 additions & 18 deletions package.json
Original file line number Diff line number Diff line change
@@ -24,26 +24,26 @@
},
"devDependencies": {
"@milahu/patch-package": "6.4.14",
"@trivago/prettier-plugin-sort-imports": "4.2.1",
"@types/alpinejs": "3.13.3",
"@types/node": "20.8.10",
"@typescript-eslint/eslint-plugin": "6.9.1",
"@typescript-eslint/parser": "6.9.1",
"@vitest/ui": "0.34.6",
"alpinejs": "3.13.2",
"esbuild": "0.19.5",
"eslint": "8.52.0",
"happy-dom": "9.1.9",
"@trivago/prettier-plugin-sort-imports": "4.3.0",
"@types/alpinejs": "3.13.6",
"@types/node": "20.11.1",
"@typescript-eslint/eslint-plugin": "6.18.1",
"@typescript-eslint/parser": "6.18.1",
"@vitest/ui": "1.2.0",
"alpinejs": "3.13.3",
"esbuild": "0.19.11",
"eslint": "8.56.0",
"happy-dom": "13.1.4",
"husky": "8.0.3",
"lint-staged": "15.0.2",
"lint-staged": "15.2.0",
"npm-run-all": "4.1.5",
"prettier": "3.0.3",
"prettier": "3.2.2",
"pretty-bytes": "6.1.1",
"typescript": "5.2.2",
"vite": "4.5.0",
"vite-plugin-dts": "3.6.3",
"vite-tsconfig-paths": "4.2.1",
"vitest": "0.34.6",
"typescript": "5.3.3",
"vite": "5.0.11",
"vite-plugin-dts": "3.7.0",
"vite-tsconfig-paths": "4.2.3",
"vitest": "1.2.0",
"vitest-dom": "0.1.1"
},
"lint-staged": {
@@ -73,7 +73,7 @@
}
},
"dependencies": {
"@vue/reactivity": "^3.3.8",
"@vue/reactivity": "^3.4.13",
"alpinets": "link:../alpinets/packages/alpinets"
}
}
12 changes: 6 additions & 6 deletions packages/params/README.md
Original file line number Diff line number Diff line change
@@ -122,12 +122,12 @@ type Transformer<T> = (val: T | PrimitivesToStrings<T>) => T;
type PrimitivesToStrings<T> = T extends string | number | boolean | null
? `${T}`
: T extends Array<infer U>
? Array<PrimitivesToStrings<U>>
: T extends object
? {
[K in keyof T]: PrimitivesToStrings<T[K]>;
}
: T;
? Array<PrimitivesToStrings<U>>
: T extends object
? {
[K in keyof T]: PrimitivesToStrings<T[K]>;
}
: T;
```

Note, the transformer will need to be able to handle being called with the type of the value or a simply parsed structure that equates to all primitives being strings. This is because the transformer will be called with the value when initializing, which can be the provided value, or the one determined from the query string.
45 changes: 45 additions & 0 deletions packages/params/src/encoding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
export type Encoding<T> = {
to(value: T): PrimitivesToStrings<T>;
from(value: PrimitivesToStrings<T>): T;
};

export type PrimitivesToStrings<T> = T extends string | number | boolean | null
? `${T}`
: T extends Array<infer U>
? Array<PrimitivesToStrings<U>>
: T extends object
? {
[K in keyof T]: PrimitivesToStrings<T[K]>;
}
: T;

globalThis.btoa ??= (str: string) => Buffer.from(str).toString('base64');
globalThis.atob ??= (str: string) => Buffer.from(str, 'base64').toString();

export const base64: Encoding<string> = {
to: (value) => btoa(value),
from: (value) => atob(value),
};

export const base64URL: Encoding<string> = {
to: (value) =>
btoa(value).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''),
from: (value) => atob(value.replaceAll('-', '+').replaceAll('_', '/')),
};

if (import.meta.vitest) {
describe('Encoding', () => {
it('should encode and decode base64', () => {
expect(base64.to('hello world')).toBe('aGVsbG8gd29ybGQ=');
expect(base64.to('<<???>>')).toBe('PDw/Pz8+Pg==');
expect(base64.from('aGVsbG8gd29ybGQ=')).toBe('hello world');
expect(base64.from('PDw/Pz8+Pg==')).toBe('<<???>>');
});
it('should encode and decode base64URL', () => {
expect(base64URL.to('hello world')).toBe('aGVsbG8gd29ybGQ');
expect(base64URL.to('<<???>>')).toBe('PDw_Pz8-Pg');
expect(base64URL.from('aGVsbG8gd29ybGQ')).toBe('hello world');
expect(base64URL.from('PDw_Pz8-Pg')).toBe('<<???>>');
});
});
}
102 changes: 66 additions & 36 deletions packages/params/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,59 +1,67 @@
import type { PluginCallback, InterceptorObject, Alpine } from 'alpinejs';
import { fromQueryString, toQueryString } from './querystring';
import { fromQueryString, toQueryString } from './querystring.js';
import {
retrieveDotNotatedValueFromData,
objectAtPath,
deleteDotNotatedValueFromData,
insertDotNotatedValueIntoData,
} from './pathresolve';
import { UpdateMethod, onURLChange, untrack } from './history';
} from './pathresolve.js';
import { UpdateMethod, onURLChange, untrack } from './history.js';
import { Encoding, PrimitivesToStrings } from './encoding.js';
export { base64, base64URL } from './encoding.js';
export type { Encoding, PrimitivesToStrings } from './encoding.js';

type InnerType<T, S> = T extends PrimitivesToStrings<T>
? T
: S extends Transformer<T>
? T
: T | PrimitivesToStrings<T>;
type _InnerType<T, S> =
T extends PrimitivesToStrings<T>
? T
: S extends Transformer<T>
? T
: T | PrimitivesToStrings<T>;

/**
* This is the InterceptorObject that is returned from the `query` function.
* When inside an Alpine Component or Store, these interceptors are initialized.
* This hooks up setter/getter methods to to replace the object itself
* and sync the query string params
*/
class QueryInterceptor<T, S extends Transformer<T> | undefined = undefined>
implements InterceptorObject<InnerType<T, S>>
{
class QueryInterceptor<T> implements InterceptorObject<T> {
_x_interceptor = true as const;
private alias: string | undefined = undefined;
private transformer?: S;
private encoder: Encoding<T> = {
to: (v) => v as PrimitivesToStrings<T>,
from: (v) => v as T,
};
private method: UpdateMethod = UpdateMethod.replace;
private show: boolean = false;
public initialValue: InnerType<T, S>;
public initialValue: T;
constructor(
initialValue: T,
private Alpine: Pick<Alpine, 'effect'>,
private reactiveParams: Record<string, unknown>,
) {
this.initialValue = initialValue as InnerType<T, S>;
this.initialValue = initialValue;
}
/**
* Self Initializing interceptor called by Alpine during component initialization
* @param {object} data The Alpine Data Object (component or store)
* @param {string} path dot notated path from the data root to the interceptor
* @returns {T} The value of the interceptor after initialization
*/
initialize(data: Record<string, unknown>, path: string): InnerType<T, S> {
initialize(data: Record<string, unknown>, path: string): T {
const {
alias = path,
Alpine,
initialValue,
method,
reactiveParams,
show,
transformer,
encoder,
} = this;
const initial = (retrieveDotNotatedValueFromData(alias, reactiveParams) ??
initialValue) as InnerType<T, S>;
const existing = retrieveDotNotatedValueFromData(
alias,
reactiveParams,
) as PrimitivesToStrings<T> | null;
const initial = existing ? encoder.from(existing) : initialValue;

const keys = path.split('.');
const final = keys.pop()!;
@@ -63,19 +71,26 @@ class QueryInterceptor<T, S extends Transformer<T> | undefined = undefined>
set: (value: T) => {
!show && value === initialValue
? deleteDotNotatedValueFromData(alias, reactiveParams)
: insertDotNotatedValueIntoData(alias, value, reactiveParams);
: insertDotNotatedValueIntoData(
alias,
encoder.to(value),
reactiveParams,
);
},
get: () => {
const value = (retrieveDotNotatedValueFromData(alias, reactiveParams) ??
initialValue) as T;
const existing = retrieveDotNotatedValueFromData(
alias,
reactiveParams,
) as PrimitivesToStrings<T> | null;
const value = existing ? encoder.from(existing) : initialValue;
return value;
},
enumerable: true,
});

Alpine.effect(paramEffect(alias, reactiveParams, method));

return (transformer?.(initial) ?? initial) as InnerType<T, S>;
return initial;
}
/**
* Changes the keyname for using in the query string
@@ -90,10 +105,9 @@ class QueryInterceptor<T, S extends Transformer<T> | undefined = undefined>
* Transforms the value of the query param before it is set on the data
* @param {function} fn Transformer function
*/
into(fn: Transformer<T>): QueryInterceptor<T, Transformer<T>> {
const self = this as QueryInterceptor<T, Transformer<T>>;
self.transformer = fn;
return self;
into(fn: Transformer<T>): QueryInterceptor<T> {
this.encoder.from = fn;
return this;
}
/**
* Always show the initial value in the query string
@@ -109,6 +123,14 @@ class QueryInterceptor<T, S extends Transformer<T> | undefined = undefined>
this.method = UpdateMethod.push;
return this;
}
/**
* Registers encoding and decoding functions to transform the value
* before it is set on the query string
*/
encoding(encoder: Encoding<T>): QueryInterceptor<T> {
this.encoder = encoder;
return this;
}
}

export const query: PluginCallback = (Alpine) => {
@@ -195,6 +217,7 @@ const setParams = (params: Record<string, unknown>, method: UpdateMethod) => {
if (import.meta.vitest) {
describe('QueryInterceptor', async () => {
const Alpine = await import('alpinejs').then((m) => m.default);
const { base64URL } = await import('./encoding');
afterEach(() => {
vi.restoreAllMocks();
});
@@ -351,17 +374,24 @@ if (import.meta.vitest) {
);
expect(history.replaceState).not.toHaveBeenCalled();
});
it('can have a defined encoding', async () => {
vi.spyOn(history, UpdateMethod.replace);
const paramObject = Alpine.reactive({});
const data = { foo: '' };
new QueryInterceptor(data.foo, Alpine, paramObject)
.encoding(base64URL)
.initialize(data, 'foo');
data.foo = '<<???>>';
await Alpine.nextTick();
expect(data).toEqual({ foo: '<<???>>' });
expect(paramObject).toEqual({ foo: 'PDw_Pz8-Pg' });
expect(history.replaceState).toHaveBeenCalledWith(
{ query: { foo: 'PDw_Pz8-Pg' } },
'',
'?foo=PDw_Pz8-Pg',
);
});
});
}

type PrimitivesToStrings<T> = T extends string | number | boolean | null
? `${T}`
: T extends Array<infer U>
? Array<PrimitivesToStrings<U>>
: T extends object
? {
[K in keyof T]: PrimitivesToStrings<T[K]>;
}
: T;

export { observeHistory } from './history';
1,283 changes: 598 additions & 685 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions size.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"params": {
"minified": {
"pretty": "2.18 kB",
"raw": 2178
"pretty": "2.57 kB",
"raw": 2571
},
"brotli": {
"pretty": "1.03 kB",
"raw": 1029
"pretty": "1.16 kB",
"raw": 1163
}
},
"xajax": {
1 change: 0 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
@@ -31,7 +31,6 @@ export default defineConfig({
reporters: ['dot'],
environment: 'happy-dom',
deps: {},
useAtomics: true,
passWithNoTests: true,
},
});