feat(storage): enhance useStorageAsync with cross-instance sync and event handling
This commit is contained in:
@@ -0,0 +1,213 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
decodeCookieValue,
|
||||||
|
encodeCookieName,
|
||||||
|
encodeCookieValue,
|
||||||
|
getCookieValue,
|
||||||
|
parseCookieString,
|
||||||
|
serializeCookie,
|
||||||
|
} from './index';
|
||||||
|
|
||||||
|
describe('encodeCookieValue', () => {
|
||||||
|
it('passes plain values through unchanged', () => {
|
||||||
|
expect(encodeCookieValue('dark')).toBe('dark');
|
||||||
|
expect(encodeCookieValue('abc-123_~.!*')).toBe('abc-123_~.!*');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps RFC 6265-allowed punctuation readable', () => {
|
||||||
|
expect(encodeCookieValue('a=b&c:d/e?f@g')).toBe('a=b&c:d/e?f@g');
|
||||||
|
expect(encodeCookieValue('#$+<>[]^`{|}')).toBe('#$+<>[]^`{|}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes characters cookies cannot contain', () => {
|
||||||
|
expect(encodeCookieValue('a;b')).toBe('a%3Bb');
|
||||||
|
expect(encodeCookieValue('a b')).toBe('a%20b');
|
||||||
|
expect(encodeCookieValue('a,b')).toBe('a%2Cb');
|
||||||
|
expect(encodeCookieValue('a"b')).toBe('a%22b');
|
||||||
|
expect(encodeCookieValue('a\\b')).toBe('a%5Cb');
|
||||||
|
expect(encodeCookieValue('50%')).toBe('50%25');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips through decodeCookieValue', () => {
|
||||||
|
const value = 'json:{"a": "b, c"}; 50% off\\done';
|
||||||
|
|
||||||
|
expect(decodeCookieValue(encodeCookieValue(value))).toBe(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('decodeCookieValue', () => {
|
||||||
|
it('decodes percent escapes', () => {
|
||||||
|
expect(decodeCookieValue('a%3Bb')).toBe('a;b');
|
||||||
|
expect(decodeCookieValue('%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82')).toBe('привет');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns malformed escapes as-is instead of throwing', () => {
|
||||||
|
expect(decodeCookieValue('100%')).toBe('100%');
|
||||||
|
expect(decodeCookieValue('%E0%A4%A')).toBe('%E0%A4%A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unwraps DQUOTE-wrapped values', () => {
|
||||||
|
expect(decodeCookieValue('"dark"')).toBe('dark');
|
||||||
|
expect(decodeCookieValue('"a%3Bb"')).toBe('a;b');
|
||||||
|
expect(decodeCookieValue('"')).toBe('"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('encodeCookieName', () => {
|
||||||
|
it('passes typical names through unchanged', () => {
|
||||||
|
expect(encodeCookieName('session-id')).toBe('session-id');
|
||||||
|
expect(encodeCookieName('user_pref.v2')).toBe('user_pref.v2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes separators and whitespace', () => {
|
||||||
|
expect(encodeCookieName('user name')).toBe('user%20name');
|
||||||
|
expect(encodeCookieName('a=b')).toBe('a%3Db');
|
||||||
|
expect(encodeCookieName('a;b')).toBe('a%3Bb');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes parentheses', () => {
|
||||||
|
expect(encodeCookieName('a(b)')).toBe('a%28b%29');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseCookieString', () => {
|
||||||
|
it('parses an empty string to an empty map', () => {
|
||||||
|
expect(parseCookieString('').size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses multiple cookies', () => {
|
||||||
|
const map = parseCookieString('theme=dark; sid=abc123');
|
||||||
|
|
||||||
|
expect(map.get('theme')).toBe('dark');
|
||||||
|
expect(map.get('sid')).toBe('abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes names and values by default', () => {
|
||||||
|
const map = parseCookieString('user%20name=a%3Bb');
|
||||||
|
|
||||||
|
expect(map.get('user name')).toBe('a;b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps raw values with an identity decoder', () => {
|
||||||
|
const map = parseCookieString('sid=a%3Bb', value => value);
|
||||||
|
|
||||||
|
expect(map.get('sid')).toBe('a%3Bb');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the first occurrence per name (most specific path wins)', () => {
|
||||||
|
const map = parseCookieString('dup=specific; dup=generic');
|
||||||
|
|
||||||
|
expect(map.get('dup')).toBe('specific');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the value intact when it contains "="', () => {
|
||||||
|
const map = parseCookieString('token=a=b=c');
|
||||||
|
|
||||||
|
expect(map.get('token')).toBe('a=b=c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unwraps double-quoted values via the default decoder, keeps them raw with identity', () => {
|
||||||
|
expect(parseCookieString('quoted="hello"').get('quoted')).toBe('hello');
|
||||||
|
// The raw layer is verbatim — matching what the Cookie Store API reports
|
||||||
|
expect(parseCookieString('quoted="hello"', value => value).get('quoted')).toBe('"hello"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses nameless cookies under the empty name', () => {
|
||||||
|
expect(parseCookieString('=bare').get('')).toBe('bare');
|
||||||
|
expect(parseCookieString('bare').get('')).toBe('bare');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCookieValue', () => {
|
||||||
|
it('finds a cookie by name', () => {
|
||||||
|
expect(getCookieValue('theme=dark; sid=abc', 'sid')).toBe('abc');
|
||||||
|
expect(getCookieValue('theme=dark; sid=abc', 'theme')).toBe('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for a missing cookie or empty string', () => {
|
||||||
|
expect(getCookieValue('theme=dark', 'missing')).toBeNull();
|
||||||
|
expect(getCookieValue('', 'any')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodes the value by default and supports an identity decoder', () => {
|
||||||
|
expect(getCookieValue('sid=a%3Bb', 'sid')).toBe('a;b');
|
||||||
|
expect(getCookieValue('sid=a%3Bb', 'sid', value => value)).toBe('a%3Bb');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches encoded stored names against the raw name', () => {
|
||||||
|
expect(getCookieValue('user%20name=v', 'user name')).toBe('v');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the first occurrence and unwraps quotes via the default decoder', () => {
|
||||||
|
expect(getCookieValue('dup=specific; dup=generic', 'dup')).toBe('specific');
|
||||||
|
expect(getCookieValue('quoted="hello"', 'quoted')).toBe('hello');
|
||||||
|
expect(getCookieValue('quoted="hello"', 'quoted', value => value)).toBe('"hello"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches parseCookieString semantics for edge cases', () => {
|
||||||
|
for (const cookie of ['token=a=b=c', '=bare', 'bare', 'a=1; ; b=2']) {
|
||||||
|
const map = parseCookieString(cookie);
|
||||||
|
|
||||||
|
for (const [name, value] of map)
|
||||||
|
expect(getCookieValue(cookie, name)).toBe(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('serializeCookie', () => {
|
||||||
|
it('always emits Path and SameSite', () => {
|
||||||
|
expect(serializeCookie('theme', 'dark')).toBe('theme=dark; Path=/; SameSite=Lax');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits the provided attributes', () => {
|
||||||
|
expect(serializeCookie('sid', 'abc', {
|
||||||
|
path: '/app',
|
||||||
|
domain: 'example.com',
|
||||||
|
maxAge: 3600,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
})).toBe('sid=abc; Path=/app; Domain=example.com; SameSite=Strict; Max-Age=3600; Secure');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes expires from a Date and from epoch milliseconds', () => {
|
||||||
|
const date = new Date('2030-01-01T00:00:00Z');
|
||||||
|
|
||||||
|
expect(serializeCookie('a', 'b', { expires: date })).toContain(`Expires=${date.toUTCString()}`);
|
||||||
|
expect(serializeCookie('a', 'b', { expires: date.getTime() })).toContain(`Expires=${date.toUTCString()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits Partitioned together with Secure', () => {
|
||||||
|
expect(serializeCookie('a', 'b', { secure: true, partitioned: true })).toContain('; Secure; Partitioned');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expresses deletion as a non-positive Max-Age', () => {
|
||||||
|
expect(serializeCookie('a', '', { maxAge: 0 })).toBe('a=; Path=/; SameSite=Lax; Max-Age=0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on a non-encoded name', () => {
|
||||||
|
expect(() => serializeCookie('a b', 'v')).toThrow(TypeError);
|
||||||
|
expect(() => serializeCookie('a=b', 'v')).toThrow(TypeError);
|
||||||
|
expect(() => serializeCookie('a;b', 'v')).toThrow(TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on a non-encoded value (attribute injection)', () => {
|
||||||
|
expect(() => serializeCookie('a', 'v; Path=/admin')).toThrow(TypeError);
|
||||||
|
expect(() => serializeCookie('a', 'v w')).toThrow(TypeError);
|
||||||
|
expect(() => serializeCookie('a', 'a,b')).toThrow(TypeError);
|
||||||
|
expect(serializeCookie('a', '"quoted"')).toContain('a="quoted"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on SameSite=None without Secure', () => {
|
||||||
|
expect(() => serializeCookie('a', 'b', { sameSite: 'none' })).toThrow(TypeError);
|
||||||
|
expect(serializeCookie('a', 'b', { sameSite: 'none', secure: true })).toContain('SameSite=None');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on Partitioned without Secure', () => {
|
||||||
|
expect(() => serializeCookie('a', 'b', { partitioned: true })).toThrow(TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when name plus value exceeds 4096 bytes (the "=" is not counted)', () => {
|
||||||
|
expect(() => serializeCookie('big', 'x'.repeat(4094))).toThrow(RangeError);
|
||||||
|
expect(serializeCookie('big', 'x'.repeat(4093))).toContain('big=');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
/**
|
||||||
|
* Write-time cookie attributes (the `Set-Cookie` half of RFC 6265).
|
||||||
|
*
|
||||||
|
* Cookies expose no way to read attributes back — these only describe how a
|
||||||
|
* cookie is written (and must be repeated to overwrite or delete it, since a
|
||||||
|
* cookie's identity is its `name` + `domain` + `path`).
|
||||||
|
*/
|
||||||
|
export interface CookieAttributes {
|
||||||
|
/**
|
||||||
|
* The path the cookie is scoped to.
|
||||||
|
*
|
||||||
|
* @default '/'
|
||||||
|
*/
|
||||||
|
path?: string;
|
||||||
|
/**
|
||||||
|
* The domain the cookie is scoped to. Omitted = host-only cookie.
|
||||||
|
*/
|
||||||
|
domain?: string;
|
||||||
|
/**
|
||||||
|
* Lifetime in seconds. Takes precedence over `expires` in browsers when both
|
||||||
|
* are present (RFC 6265 §4.1.2.2). Non-positive values expire the cookie.
|
||||||
|
*/
|
||||||
|
maxAge?: number;
|
||||||
|
/**
|
||||||
|
* Expiry as a `Date` or Unix epoch **milliseconds**. Omitting both `maxAge`
|
||||||
|
* and `expires` creates a session cookie.
|
||||||
|
*/
|
||||||
|
expires?: Date | number;
|
||||||
|
/**
|
||||||
|
* Only send the cookie over HTTPS.
|
||||||
|
*/
|
||||||
|
secure?: boolean;
|
||||||
|
/**
|
||||||
|
* `SameSite` attribute. `'none'` requires `secure: true` — browsers silently
|
||||||
|
* drop the cookie otherwise, so {@link serializeCookie} fails loudly instead.
|
||||||
|
*
|
||||||
|
* @default 'lax'
|
||||||
|
*/
|
||||||
|
sameSite?: 'lax' | 'strict' | 'none';
|
||||||
|
/**
|
||||||
|
* Partition the cookie by top-level site (CHIPS). Requires `secure: true`.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
partitioned?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browsers commonly enforce RFC 6265's minimum of 4096 bytes for `name=value`;
|
||||||
|
* anything longer is silently dropped, so {@link serializeCookie} throws instead.
|
||||||
|
*/
|
||||||
|
const MAX_COOKIE_BYTES = 4096;
|
||||||
|
|
||||||
|
// RFC 6265 cookie-octet allows these characters, but encodeURIComponent escapes
|
||||||
|
// them — un-escape to keep values readable and js-cookie-compatible:
|
||||||
|
// %23 # | %24 $ | %26 & | %2B + | %2F / | %3A : | %3C < | %3D = | %3E > |
|
||||||
|
// %3F ? | %40 @ | %5B [ | %5D ] | %5E ^ | %60 ` | %7B { | %7C | | %7D }
|
||||||
|
const ALLOWED_VALUE_ESCAPES = /%(?:2[346BF]|3[AC-F]|40|5[BDE]|60|7[BCD])/g;
|
||||||
|
|
||||||
|
// Cookie names are RFC 2616 tokens; restore the token characters
|
||||||
|
// encodeURIComponent escapes: %23 # | %24 $ | %26 & | %2B + | %5E ^ | %60 ` | %7C |
|
||||||
|
const ALLOWED_NAME_ESCAPES = /%(?:2[346B]|5E|60|7C)/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name encodeCookieValue
|
||||||
|
* @category Browsers
|
||||||
|
* @description Percent-encodes only the characters a cookie value cannot contain
|
||||||
|
* per RFC 6265 (controls, whitespace, `"` `,` `;` `\` and `%` itself), leaving
|
||||||
|
* everything else readable. Compatible with js-cookie's default write converter.
|
||||||
|
*
|
||||||
|
* @param {string} value The raw cookie value
|
||||||
|
* @returns {string} The encoded cookie value
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* encodeCookieValue('a=b; c'); // 'a=b%3B%20c'
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function encodeCookieValue(value: string): string {
|
||||||
|
return encodeURIComponent(value).replaceAll(ALLOWED_VALUE_ESCAPES, decodeURIComponent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name decodeCookieValue
|
||||||
|
* @category Browsers
|
||||||
|
* @description Decodes a cookie value: unwraps an RFC 6265 DQUOTE-wrapped
|
||||||
|
* value (the quotes are transport dressing, not payload), then decodes
|
||||||
|
* percent-escapes. Malformed escapes (e.g. third-party cookies that never
|
||||||
|
* used percent-encoding) are returned as-is instead of throwing.
|
||||||
|
*
|
||||||
|
* @param {string} value The encoded cookie value
|
||||||
|
* @returns {string} The decoded cookie value
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* decodeCookieValue('a%3Bb'); // 'a;b'
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* decodeCookieValue('"dark"'); // 'dark'
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function decodeCookieValue(value: string): string {
|
||||||
|
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"'))
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return value.replaceAll(/(?:%[\dA-F]{2})+/gi, decodeURIComponent);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name encodeCookieName
|
||||||
|
* @category Browsers
|
||||||
|
* @description Percent-encodes the characters a cookie name cannot contain
|
||||||
|
* (cookie names are RFC 2616 tokens). Typical names — letters, digits, `-`,
|
||||||
|
* `_`, `.` — pass through unchanged. `(` and `)` are escaped as `%28`/`%29`.
|
||||||
|
*
|
||||||
|
* @param {string} name The raw cookie name
|
||||||
|
* @returns {string} The encoded cookie name
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* encodeCookieName('user name'); // 'user%20name'
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function encodeCookieName(name: string): string {
|
||||||
|
return encodeURIComponent(name)
|
||||||
|
.replaceAll(ALLOWED_NAME_ESCAPES, decodeURIComponent)
|
||||||
|
.replaceAll(/[()]/g, c => c === '(' ? '%28' : '%29');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name parseCookieString
|
||||||
|
* @category Browsers
|
||||||
|
* @description Parses a `document.cookie`-style string (`'a=1; b=2'`) into a
|
||||||
|
* `Map` of decoded names to values. Keeps the **first** occurrence per name —
|
||||||
|
* browsers order cookies most-specific-path first, so the first one is the one
|
||||||
|
* a server would use. The raw value is passed to `decode` verbatim (including
|
||||||
|
* any wrapping quotes) so it matches what the Cookie Store API would report;
|
||||||
|
* the default decoder unwraps quotes and percent-escapes.
|
||||||
|
*
|
||||||
|
* @param {string} cookie The cookie string to parse
|
||||||
|
* @param {(value: string) => string} [decode=decodeCookieValue] Value decoder; pass the identity function to keep raw encoded values
|
||||||
|
* @returns {Map<string, string>} Decoded `name -> value` pairs
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* parseCookieString('theme=dark; sid=a%3Bb'); // Map { 'theme' => 'dark', 'sid' => 'a;b' }
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function parseCookieString(cookie: string, decode: (value: string) => string = decodeCookieValue): Map<string, string> {
|
||||||
|
const result = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const pair of cookie.split('; ')) {
|
||||||
|
if (!pair)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const separator = pair.indexOf('=');
|
||||||
|
|
||||||
|
// Nameless cookies ('=value' or bare 'value') parse under the empty name.
|
||||||
|
const rawName = separator === -1 ? '' : pair.slice(0, separator);
|
||||||
|
const rawValue = separator === -1 ? pair : pair.slice(separator + 1);
|
||||||
|
|
||||||
|
const name = decodeCookieValue(rawName);
|
||||||
|
|
||||||
|
if (!result.has(name))
|
||||||
|
result.set(name, decode(rawValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name getCookieValue
|
||||||
|
* @category Browsers
|
||||||
|
* @description Looks up a single cookie in a `document.cookie`-style string
|
||||||
|
* without building a full map — same first-occurrence and verbatim-raw-value
|
||||||
|
* semantics as {@link parseCookieString}, but allocation-free for misses and
|
||||||
|
* cheap for hot paths (reactive reads, polling).
|
||||||
|
*
|
||||||
|
* @param {string} cookie The cookie string to search
|
||||||
|
* @param {string} name The raw (decoded) cookie name to look up
|
||||||
|
* @param {(value: string) => string} [decode=decodeCookieValue] Value decoder; pass the identity function to keep the raw encoded value
|
||||||
|
* @returns {string | null} The cookie value, or `null` when absent
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* getCookieValue('theme=dark; sid=a%3Bb', 'sid'); // 'a;b'
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function getCookieValue(cookie: string, name: string, decode: (value: string) => string = decodeCookieValue): string | null {
|
||||||
|
for (const pair of cookie.split('; ')) {
|
||||||
|
if (!pair)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const separator = pair.indexOf('=');
|
||||||
|
const rawName = separator === -1 ? '' : pair.slice(0, separator);
|
||||||
|
|
||||||
|
// Stored names are encoded — compare directly first (encoding is the
|
||||||
|
// identity for typical names), decode only on mismatch.
|
||||||
|
if (rawName !== name && decodeCookieValue(rawName) !== name)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
return decode(separator === -1 ? pair : pair.slice(separator + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const INVALID_COOKIE_NAME = /[\s;=]/;
|
||||||
|
|
||||||
|
// RFC 6265 cookie-octet (optionally DQUOTE-wrapped): a raw ';' or whitespace
|
||||||
|
// in a value silently truncates it and injects attributes into the cookie.
|
||||||
|
const VALID_COOKIE_VALUE = /^"?[\u0021\u0023-\u002B\u002D-\u003A\u003C-\u005B\u005D-\u007E]*"?$/;
|
||||||
|
|
||||||
|
// Encoded names/values are ASCII, but the inputs are not guaranteed to be —
|
||||||
|
// any string under this length cannot exceed MAX_COOKIE_BYTES even at the
|
||||||
|
// 3-bytes-per-UTF-16-unit worst case, so the common path never pays for an
|
||||||
|
// actual byte count.
|
||||||
|
const SAFE_COOKIE_LENGTH = Math.floor(MAX_COOKIE_BYTES / 3);
|
||||||
|
|
||||||
|
let textEncoder: TextEncoder | undefined;
|
||||||
|
|
||||||
|
function exceedsCookieBytes(pair: string): boolean {
|
||||||
|
if (pair.length <= SAFE_COOKIE_LENGTH)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
textEncoder ??= new TextEncoder();
|
||||||
|
|
||||||
|
return textEncoder.encode(pair).length > MAX_COOKIE_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name serializeCookie
|
||||||
|
* @category Browsers
|
||||||
|
* @description Builds a `document.cookie` assignment string from an
|
||||||
|
* **already-encoded** name and value (see {@link encodeCookieName} /
|
||||||
|
* {@link encodeCookieValue}) plus {@link CookieAttributes}. `Path` and
|
||||||
|
* `SameSite` are always emitted explicitly. Fails loudly on combinations
|
||||||
|
* browsers silently drop: `SameSite=None` or `Partitioned` without `Secure`,
|
||||||
|
* and `name=value` over 4096 UTF-8 bytes.
|
||||||
|
*
|
||||||
|
* @param {string} name The encoded cookie name
|
||||||
|
* @param {string} value The encoded cookie value
|
||||||
|
* @param {CookieAttributes} [attributes={}] Write-time attributes
|
||||||
|
* @returns {string} The string to assign to `document.cookie`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* document.cookie = serializeCookie('theme', 'dark', { maxAge: 3600 });
|
||||||
|
* // 'theme=dark; Path=/; SameSite=Lax; Max-Age=3600'
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Deleting reuses the same identity attributes with a non-positive maxAge
|
||||||
|
* document.cookie = serializeCookie('theme', '', { maxAge: 0 });
|
||||||
|
*
|
||||||
|
* @since 0.0.5
|
||||||
|
*/
|
||||||
|
export function serializeCookie(name: string, value: string, attributes: CookieAttributes = {}): string {
|
||||||
|
const {
|
||||||
|
path = '/',
|
||||||
|
domain,
|
||||||
|
maxAge,
|
||||||
|
expires,
|
||||||
|
secure = false,
|
||||||
|
sameSite = 'lax',
|
||||||
|
partitioned = false,
|
||||||
|
} = attributes;
|
||||||
|
|
||||||
|
if (INVALID_COOKIE_NAME.test(name))
|
||||||
|
throw new TypeError(`[serializeCookie] invalid cookie name "${name}" — encode it with encodeCookieName first`);
|
||||||
|
|
||||||
|
if (!VALID_COOKIE_VALUE.test(value))
|
||||||
|
throw new TypeError(`[serializeCookie] invalid cookie value for "${name}" — encode it with encodeCookieValue first`);
|
||||||
|
|
||||||
|
if (sameSite === 'none' && !secure)
|
||||||
|
throw new TypeError('[serializeCookie] SameSite=None requires Secure — browsers reject the cookie otherwise');
|
||||||
|
|
||||||
|
if (partitioned && !secure)
|
||||||
|
throw new TypeError('[serializeCookie] Partitioned requires Secure — browsers reject the cookie otherwise');
|
||||||
|
|
||||||
|
// Browsers enforce the RFC 6265bis limit on name + value, excluding the '='
|
||||||
|
if (exceedsCookieBytes(`${name}${value}`))
|
||||||
|
throw new RangeError(`[serializeCookie] cookie "${name}" exceeds ${MAX_COOKIE_BYTES} bytes — browsers drop it silently`);
|
||||||
|
|
||||||
|
let cookie = `${name}=${value}; Path=${path}`;
|
||||||
|
|
||||||
|
if (domain)
|
||||||
|
cookie += `; Domain=${domain}`;
|
||||||
|
|
||||||
|
cookie += `; SameSite=${sameSite.charAt(0).toUpperCase()}${sameSite.slice(1)}`;
|
||||||
|
|
||||||
|
if (maxAge !== undefined)
|
||||||
|
cookie += `; Max-Age=${Math.floor(maxAge)}`;
|
||||||
|
|
||||||
|
if (expires !== undefined)
|
||||||
|
cookie += `; Expires=${(expires instanceof Date ? expires : new Date(expires)).toUTCString()}`;
|
||||||
|
|
||||||
|
if (secure)
|
||||||
|
cookie += '; Secure';
|
||||||
|
|
||||||
|
if (partitioned)
|
||||||
|
cookie += '; Partitioned';
|
||||||
|
|
||||||
|
return cookie;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './animationLifecycle';
|
export * from './animationLifecycle';
|
||||||
|
export * from './cookies';
|
||||||
export * from './domStyle';
|
export * from './domStyle';
|
||||||
export * from './focusGuard';
|
export * from './focusGuard';
|
||||||
export * from './focusScope';
|
export * from './focusScope';
|
||||||
|
|||||||
+108
-44
@@ -9,51 +9,54 @@
|
|||||||
@custom-variant dark (&:where(.dark, .dark *));
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
--font-sans: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
|
||||||
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
|
--font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, monospace;
|
||||||
|
--font-display: 'Bricolage Grotesque', 'IBM Plex Sans', system-ui, sans-serif;
|
||||||
|
|
||||||
--radius-card: 0.75rem;
|
--radius-card: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Semantic design tokens — Geist-minimal ──────────────────────────────── */
|
/* ── Semantic design tokens — ink on warm paper, signal-orange instruments ──
|
||||||
|
The site reads like a tool-maker's field manual: warm neutral surfaces,
|
||||||
|
hairline rules, international-orange accents, code-comment labels. */
|
||||||
:root {
|
:root {
|
||||||
--bg: #ffffff;
|
--bg: #faf8f3;
|
||||||
--bg-subtle: #fafafa;
|
--bg-subtle: #f4f1e8;
|
||||||
--bg-elevated: #ffffff;
|
--bg-elevated: #fffdf8;
|
||||||
--bg-inset: #f4f4f5;
|
--bg-inset: #eeeadf;
|
||||||
--border: #ececec;
|
--border: #e5dfd0;
|
||||||
--border-strong: #d8d8dc;
|
--border-strong: #cfc6b1;
|
||||||
--fg: #18181b;
|
--fg: #211e18;
|
||||||
--fg-muted: #52525b;
|
--fg-muted: #5d574b;
|
||||||
--fg-subtle: #a1a1aa;
|
--fg-subtle: #93897a;
|
||||||
--accent: #2563eb;
|
--accent: #d9480f;
|
||||||
--accent-hover: #1d4ed8;
|
--accent-hover: #bf3f0d;
|
||||||
--accent-fg: #ffffff;
|
--accent-fg: #fffdf8;
|
||||||
--accent-subtle: #eef3ff;
|
--accent-subtle: #f7e7d8;
|
||||||
--accent-text: #2563eb;
|
--accent-text: #c2410c;
|
||||||
--header-bg: rgba(255, 255, 255, 0.72);
|
--header-bg: rgba(250, 248, 243, 0.82);
|
||||||
--ring: rgba(37, 99, 235, 0.35);
|
--ring: rgba(217, 72, 15, 0.35);
|
||||||
--shadow-card: 0 1px 2px rgba(16, 24, 40, 0.04), 0 1px 3px rgba(16, 24, 40, 0.06);
|
--shadow-card: 0 1px 2px rgba(56, 44, 28, 0.05), 0 1px 3px rgba(56, 44, 28, 0.07);
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--bg: #0a0a0a;
|
--bg: #161310;
|
||||||
--bg-subtle: #0f0f10;
|
--bg-subtle: #1b1813;
|
||||||
--bg-elevated: #141416;
|
--bg-elevated: #211d17;
|
||||||
--bg-inset: #1b1b1e;
|
--bg-inset: #2a251c;
|
||||||
--border: #232327;
|
--border: #322c22;
|
||||||
--border-strong: #34343a;
|
--border-strong: #4a4231;
|
||||||
--fg: #ededed;
|
--fg: #ece7db;
|
||||||
--fg-muted: #a1a1aa;
|
--fg-muted: #b2a995;
|
||||||
--fg-subtle: #6c6c75;
|
--fg-subtle: #7d7363;
|
||||||
--accent: #3b82f6;
|
--accent: #ff7d33;
|
||||||
--accent-hover: #60a5fa;
|
--accent-hover: #ff9a59;
|
||||||
--accent-fg: #ffffff;
|
--accent-fg: #1d0e04;
|
||||||
--accent-subtle: #14203a;
|
--accent-subtle: #3a2415;
|
||||||
--accent-text: #74a8ff;
|
--accent-text: #ff9c63;
|
||||||
--header-bg: rgba(10, 10, 10, 0.72);
|
--header-bg: rgba(22, 19, 16, 0.82);
|
||||||
--ring: rgba(59, 130, 246, 0.4);
|
--ring: rgba(255, 125, 51, 0.4);
|
||||||
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.5);
|
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
@@ -90,6 +93,55 @@ code, pre, kbd {
|
|||||||
*::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 9999px; }
|
*::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 9999px; }
|
||||||
*::-webkit-scrollbar-track { background: transparent; }
|
*::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|
||||||
|
/* ── Identity helpers ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Section labels styled as code comments: `// sensors` */
|
||||||
|
.comment-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-transform: lowercase;
|
||||||
|
color: var(--fg-subtle);
|
||||||
|
}
|
||||||
|
.comment-label::before {
|
||||||
|
content: '// ';
|
||||||
|
color: var(--accent-text);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Engineering-grid backdrop for heroes, faded out radially */
|
||||||
|
.blueprint {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--border) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--border) 1px, transparent 1px);
|
||||||
|
background-size: 28px 28px;
|
||||||
|
mask-image: radial-gradient(ellipse 75% 90% at 24% 8%, black 25%, transparent 72%);
|
||||||
|
-webkit-mask-image: radial-gradient(ellipse 75% 90% at 24% 8%, black 25%, transparent 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Staggered rise-in for card grids (landing / package index) */
|
||||||
|
@keyframes rise-in {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
.stagger > * {
|
||||||
|
animation: rise-in 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
.stagger > *:nth-child(1) { animation-delay: 0.03s; }
|
||||||
|
.stagger > *:nth-child(2) { animation-delay: 0.07s; }
|
||||||
|
.stagger > *:nth-child(3) { animation-delay: 0.11s; }
|
||||||
|
.stagger > *:nth-child(4) { animation-delay: 0.15s; }
|
||||||
|
.stagger > *:nth-child(5) { animation-delay: 0.19s; }
|
||||||
|
.stagger > *:nth-child(6) { animation-delay: 0.23s; }
|
||||||
|
.stagger > *:nth-child(7) { animation-delay: 0.27s; }
|
||||||
|
.stagger > *:nth-child(8) { animation-delay: 0.31s; }
|
||||||
|
.stagger > *:nth-child(n+9) { animation-delay: 0.35s; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.stagger > * { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Shiki dual-theme: switch to dark colors under .dark */
|
/* Shiki dual-theme: switch to dark colors under .dark */
|
||||||
.dark .shiki,
|
.dark .shiki,
|
||||||
.dark .shiki span {
|
.dark .shiki span {
|
||||||
@@ -100,6 +152,9 @@ code, pre, kbd {
|
|||||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Code blocks sit on the warm surface, not on the theme's own background */
|
||||||
|
.shiki { background-color: transparent !important; }
|
||||||
|
|
||||||
/* ── Markdown (guide) typography ──────────────────────────────────────────── */
|
/* ── Markdown (guide) typography ──────────────────────────────────────────── */
|
||||||
.prose-docs {
|
.prose-docs {
|
||||||
color: var(--fg-muted);
|
color: var(--fg-muted);
|
||||||
@@ -109,6 +164,7 @@ code, pre, kbd {
|
|||||||
.prose-docs > :first-child { margin-top: 0; }
|
.prose-docs > :first-child { margin-top: 0; }
|
||||||
.prose-docs h1 {
|
.prose-docs h1 {
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
|
font-family: var(--font-display);
|
||||||
font-size: 1.875rem;
|
font-size: 1.875rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
@@ -116,6 +172,7 @@ code, pre, kbd {
|
|||||||
}
|
}
|
||||||
.prose-docs h2 {
|
.prose-docs h2 {
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
|
font-family: var(--font-display);
|
||||||
font-size: 1.375rem;
|
font-size: 1.375rem;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
@@ -136,27 +193,28 @@ code, pre, kbd {
|
|||||||
color: var(--accent-text);
|
color: var(--accent-text);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
border-bottom: 1px dotted var(--border-strong);
|
||||||
}
|
}
|
||||||
.prose-docs a:hover { text-decoration: underline; }
|
.prose-docs a:hover { border-bottom-color: var(--accent-text); }
|
||||||
.prose-docs strong { color: var(--fg); font-weight: 600; }
|
.prose-docs strong { color: var(--fg); font-weight: 600; }
|
||||||
.prose-docs ul, .prose-docs ol { margin: 1rem 0; padding-left: 1.5rem; }
|
.prose-docs ul, .prose-docs ol { margin: 1rem 0; padding-left: 1.5rem; }
|
||||||
.prose-docs ul { list-style: disc; }
|
.prose-docs ul { list-style: disc; }
|
||||||
.prose-docs ol { list-style: decimal; }
|
.prose-docs ol { list-style: decimal; }
|
||||||
.prose-docs li { margin: 0.375rem 0; }
|
.prose-docs li { margin: 0.375rem 0; }
|
||||||
.prose-docs li::marker { color: var(--fg-subtle); }
|
.prose-docs li::marker { color: var(--accent-text); }
|
||||||
.prose-docs blockquote {
|
.prose-docs blockquote {
|
||||||
border-left: 3px solid var(--border-strong);
|
border-left: 3px solid var(--accent);
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
margin: 1.25rem 0;
|
margin: 1.25rem 0;
|
||||||
color: var(--fg-muted);
|
color: var(--fg-muted);
|
||||||
}
|
}
|
||||||
.prose-docs hr { border: 0; border-top: 1px solid var(--border); margin: 2rem 0; }
|
.prose-docs hr { border: 0; border-top: 1px dashed var(--border-strong); margin: 2rem 0; }
|
||||||
/* inline code */
|
/* inline code */
|
||||||
.prose-docs :not(pre) > code {
|
.prose-docs :not(pre) > code {
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
background-color: var(--bg-inset);
|
background-color: var(--bg-inset);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.25rem;
|
||||||
padding: 0.1rem 0.35rem;
|
padding: 0.1rem 0.35rem;
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
@@ -164,7 +222,7 @@ code, pre, kbd {
|
|||||||
.prose-docs pre {
|
.prose-docs pre {
|
||||||
background-color: var(--bg-subtle);
|
background-color: var(--bg-subtle);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 0.625rem;
|
border-radius: 0.5rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin: 1.25rem 0;
|
margin: 1.25rem 0;
|
||||||
@@ -183,7 +241,13 @@ code, pre, kbd {
|
|||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.prose-docs th { background-color: var(--bg-subtle); color: var(--fg); font-weight: 600; }
|
.prose-docs th {
|
||||||
|
background-color: var(--bg-subtle);
|
||||||
|
color: var(--fg);
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Page-enter fade for route transitions */
|
/* Page-enter fade for route transitions */
|
||||||
.page-enter-active, .page-leave-active { transition: opacity 0.18s ease, transform 0.18s ease; }
|
.page-enter-active, .page-leave-active { transition: opacity 0.18s ease, transform 0.18s ease; }
|
||||||
|
|||||||
@@ -3,17 +3,8 @@
|
|||||||
size?: 'sm' | 'md';
|
size?: 'sm' | 'md';
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const kindColors: Record<string, string> = {
|
// Monochrome instrument badges: the kind reads from the glyph, not a color.
|
||||||
function: 'bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300',
|
// Components are the one structural exception and carry the accent.
|
||||||
class: 'bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300',
|
|
||||||
interface: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300',
|
|
||||||
type: 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300',
|
|
||||||
enum: 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300',
|
|
||||||
variable: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-500/15 dark:text-zinc-300',
|
|
||||||
component: 'bg-fuchsia-100 text-fuchsia-700 dark:bg-fuchsia-500/15 dark:text-fuchsia-300',
|
|
||||||
guide: 'bg-teal-100 text-teal-700 dark:bg-teal-500/15 dark:text-teal-300',
|
|
||||||
};
|
|
||||||
|
|
||||||
const kindLabels: Record<string, string> = {
|
const kindLabels: Record<string, string> = {
|
||||||
function: 'fn',
|
function: 'fn',
|
||||||
class: 'C',
|
class: 'C',
|
||||||
@@ -29,8 +20,10 @@ const kindLabels: Record<string, string> = {
|
|||||||
<template>
|
<template>
|
||||||
<span
|
<span
|
||||||
:class="[
|
:class="[
|
||||||
'inline-flex items-center justify-center rounded-md font-mono font-semibold shrink-0',
|
'inline-flex items-center justify-center rounded font-mono font-medium shrink-0 border',
|
||||||
kindColors[kind] ?? kindColors.variable,
|
kind === 'component'
|
||||||
|
? 'border-(--accent-subtle) bg-(--accent-subtle) text-(--accent-text)'
|
||||||
|
: 'border-(--border) bg-(--bg-inset) text-(--fg-muted)',
|
||||||
size === 'sm' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs',
|
size === 'sm' ? 'w-5 h-5 text-[10px]' : 'w-6 h-6 text-xs',
|
||||||
]"
|
]"
|
||||||
:title="kind"
|
:title="kind"
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ onUnmounted(() => globalThis.removeEventListener('keydown', onKeydown));
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="shrink-0">
|
||||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
|
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="hidden sm:inline flex-1 text-left">Search…</span>
|
<span class="hidden sm:inline flex-1 text-left font-mono text-[13px]">search…</span>
|
||||||
<kbd class="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-mono bg-(--bg) border border-(--border) rounded text-(--fg-subtle)">⌘K</kbd>
|
<kbd class="hidden sm:inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-mono bg-(--bg) border border-(--border) rounded text-(--fg-subtle)">⌘K</kbd>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -84,17 +84,15 @@ onUnmounted(() => globalThis.removeEventListener('keydown', onKeydown));
|
|||||||
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm" @click="close" />
|
<div class="fixed inset-0 bg-black/40 backdrop-blur-sm" @click="close" />
|
||||||
|
|
||||||
<div class="fixed inset-x-0 top-[12vh] mx-auto max-w-xl px-4">
|
<div class="fixed inset-x-0 top-[12vh] mx-auto max-w-xl px-4">
|
||||||
<div class="bg-(--bg-elevated) rounded-2xl border border-(--border) shadow-2xl overflow-hidden">
|
<div class="bg-(--bg-elevated) rounded-xl border border-(--border) shadow-2xl overflow-hidden">
|
||||||
<div class="flex items-center px-4 border-b border-(--border)">
|
<div class="flex items-center px-4 border-b border-(--border)">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-(--fg-subtle) shrink-0">
|
<span class="font-mono text-base text-(--accent-text) select-none shrink-0">❯</span>
|
||||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
|
|
||||||
</svg>
|
|
||||||
<input
|
<input
|
||||||
v-model="query"
|
v-model="query"
|
||||||
data-search-input
|
data-search-input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search across all packages…"
|
placeholder="search across all packages…"
|
||||||
class="w-full py-3.5 px-3 bg-transparent text-(--fg) placeholder:text-(--fg-subtle) focus:outline-none text-[15px]"
|
class="w-full py-3.5 px-3 bg-transparent text-(--fg) placeholder:text-(--fg-subtle) focus:outline-none font-mono text-[14px]"
|
||||||
>
|
>
|
||||||
<kbd class="hidden sm:inline-flex items-center px-1.5 py-0.5 text-[10px] font-mono bg-(--bg-inset) border border-(--border) rounded text-(--fg-subtle)">ESC</kbd>
|
<kbd class="hidden sm:inline-flex items-center px-1.5 py-0.5 text-[10px] font-mono bg-(--bg-inset) border border-(--border) rounded text-(--fg-subtle)">ESC</kbd>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,16 +6,16 @@
|
|||||||
const variantClasses: Record<string, string> = {
|
const variantClasses: Record<string, string> = {
|
||||||
since: 'bg-(--bg-inset) text-(--fg-muted) border border-(--border)',
|
since: 'bg-(--bg-inset) text-(--fg-muted) border border-(--border)',
|
||||||
neutral: 'bg-(--bg-inset) text-(--fg-muted) border border-(--border)',
|
neutral: 'bg-(--bg-inset) text-(--fg-muted) border border-(--border)',
|
||||||
test: 'bg-emerald-50 text-emerald-700 border border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:border-emerald-500/20',
|
test: 'bg-emerald-50 text-emerald-800 border border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:border-emerald-500/20',
|
||||||
demo: 'bg-blue-50 text-blue-700 border border-blue-200 dark:bg-blue-500/10 dark:text-blue-300 dark:border-blue-500/20',
|
demo: 'bg-(--accent-subtle) text-(--accent-text) border border-(--accent-subtle)',
|
||||||
wip: 'bg-amber-50 text-amber-700 border border-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:border-amber-500/20',
|
wip: 'bg-amber-50 text-amber-800 border border-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:border-amber-500/20',
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span
|
<span
|
||||||
:class="[
|
:class="[
|
||||||
'inline-flex items-center px-2 py-0.5 text-[11px] font-medium rounded-full leading-none h-5',
|
'inline-flex items-center px-1.5 py-0.5 font-mono text-[10px] font-medium rounded leading-none h-4.5 lowercase',
|
||||||
variantClasses[variant ?? 'since'],
|
variantClasses[variant ?? 'since'],
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ function go(id: string) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav v-if="items.length > 0" class="text-sm">
|
<nav v-if="items.length > 0" class="text-sm">
|
||||||
<div class="text-[11px] font-semibold uppercase tracking-wider text-(--fg-subtle) mb-3">
|
<div class="comment-label mb-3">
|
||||||
On this page
|
on this page
|
||||||
</div>
|
</div>
|
||||||
<ul class="space-y-1 border-l border-(--border)">
|
<ul class="space-y-1 border-l border-(--border)">
|
||||||
<li v-for="item in items" :key="item.id">
|
<li v-for="item in items" :key="item.id">
|
||||||
|
|||||||
+118
-24
@@ -17,6 +17,60 @@ function isActive(pkgSlug: string, slug: string) {
|
|||||||
return route.path === `/${pkgSlug}/${slug}`;
|
return route.path === `/${pkgSlug}/${slug}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Category tree: collapsed by default, filterable ───────────────────────
|
||||||
|
const navQuery = ref('');
|
||||||
|
const openCategories = ref(new Set<string>());
|
||||||
|
|
||||||
|
function toggleCategory(slug: string) {
|
||||||
|
if (openCategories.value.has(slug))
|
||||||
|
openCategories.value.delete(slug);
|
||||||
|
else
|
||||||
|
openCategories.value.add(slug);
|
||||||
|
|
||||||
|
openCategories.value = new Set(openCategories.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-open the category that contains the current page
|
||||||
|
watch([currentPackage, () => route.path], () => {
|
||||||
|
const pkg = currentPackage.value;
|
||||||
|
|
||||||
|
if (!pkg || pkg.kind !== 'api') return;
|
||||||
|
|
||||||
|
for (const category of pkg.categories) {
|
||||||
|
if (category.items.some(item => isActive(pkg.slug, item.slug)))
|
||||||
|
openCategories.value.add(category.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
openCategories.value = new Set(openCategories.value);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// Reset the filter when navigating to another package
|
||||||
|
watch(currentPackageSlug, () => {
|
||||||
|
navQuery.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleCategories = computed(() => {
|
||||||
|
const pkg = currentPackage.value;
|
||||||
|
|
||||||
|
if (!pkg || pkg.kind !== 'api') return [];
|
||||||
|
|
||||||
|
const query = navQuery.value.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!query) return pkg.categories;
|
||||||
|
|
||||||
|
return pkg.categories
|
||||||
|
.map(category => ({
|
||||||
|
...category,
|
||||||
|
items: category.items.filter(item => item.name.toLowerCase().includes(query)),
|
||||||
|
}))
|
||||||
|
.filter(category => category.items.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function isCategoryOpen(slug: string) {
|
||||||
|
// Filtering expands every matching category
|
||||||
|
return navQuery.value.trim() !== '' || openCategories.value.has(slug);
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => route.path, () => {
|
watch(() => route.path, () => {
|
||||||
isSidebarOpen.value = false;
|
isSidebarOpen.value = false;
|
||||||
});
|
});
|
||||||
@@ -38,10 +92,13 @@ watch(() => route.path, () => {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<NuxtLink to="/" class="flex items-center gap-2 font-semibold text-[15px] mr-auto">
|
<NuxtLink to="/" class="group flex items-center gap-2.5 mr-auto">
|
||||||
<span class="inline-flex items-center justify-center w-7 h-7 rounded-lg bg-(--fg) text-(--bg) text-xs font-bold">R</span>
|
<span class="inline-flex items-center justify-center w-7 h-7 rounded-md bg-(--accent) text-(--accent-fg) font-mono text-[13px] font-semibold leading-none select-none">
|
||||||
<span class="hidden sm:flex items-center">
|
❯
|
||||||
<span class="text-(--accent-text)">@robonen</span><span class="text-(--fg-subtle)">/</span><span class="text-(--fg)">tools</span>
|
</span>
|
||||||
|
<span class="hidden sm:flex items-baseline font-mono text-[13.5px] tracking-tight">
|
||||||
|
<span class="text-(--fg-subtle)">~/</span><span class="text-(--fg) font-medium">robonen</span><span class="text-(--fg-subtle)">/</span><span class="text-(--accent-text) font-medium">tools</span>
|
||||||
|
<span class="ml-1 inline-block w-1.75 h-3.75 translate-y-0.5 bg-(--accent) opacity-0 group-hover:opacity-80 group-hover:animate-pulse" />
|
||||||
</span>
|
</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
@@ -71,29 +128,38 @@ watch(() => route.path, () => {
|
|||||||
>
|
>
|
||||||
<nav class="h-full overflow-y-auto py-8 px-4 lg:pr-6 lg:pl-0 overscroll-contain">
|
<nav class="h-full overflow-y-auto py-8 px-4 lg:pr-6 lg:pl-0 overscroll-contain">
|
||||||
<div v-for="grp in groups" :key="grp.group" class="mb-7">
|
<div v-for="grp in groups" :key="grp.group" class="mb-7">
|
||||||
<div class="text-[11px] font-semibold uppercase tracking-wider text-(--fg-subtle) mb-2 px-2">
|
<div class="comment-label mb-2 px-2">{{ grp.label.toLowerCase() }}</div>
|
||||||
{{ grp.label }}
|
|
||||||
</div>
|
|
||||||
<ul class="space-y-0.5">
|
<ul class="space-y-0.5">
|
||||||
<li v-for="pkg in grp.packages" :key="pkg.slug">
|
<li v-for="pkg in grp.packages" :key="pkg.slug">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="`/${pkg.slug}`"
|
:to="`/${pkg.slug}`"
|
||||||
:class="[
|
:class="[
|
||||||
'flex items-center justify-between py-1.5 px-2 rounded-lg text-sm transition-colors',
|
'flex items-center justify-between py-1.5 px-2 rounded-md text-sm transition-colors',
|
||||||
currentPackageSlug === pkg.slug
|
currentPackageSlug === pkg.slug
|
||||||
? 'text-(--fg) font-medium bg-(--bg-inset)'
|
? 'text-(--fg) font-medium bg-(--bg-inset)'
|
||||||
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
|
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<span>{{ pkg.name.replace('@robonen/', '') }}</span>
|
<span class="font-mono text-[13px]">{{ pkg.name.replace('@robonen/', '') }}</span>
|
||||||
<span class="text-[10px] uppercase tracking-wide text-(--fg-subtle)">{{ pkg.kind === 'api' ? 'api' : pkg.kind === 'components' ? 'ui' : 'guide' }}</span>
|
<span class="text-[10px] font-mono text-(--fg-subtle)">{{ pkg.kind === 'api' ? 'api' : pkg.kind === 'components' ? 'ui' : 'guide' }}</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<!-- Expanded tree for the current package -->
|
<!-- Expanded tree for the current package -->
|
||||||
<div v-if="currentPackageSlug === pkg.slug && currentPackage" class="mt-1 mb-2 ml-2 pl-3 border-l border-(--border)">
|
<div v-if="currentPackageSlug === pkg.slug && currentPackage" class="mt-1.5 mb-3 ml-2.5 pl-2.5 border-l border-(--border)">
|
||||||
|
<!-- Quick filter — the tree below collapses to matches -->
|
||||||
|
<div v-if="currentPackage.kind === 'api'" class="relative mb-2 mt-1">
|
||||||
|
<span class="absolute left-2 top-1/2 -translate-y-1/2 font-mono text-[11px] text-(--accent-text) select-none">❯</span>
|
||||||
|
<input
|
||||||
|
v-model="navQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="filter…"
|
||||||
|
class="w-full h-7 pl-6 pr-2 font-mono text-[12px] rounded-md bg-(--bg-subtle) border border-(--border) text-(--fg) placeholder:text-(--fg-subtle) focus:outline-none focus:border-(--border-strong) transition-colors"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Hand-authored guide sections (intro + prose pages) -->
|
<!-- Hand-authored guide sections (intro + prose pages) -->
|
||||||
<div v-if="currentPackage.docs.length" class="mb-2">
|
<div v-if="currentPackage.docs.length && !navQuery" class="mb-2">
|
||||||
<div class="text-[11px] font-medium text-(--fg-subtle) py-1 px-1">Guide</div>
|
<div class="comment-label py-1 px-1">guide</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-if="getIntro(currentPackage)">
|
<li v-if="getIntro(currentPackage)">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -101,7 +167,7 @@ watch(() => route.path, () => {
|
|||||||
:class="[
|
:class="[
|
||||||
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
|
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
|
||||||
route.path === `/${pkg.slug}`
|
route.path === `/${pkg.slug}`
|
||||||
? 'text-(--accent-text) bg-(--accent-subtle) font-medium'
|
? 'text-(--accent-text) font-medium'
|
||||||
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
|
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
@@ -114,7 +180,7 @@ watch(() => route.path, () => {
|
|||||||
:class="[
|
:class="[
|
||||||
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
|
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
|
||||||
isActive(pkg.slug, s.slug)
|
isActive(pkg.slug, s.slug)
|
||||||
? 'text-(--accent-text) bg-(--accent-subtle) font-medium'
|
? 'text-(--accent-text) font-medium'
|
||||||
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
|
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
@@ -124,22 +190,50 @@ watch(() => route.path, () => {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- api -->
|
<!-- api: collapsible categories -->
|
||||||
<template v-if="currentPackage.kind === 'api'">
|
<template v-if="currentPackage.kind === 'api'">
|
||||||
<div v-for="cat in currentPackage.categories" :key="cat.slug" class="mb-2">
|
<div v-if="navQuery && visibleCategories.length === 0" class="py-2 px-1 font-mono text-[11px] text-(--fg-subtle)">
|
||||||
<div class="text-[11px] font-medium text-(--fg-subtle) py-1 px-1">{{ cat.name }}</div>
|
no matches
|
||||||
<ul>
|
</div>
|
||||||
|
|
||||||
|
<div v-for="cat in visibleCategories" :key="cat.slug" class="mb-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center gap-1.5 py-1 px-1 rounded-md cursor-pointer group/cat"
|
||||||
|
@click="toggleCategory(cat.slug)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg" width="9" height="9" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
:class="[
|
||||||
|
'shrink-0 text-(--fg-subtle) transition-transform duration-150',
|
||||||
|
isCategoryOpen(cat.slug) ? 'rotate-90' : '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
<span class="comment-label group-hover/cat:text-(--fg-muted) transition-colors">{{ cat.name.toLowerCase() }}</span>
|
||||||
|
<span class="ml-auto font-mono text-[10px] text-(--fg-subtle) tabular-nums">{{ cat.items.length }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul v-if="isCategoryOpen(cat.slug)" class="mb-1.5">
|
||||||
<li v-for="item in cat.items" :key="item.slug">
|
<li v-for="item in cat.items" :key="item.slug">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="`/${pkg.slug}/${item.slug}`"
|
:to="`/${pkg.slug}/${item.slug}`"
|
||||||
:class="[
|
:class="[
|
||||||
'block py-1 px-2 text-[13px] rounded-md font-mono transition-colors truncate',
|
'flex items-center gap-1.5 py-0.75 px-2 text-[13px] rounded-md font-mono transition-colors',
|
||||||
isActive(pkg.slug, item.slug)
|
isActive(pkg.slug, item.slug)
|
||||||
? 'text-(--accent-text) bg-(--accent-subtle) font-medium'
|
? 'text-(--accent-text) font-medium'
|
||||||
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
|
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ item.name }}
|
<span
|
||||||
|
:class="[
|
||||||
|
'shrink-0 text-[10px] select-none transition-opacity',
|
||||||
|
isActive(pkg.slug, item.slug) ? 'opacity-100 text-(--accent-text)' : 'opacity-0',
|
||||||
|
]"
|
||||||
|
>❯</span>
|
||||||
|
<span class="truncate">{{ item.name }}</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -154,7 +248,7 @@ watch(() => route.path, () => {
|
|||||||
:class="[
|
:class="[
|
||||||
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
|
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
|
||||||
isActive(pkg.slug, c.slug)
|
isActive(pkg.slug, c.slug)
|
||||||
? 'text-(--accent-text) bg-(--accent-subtle) font-medium'
|
? 'text-(--accent-text) font-medium'
|
||||||
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
|
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
@@ -171,7 +265,7 @@ watch(() => route.path, () => {
|
|||||||
:class="[
|
:class="[
|
||||||
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
|
'block py-1 px-2 text-[13px] rounded-md transition-colors truncate',
|
||||||
isActive(pkg.slug, s.slug)
|
isActive(pkg.slug, s.slug)
|
||||||
? 'text-(--accent-text) bg-(--accent-subtle) font-medium'
|
? 'text-(--accent-text) font-medium'
|
||||||
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
|
: 'text-(--fg-muted) hover:text-(--fg) hover:bg-(--bg-inset)',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -98,14 +98,14 @@ const toc = computed(() => {
|
|||||||
return items;
|
return items;
|
||||||
});
|
});
|
||||||
|
|
||||||
const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-3';
|
const sectionTitle = 'comment-label mb-3';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="entry" class="xl:grid xl:grid-cols-[minmax(0,1fr)_14rem] xl:gap-12">
|
<div v-if="entry" class="xl:grid xl:grid-cols-[minmax(0,1fr)_14rem] xl:gap-12">
|
||||||
<article class="min-w-0 max-w-3xl">
|
<article class="min-w-0 max-w-3xl">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<nav class="flex items-center gap-1.5 text-sm text-(--fg-subtle) mb-6">
|
<nav class="flex items-center gap-1.5 font-mono text-[13px] text-(--fg-subtle) mb-6">
|
||||||
<NuxtLink :to="`/${pkg.slug}`" class="hover:text-(--fg) transition-colors">{{ pkg.name }}</NuxtLink>
|
<NuxtLink :to="`/${pkg.slug}`" class="hover:text-(--fg) transition-colors">{{ pkg.name }}</NuxtLink>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span class="text-(--fg)">{{ title }}</span>
|
<span class="text-(--fg)">{{ title }}</span>
|
||||||
@@ -116,7 +116,7 @@ const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-
|
|||||||
<header class="mb-8">
|
<header class="mb-8">
|
||||||
<div class="flex items-center gap-2.5 mb-2 flex-wrap">
|
<div class="flex items-center gap-2.5 mb-2 flex-wrap">
|
||||||
<DocsBadge :kind="entry.item.kind" size="md" />
|
<DocsBadge :kind="entry.item.kind" size="md" />
|
||||||
<h1 class="min-w-0 break-words text-2xl font-bold font-mono tracking-tight text-(--fg)">{{ entry.item.name }}</h1>
|
<h1 class="min-w-0 break-words text-[1.6rem] font-semibold font-mono tracking-tight text-(--fg)">{{ entry.item.name }}</h1>
|
||||||
<DocsTag v-if="entry.item.since" :label="`v${entry.item.since}`" variant="neutral" />
|
<DocsTag v-if="entry.item.since" :label="`v${entry.item.since}`" variant="neutral" />
|
||||||
<DocsTag
|
<DocsTag
|
||||||
v-if="entry.item.hasTests"
|
v-if="entry.item.hasTests"
|
||||||
@@ -218,7 +218,7 @@ const sectionTitle = 'text-xs font-semibold uppercase tracking-wider text-(--fg-
|
|||||||
<header class="mb-8">
|
<header class="mb-8">
|
||||||
<div class="flex items-center gap-2.5 mb-2 flex-wrap">
|
<div class="flex items-center gap-2.5 mb-2 flex-wrap">
|
||||||
<DocsBadge kind="component" size="md" />
|
<DocsBadge kind="component" size="md" />
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-(--fg)">{{ entry.component.name }}</h1>
|
<h1 class="font-display text-[1.7rem] font-bold tracking-tight text-(--fg)">{{ entry.component.name }}</h1>
|
||||||
<DocsTag :label="`${entry.component.parts.length} parts`" variant="neutral" />
|
<DocsTag :label="`${entry.component.parts.length} parts`" variant="neutral" />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="entry.component.description" class="text-(--fg-muted) text-[15px] leading-relaxed">
|
<p v-if="entry.component.description" class="text-(--fg-muted) text-[15px] leading-relaxed">
|
||||||
|
|||||||
@@ -22,6 +22,35 @@ const kindLabel = computed(() => ({
|
|||||||
guide: 'Guide',
|
guide: 'Guide',
|
||||||
}[pkg.value!.kind]));
|
}[pkg.value!.kind]));
|
||||||
|
|
||||||
|
// ── API reference: filterable, chip-navigable categories ──────────────────
|
||||||
|
const query = ref('');
|
||||||
|
|
||||||
|
const filteredCategories = computed(() => {
|
||||||
|
if (pkg.value?.kind !== 'api') return [];
|
||||||
|
|
||||||
|
const needle = query.value.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!needle) return pkg.value.categories;
|
||||||
|
|
||||||
|
return pkg.value.categories
|
||||||
|
.map(category => ({
|
||||||
|
...category,
|
||||||
|
items: category.items.filter(item =>
|
||||||
|
item.name.toLowerCase().includes(needle)
|
||||||
|
|| item.description?.toLowerCase().includes(needle),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.filter(category => category.items.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredCount = computed(() =>
|
||||||
|
filteredCategories.value.reduce((total, category) => total + category.items.length, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
function scrollToCategory(catSlug: string) {
|
||||||
|
document.getElementById(`cat-${catSlug}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
// For guide packages, surface the overview section inline.
|
// For guide packages, surface the overview section inline.
|
||||||
const overview = computed(() =>
|
const overview = computed(() =>
|
||||||
pkg.value?.kind === 'guide' ? pkg.value.sections.find(s => s.slug === 'overview') : undefined,
|
pkg.value?.kind === 'guide' ? pkg.value.sections.find(s => s.slug === 'overview') : undefined,
|
||||||
@@ -40,52 +69,75 @@ const otherSections = computed(() =>
|
|||||||
|
|
||||||
<!-- Auto header (shown only when there's no hand-authored intro) -->
|
<!-- Auto header (shown only when there's no hand-authored intro) -->
|
||||||
<header v-else class="mb-8 pb-8 border-b border-(--border)">
|
<header v-else class="mb-8 pb-8 border-b border-(--border)">
|
||||||
|
<div class="comment-label mb-3">{{ kindLabel.toLowerCase() }} · {{ countEntries(pkg) }} entries</div>
|
||||||
<div class="flex items-center gap-2.5 mb-2 flex-wrap">
|
<div class="flex items-center gap-2.5 mb-2 flex-wrap">
|
||||||
<h1 class="font-mono text-2xl font-bold tracking-tight text-(--fg)">{{ pkg.name }}</h1>
|
<h1 class="font-display text-3xl font-bold tracking-tight text-(--fg)">{{ pkg.name }}</h1>
|
||||||
<DocsTag :label="`v${pkg.version}`" variant="neutral" />
|
<DocsTag :label="`v${pkg.version}`" variant="neutral" />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-(--fg-muted) text-[15px] leading-relaxed">{{ pkg.description }}</p>
|
<p class="text-(--fg-muted) text-[15px] leading-relaxed">{{ pkg.description }}</p>
|
||||||
<div class="mt-4 flex items-center gap-3 text-xs text-(--fg-subtle)">
|
|
||||||
<span>{{ kindLabel }}</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>{{ countEntries(pkg) }} entries</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-5">
|
<div class="mt-5">
|
||||||
<DocsCode :code="`pnpm add ${pkg.name}`" lang="bash" />
|
<DocsCode :code="`pnpm add ${pkg.name}`" lang="bash" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- When an intro replaces the header, label the auto-generated reference -->
|
<!-- API: filter + category chips + dense reference grid -->
|
||||||
<h2 v-if="introComponent && pkg.kind === 'api'" class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4 pt-2">
|
|
||||||
API Reference
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<!-- API: categories of items -->
|
|
||||||
<template v-if="pkg.kind === 'api'">
|
<template v-if="pkg.kind === 'api'">
|
||||||
<section v-for="category in pkg.categories" :key="category.slug" class="mb-10">
|
<div class="sticky top-14 z-20 -mx-2 px-2 py-3 backdrop-blur-md" style="background-color: var(--header-bg)">
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4">
|
<div class="relative mb-2.5">
|
||||||
{{ category.name }}
|
<span class="absolute left-3 top-1/2 -translate-y-1/2 font-mono text-sm text-(--accent-text) select-none">❯</span>
|
||||||
<span class="ml-1 text-(--fg-subtle) normal-case font-normal">· {{ category.items.length }}</span>
|
<input
|
||||||
|
v-model="query"
|
||||||
|
type="text"
|
||||||
|
:placeholder="`filter ${countEntries(pkg)} entries…`"
|
||||||
|
class="w-full h-10 pl-8 pr-16 font-mono text-sm rounded-md bg-(--bg-elevated) border border-(--border) text-(--fg) placeholder:text-(--fg-subtle) focus:outline-none focus:border-(--accent) transition-colors"
|
||||||
|
>
|
||||||
|
<span v-if="query" class="absolute right-3 top-1/2 -translate-y-1/2 font-mono text-[11px] text-(--fg-subtle) tabular-nums">
|
||||||
|
{{ filteredCount }} hits
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-1.5 overflow-x-auto pb-1 -mb-1">
|
||||||
|
<button
|
||||||
|
v-for="category in filteredCategories"
|
||||||
|
:key="category.slug"
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 inline-flex items-center gap-1.5 h-6.5 px-2.5 font-mono text-[11px] rounded-full border border-(--border) bg-(--bg-elevated) text-(--fg-muted) hover:border-(--accent) hover:text-(--accent-text) transition-colors cursor-pointer"
|
||||||
|
@click="scrollToCategory(category.slug)"
|
||||||
|
>
|
||||||
|
{{ category.name.toLowerCase() }}
|
||||||
|
<span class="text-(--fg-subtle) tabular-nums">{{ category.items.length }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="query && filteredCategories.length === 0" class="py-16 text-center">
|
||||||
|
<div class="font-mono text-sm text-(--fg-subtle)">// no matches for "{{ query }}"</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-for="category in filteredCategories"
|
||||||
|
:id="`cat-${category.slug}`"
|
||||||
|
:key="category.slug"
|
||||||
|
class="mb-10 scroll-mt-40 pt-4"
|
||||||
|
>
|
||||||
|
<h2 class="comment-label mb-3">
|
||||||
|
{{ category.name.toLowerCase() }} · {{ category.items.length }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="grid grid-cols-1 gap-2">
|
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="item in category.items"
|
v-for="item in category.items"
|
||||||
:key="item.slug"
|
:key="item.slug"
|
||||||
:to="`/${pkg.slug}/${item.slug}`"
|
:to="`/${pkg.slug}/${item.slug}`"
|
||||||
class="group flex items-center gap-3 p-3 rounded-xl border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:bg-(--bg-subtle) transition-all"
|
class="group flex items-start gap-2.5 p-3 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:shadow-(--shadow-card) transition-all"
|
||||||
>
|
>
|
||||||
<DocsBadge :kind="item.kind" />
|
<DocsBadge :kind="item.kind" size="sm" />
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-1.5 flex-wrap">
|
||||||
<span class="font-mono text-sm font-medium text-(--fg) group-hover:text-(--accent-text) transition-colors">{{ item.name }}</span>
|
<span class="font-mono text-[13px] font-medium text-(--fg) group-hover:text-(--accent-text) transition-colors truncate">{{ item.name }}</span>
|
||||||
<DocsTag v-if="item.hasTests" label="tested" variant="test" />
|
|
||||||
<DocsTag v-if="item.hasDemo" label="demo" variant="demo" />
|
<DocsTag v-if="item.hasDemo" label="demo" variant="demo" />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="item.description" class="text-sm text-(--fg-subtle) mt-0.5 truncate">{{ item.description }}</p>
|
<p v-if="item.description" class="text-[12.5px] text-(--fg-subtle) mt-0.5 line-clamp-1">{{ item.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-(--fg-subtle) group-hover:text-(--accent-text) transition-colors shrink-0">
|
|
||||||
<polyline points="9 18 15 12 9 6" />
|
|
||||||
</svg>
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -94,19 +146,19 @@ const otherSections = computed(() =>
|
|||||||
<!-- Components: gallery -->
|
<!-- Components: gallery -->
|
||||||
<template v-else-if="pkg.kind === 'components'">
|
<template v-else-if="pkg.kind === 'components'">
|
||||||
<section>
|
<section>
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4">
|
<h2 class="comment-label mb-4">
|
||||||
All components <span class="normal-case font-normal">· {{ pkg.components.length }}</span>
|
all components · {{ pkg.components.length }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div class="stagger grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="c in pkg.components"
|
v-for="c in pkg.components"
|
||||||
:key="c.slug"
|
:key="c.slug"
|
||||||
:to="`/${pkg.slug}/${c.slug}`"
|
:to="`/${pkg.slug}/${c.slug}`"
|
||||||
class="group block p-4 rounded-xl border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:shadow-(--shadow-card) transition-all"
|
class="group block p-4 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:shadow-(--shadow-card) transition-all"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-2 mb-1.5">
|
<div class="flex items-center justify-between gap-2 mb-1.5">
|
||||||
<span class="font-semibold text-(--fg) group-hover:text-(--accent-text) transition-colors">{{ c.name }}</span>
|
<span class="font-semibold text-(--fg) group-hover:text-(--accent-text) transition-colors">{{ c.name }}</span>
|
||||||
<span class="text-[11px] text-(--fg-subtle)">{{ c.parts.length }} parts</span>
|
<span class="font-mono text-[11px] text-(--fg-subtle) tabular-nums">{{ c.parts.length }} parts</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="c.description" class="text-sm text-(--fg-subtle) line-clamp-2">{{ c.description }}</p>
|
<p v-if="c.description" class="text-sm text-(--fg-subtle) line-clamp-2">{{ c.description }}</p>
|
||||||
<div class="mt-3 flex flex-wrap gap-1">
|
<div class="mt-3 flex flex-wrap gap-1">
|
||||||
@@ -117,7 +169,7 @@ const otherSections = computed(() =>
|
|||||||
>
|
>
|
||||||
{{ part.role }}
|
{{ part.role }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="c.parts.length > 4" class="text-[10px] text-(--fg-subtle) px-1">+{{ c.parts.length - 4 }}</span>
|
<span v-if="c.parts.length > 4" class="text-[10px] font-mono text-(--fg-subtle) px-1">+{{ c.parts.length - 4 }}</span>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,18 +180,16 @@ const otherSections = computed(() =>
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<DocsMarkdown v-if="overview" :source="overview.markdown" />
|
<DocsMarkdown v-if="overview" :source="overview.markdown" />
|
||||||
<section v-if="otherSections.length > 0" class="mt-10 pt-8 border-t border-(--border)">
|
<section v-if="otherSections.length > 0" class="mt-10 pt-8 border-t border-(--border)">
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4">Sections</h2>
|
<h2 class="comment-label mb-4">sections</h2>
|
||||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="s in otherSections"
|
v-for="s in otherSections"
|
||||||
:key="s.slug"
|
:key="s.slug"
|
||||||
:to="`/${pkg.slug}/${s.slug}`"
|
:to="`/${pkg.slug}/${s.slug}`"
|
||||||
class="group flex items-center justify-between gap-3 p-3.5 rounded-xl border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:bg-(--bg-subtle) transition-all"
|
class="group flex items-center justify-between gap-3 p-3.5 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:bg-(--bg-subtle) transition-all"
|
||||||
>
|
>
|
||||||
<span class="text-sm font-medium text-(--fg) group-hover:text-(--accent-text) transition-colors">{{ s.title }}</span>
|
<span class="text-sm font-medium text-(--fg) group-hover:text-(--accent-text) transition-colors">{{ s.title }}</span>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-(--fg-subtle) group-hover:text-(--accent-text) transition-colors shrink-0">
|
<span class="font-mono text-[11px] text-(--fg-subtle) group-hover:text-(--accent-text) transition-colors">❯</span>
|
||||||
<polyline points="9 18 15 12 9 6" />
|
|
||||||
</svg>
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+37
-28
@@ -3,10 +3,10 @@ const groups = getGroupedPackages();
|
|||||||
const packages = getPackages();
|
const packages = getPackages();
|
||||||
const totalItems = getTotalItems();
|
const totalItems = getTotalItems();
|
||||||
|
|
||||||
const kindMeta: Record<string, { label: string; cls: string }> = {
|
const kindLabels: Record<string, string> = {
|
||||||
api: { label: 'API', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300' },
|
api: 'api',
|
||||||
components: { label: 'Components', cls: 'bg-fuchsia-100 text-fuchsia-700 dark:bg-fuchsia-500/15 dark:text-fuchsia-300' },
|
components: 'ui',
|
||||||
guide: { label: 'Guide', cls: 'bg-teal-100 text-teal-700 dark:bg-teal-500/15 dark:text-teal-300' },
|
guide: 'guide',
|
||||||
};
|
};
|
||||||
|
|
||||||
useHead({ title: '@robonen/tools — Documentation' });
|
useHead({ title: '@robonen/tools — Documentation' });
|
||||||
@@ -15,52 +15,61 @@ useHead({ title: '@robonen/tools — Documentation' });
|
|||||||
<template>
|
<template>
|
||||||
<div class="max-w-4xl">
|
<div class="max-w-4xl">
|
||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
<section class="mb-14">
|
<section class="relative mb-16 pt-4">
|
||||||
<div class="inline-flex items-center gap-2 mb-5 px-3 py-1 rounded-full border border-(--border) bg-(--bg-subtle) text-xs text-(--fg-muted)">
|
<div class="blueprint absolute -inset-x-10 -top-14 bottom-0 -z-10" aria-hidden="true" />
|
||||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
|
||||||
Auto-generated from source & JSDoc
|
<div class="comment-label mb-5">field manual · generated from source & jsdoc</div>
|
||||||
</div>
|
|
||||||
<h1 class="text-4xl sm:text-5xl font-bold tracking-tight text-(--fg) mb-4">
|
<h1 class="font-display text-5xl sm:text-6xl font-bold tracking-tight text-(--fg) mb-5 text-balance">
|
||||||
@robonen/tools
|
Tools, documented<span class="text-(--accent)">.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-lg text-(--fg-muted) leading-relaxed max-w-2xl">
|
<p class="text-lg text-(--fg-muted) leading-relaxed max-w-2xl">
|
||||||
A monorepo of TypeScript utilities, Vue composables, headless UI primitives
|
A monorepo of TypeScript utilities, Vue composables, headless UI primitives
|
||||||
and shared tooling — documented, typed and tested.
|
and shared tooling — typed, tested and demoed in place.
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-6 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-(--fg-subtle)">
|
|
||||||
<span><span class="text-(--fg) font-semibold">{{ packages.length }}</span> packages</span>
|
<div class="mt-7 inline-flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-[13px] text-(--fg-subtle) border border-(--border) rounded-md bg-(--bg-elevated) px-3 py-2">
|
||||||
<span><span class="text-(--fg) font-semibold">{{ totalItems }}</span> documented items</span>
|
<span class="text-(--accent-text)">❯</span>
|
||||||
<span><span class="text-(--fg) font-semibold">{{ groups.length }}</span> groups</span>
|
<span><span class="text-(--fg) font-medium tabular-nums">{{ packages.length }}</span> packages</span>
|
||||||
|
<span class="text-(--border-strong)">·</span>
|
||||||
|
<span><span class="text-(--fg) font-medium tabular-nums">{{ totalItems }}</span> documented items</span>
|
||||||
|
<span class="text-(--border-strong)">·</span>
|
||||||
|
<span><span class="text-(--fg) font-medium tabular-nums">{{ groups.length }}</span> groups</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Package groups -->
|
<!-- Package groups -->
|
||||||
<section v-for="grp in groups" :key="grp.group" class="mb-10">
|
<section v-for="grp in groups" :key="grp.group" class="mb-12">
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-wider text-(--fg-subtle) mb-4">
|
<h2 class="comment-label mb-4">{{ grp.label.toLowerCase() }}</h2>
|
||||||
{{ grp.label }}
|
<div class="stagger grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
</h2>
|
|
||||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="pkg in grp.packages"
|
v-for="pkg in grp.packages"
|
||||||
:key="pkg.slug"
|
:key="pkg.slug"
|
||||||
:to="`/${pkg.slug}`"
|
:to="`/${pkg.slug}`"
|
||||||
class="group relative block p-5 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:shadow-(--shadow-card) transition-all"
|
class="group relative block p-5 rounded-card border border-(--border) bg-(--bg-elevated) hover:border-(--border-strong) hover:shadow-(--shadow-card) transition-all overflow-hidden"
|
||||||
>
|
>
|
||||||
|
<!-- Corner notch — fills in on hover like an indicator lamp -->
|
||||||
|
<span
|
||||||
|
class="absolute right-0 top-0 w-2 h-2 bg-(--accent) opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style="clip-path: polygon(100% 0, 0 0, 100% 100%)"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="flex items-start justify-between gap-3 mb-2">
|
<div class="flex items-start justify-between gap-3 mb-2">
|
||||||
<h3 class="font-mono text-sm font-semibold text-(--fg) group-hover:text-(--accent-text) transition-colors">
|
<h3 class="font-mono text-sm font-semibold text-(--fg) group-hover:text-(--accent-text) transition-colors">
|
||||||
{{ pkg.name }}
|
{{ pkg.name }}
|
||||||
</h3>
|
</h3>
|
||||||
<span :class="['text-[10px] px-2 py-0.5 rounded-full font-medium leading-none shrink-0', kindMeta[pkg.kind]?.cls]">
|
<span class="font-mono text-[10px] px-1.5 py-0.5 rounded border border-(--border) bg-(--bg-subtle) text-(--fg-subtle) leading-none shrink-0">
|
||||||
{{ kindMeta[pkg.kind]?.label }}
|
{{ kindLabels[pkg.kind] }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-(--fg-muted) leading-relaxed line-clamp-2">
|
<p class="text-sm text-(--fg-muted) leading-relaxed line-clamp-2">
|
||||||
{{ pkg.description }}
|
{{ pkg.description }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-4 flex items-center gap-3 text-xs text-(--fg-subtle)">
|
<div class="mt-4 flex items-center gap-2 font-mono text-[11px] text-(--fg-subtle)">
|
||||||
<span class="font-mono">v{{ pkg.version }}</span>
|
<span>v{{ pkg.version }}</span>
|
||||||
<span>·</span>
|
<span class="text-(--border-strong)">·</span>
|
||||||
<span>{{ countEntries(pkg) }} {{ pkg.kind === 'components' ? 'components' : pkg.kind === 'guide' ? 'sections' : 'items' }}</span>
|
<span class="tabular-nums">{{ countEntries(pkg) }} {{ pkg.kind === 'components' ? 'components' : pkg.kind === 'guide' ? 'sections' : 'items' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -85,9 +85,30 @@ export default defineNuxtModule({
|
|||||||
filename: 'docs-metadata.ts',
|
filename: 'docs-metadata.ts',
|
||||||
write: true,
|
write: true,
|
||||||
getContents: () => {
|
getContents: () => {
|
||||||
// No indentation (smaller module) and no `as const` — a multi-MB literal
|
// Base64-encode the payload (same trick as the Nitro virtual below):
|
||||||
// type is pathological for tsc, and consumers cast to DocsMetadata anyway.
|
// build-time text replacements rewrite tokens like `import.meta.client`
|
||||||
return `export default ${JSON.stringify(metadata)};`;
|
// → `true` even inside string literals, because esbuild re-emits
|
||||||
|
// strings with escapes normalized before the replacement plugins run —
|
||||||
|
// so code snippets in examples/demo sources can only reach the page
|
||||||
|
// verbatim if the module text never contains them. Decoded once at
|
||||||
|
// module init; works in the browser, Vue SSR, and prerender.
|
||||||
|
const encoded = Buffer.from(JSON.stringify(metadata), 'utf8').toString('base64');
|
||||||
|
|
||||||
|
return `
|
||||||
|
function decodePayload(encoded: string): string {
|
||||||
|
const globalBuffer = (globalThis as { Buffer?: { from: (input: string, encoding: string) => { toString: (encoding: string) => string } } }).Buffer;
|
||||||
|
|
||||||
|
if (globalBuffer)
|
||||||
|
return globalBuffer.from(encoded, 'base64').toString('utf8');
|
||||||
|
|
||||||
|
const binary = atob(encoded);
|
||||||
|
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||||
|
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default JSON.parse(decodePayload(${JSON.stringify(encoded)}));
|
||||||
|
`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -50,8 +50,9 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
fonts: {
|
fonts: {
|
||||||
families: [
|
families: [
|
||||||
{ name: 'Inter', provider: 'google', weights: [400, 500, 600, 700] },
|
{ name: 'IBM Plex Sans', provider: 'google', weights: [400, 500, 600, 700] },
|
||||||
{ name: 'JetBrains Mono', provider: 'google', weights: [400, 500] },
|
{ name: 'IBM Plex Mono', provider: 'google', weights: [400, 500, 600] },
|
||||||
|
{ name: 'Bricolage Grotesque', provider: 'google', weights: [600, 700, 800] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -62,8 +63,8 @@ export default defineNuxtConfig({
|
|||||||
meta: [
|
meta: [
|
||||||
{ name: 'description', content: 'Auto-generated documentation for the @robonen/tools monorepo' },
|
{ name: 'description', content: 'Auto-generated documentation for the @robonen/tools monorepo' },
|
||||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||||
{ name: 'theme-color', content: '#ffffff', media: '(prefers-color-scheme: light)' },
|
{ name: 'theme-color', content: '#faf8f3', media: '(prefers-color-scheme: light)' },
|
||||||
{ name: 'theme-color', content: '#0a0a0a', media: '(prefers-color-scheme: dark)' },
|
{ name: 'theme-color', content: '#161310', media: '(prefers-color-scheme: dark)' },
|
||||||
],
|
],
|
||||||
htmlAttrs: {
|
htmlAttrs: {
|
||||||
lang: 'en',
|
lang: 'en',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export * from './useImage';
|
|||||||
export * from './useLocalFonts';
|
export * from './useLocalFonts';
|
||||||
export * from './useMediaQuery';
|
export * from './useMediaQuery';
|
||||||
export * from './useObjectUrl';
|
export * from './useObjectUrl';
|
||||||
|
export * from './useOtpCredentials';
|
||||||
export * from './usePermission';
|
export * from './usePermission';
|
||||||
export * from './usePreferredColorScheme';
|
export * from './usePreferredColorScheme';
|
||||||
export * from './usePreferredContrast';
|
export * from './usePreferredContrast';
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useOtpCredentials } from './index';
|
||||||
|
|
||||||
|
const { isSupported, code, isReceiving, receive, abort, onReceive, onError } = useOtpCredentials();
|
||||||
|
|
||||||
|
// A local, dismissible presentation error: the composable only clears its own
|
||||||
|
// `error` when a new request starts, so we mirror it here and clear it whenever
|
||||||
|
// the user takes over manually — otherwise a single failed read would pin the
|
||||||
|
// status indicator permanently.
|
||||||
|
const hint = ref<string | null>(null);
|
||||||
|
onError(() => { hint.value = 'Couldn’t read the code automatically — enter it manually.'; });
|
||||||
|
onReceive(() => { hint.value = null; });
|
||||||
|
|
||||||
|
const status = computed(() => {
|
||||||
|
if (hint.value)
|
||||||
|
return { label: hint.value, tone: 'error' as const };
|
||||||
|
if (isReceiving.value)
|
||||||
|
return { label: 'Waiting for the SMS code…', tone: 'pending' as const };
|
||||||
|
if (code.value)
|
||||||
|
return { label: 'Code received', tone: 'ok' as const };
|
||||||
|
return { label: 'Idle', tone: 'idle' as const };
|
||||||
|
});
|
||||||
|
|
||||||
|
function listen() {
|
||||||
|
hint.value = null;
|
||||||
|
receive();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInput(event: Event) {
|
||||||
|
hint.value = null;
|
||||||
|
code.value = (event.target as HTMLInputElement).value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebOTP only works on a device that can receive the SMS (Chrome Android), so
|
||||||
|
// on desktop we let you simulate the auto-fill the API would normally perform.
|
||||||
|
function simulate() {
|
||||||
|
hint.value = null;
|
||||||
|
code.value = Array.from({ length: 6 }, () => Math.floor(Math.random() * 10)).join('');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full max-w-sm flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">WebOTP</span>
|
||||||
|
<span
|
||||||
|
class="rounded-full px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="isSupported
|
||||||
|
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'bg-amber-500/10 text-amber-600 dark:text-amber-400'"
|
||||||
|
>
|
||||||
|
{{ isSupported ? 'supported' : 'unsupported' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
:value="code ?? ''"
|
||||||
|
type="text"
|
||||||
|
inputmode="numeric"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
maxlength="6"
|
||||||
|
placeholder="••••••"
|
||||||
|
class="w-full rounded-xl border border-(--border) bg-(--bg-inset) px-4 py-3 text-center font-mono text-2xl tracking-[0.4em] tabular-nums text-(--fg) outline-none transition focus:border-(--accent)"
|
||||||
|
@input="onInput"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
v-if="!isReceiving"
|
||||||
|
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-(--accent) px-3 py-1.5 text-sm font-medium text-(--accent-fg) transition hover:bg-(--accent-hover) active:scale-[0.98] cursor-pointer"
|
||||||
|
@click="listen"
|
||||||
|
>
|
||||||
|
Listen for code
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="inline-flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-(--border) px-3 py-1.5 text-sm font-medium text-(--fg-muted) transition hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
|
||||||
|
@click="abort()"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center justify-center rounded-lg border border-(--border) px-3 py-1.5 text-sm font-medium text-(--fg-muted) transition hover:border-(--border-strong) active:scale-[0.98] cursor-pointer"
|
||||||
|
@click="simulate"
|
||||||
|
>
|
||||||
|
Simulate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span
|
||||||
|
class="size-2 rounded-full"
|
||||||
|
:class="{
|
||||||
|
'bg-(--fg-subtle)': status.tone === 'idle',
|
||||||
|
'animate-pulse bg-amber-500': status.tone === 'pending',
|
||||||
|
'bg-emerald-500': status.tone === 'ok',
|
||||||
|
'bg-red-500': status.tone === 'error',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<span class="text-(--fg-muted)">{{ status.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick } from 'vue';
|
||||||
|
import { useOtpCredentials } from '.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a fake `window`/`navigator` pair exposing the WebOTP surface. `get`
|
||||||
|
* resolves with an `{ code }` credential by default; tests can override it to
|
||||||
|
* reject (e.g. an AbortError) or hang.
|
||||||
|
*/
|
||||||
|
function createOtpEnv(get = vi.fn(async (_options?: CredentialRequestOptions) => ({ code: '123456' }))) {
|
||||||
|
const window = { OTPCredential: class {} } as unknown as Window & typeof globalThis;
|
||||||
|
const navigator = { credentials: { get } } as unknown as Navigator;
|
||||||
|
return { window, navigator, get };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fake `get` that never resolves on its own — it rejects with an AbortError
|
||||||
|
* the moment its signal aborts (or immediately if already aborted), mirroring
|
||||||
|
* how a real `navigator.credentials.get` reacts to abortion.
|
||||||
|
*/
|
||||||
|
function abortableGet() {
|
||||||
|
return vi.fn((options?: CredentialRequestOptions) =>
|
||||||
|
new Promise<{ code: string }>((_resolve, reject) => {
|
||||||
|
const signal = options!.signal!;
|
||||||
|
const fail = (): void => reject(new DOMException('aborted', 'AbortError'));
|
||||||
|
if (signal.aborted)
|
||||||
|
fail();
|
||||||
|
else
|
||||||
|
signal.addEventListener('abort', fail);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withScope<T>(fn: () => T): { result: T; stop: () => void } {
|
||||||
|
const scope = effectScope();
|
||||||
|
let result!: T;
|
||||||
|
scope.run(() => {
|
||||||
|
result = fn();
|
||||||
|
});
|
||||||
|
return { result, stop: () => scope.stop() };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useOtpCredentials, () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports supported when OTPCredential and navigator.credentials exist', () => {
|
||||||
|
const { window, navigator } = createOtpEnv();
|
||||||
|
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||||
|
expect(result.isSupported.value).toBeTruthy();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unsupported when OTPCredential is absent', () => {
|
||||||
|
const window = {} as unknown as Window & typeof globalThis;
|
||||||
|
const navigator = { credentials: { get: vi.fn() } } as unknown as Navigator;
|
||||||
|
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||||
|
expect(result.isSupported.value).toBeFalsy();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is SSR-safe and a no-op when window/navigator are undefined', async () => {
|
||||||
|
const { result, stop } = withScope(() =>
|
||||||
|
useOtpCredentials({
|
||||||
|
window: undefined as unknown as Window,
|
||||||
|
navigator: undefined as unknown as Navigator,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.isSupported.value).toBeFalsy();
|
||||||
|
await expect(result.receive()).resolves.toBeUndefined();
|
||||||
|
expect(result.code.value).toBeNull();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults code to null and isReceiving to false', () => {
|
||||||
|
const { window, navigator } = createOtpEnv();
|
||||||
|
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||||
|
expect(result.code.value).toBeNull();
|
||||||
|
expect(result.isReceiving.value).toBeFalsy();
|
||||||
|
expect(result.error.value).toBeNull();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves with the code, updates code, and clears isReceiving', async () => {
|
||||||
|
const { window, navigator, get } = createOtpEnv();
|
||||||
|
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||||
|
|
||||||
|
const received = await result.receive();
|
||||||
|
|
||||||
|
expect(get).toHaveBeenCalledTimes(1);
|
||||||
|
expect(received).toBe('123456');
|
||||||
|
expect(result.code.value).toBe('123456');
|
||||||
|
expect(result.isReceiving.value).toBeFalsy();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests the sms transport by default and forwards an abort signal', async () => {
|
||||||
|
const { window, navigator, get } = createOtpEnv();
|
||||||
|
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||||
|
|
||||||
|
await result.receive();
|
||||||
|
|
||||||
|
const passed = get.mock.calls[0]![0]!;
|
||||||
|
expect(passed.otp).toEqual({ transport: ['sms'] });
|
||||||
|
expect(passed.signal).toBeInstanceOf(AbortSignal);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors a per-call transport override', async () => {
|
||||||
|
const { window, navigator, get } = createOtpEnv();
|
||||||
|
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator, transport: ['sms'] }));
|
||||||
|
|
||||||
|
await result.receive({ transport: [] as unknown as OTPTransportType[] });
|
||||||
|
|
||||||
|
expect(get.mock.calls[0]![0]!.otp).toEqual({ transport: [] });
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onReceive listeners and the option callback with the code', async () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
const listener = vi.fn();
|
||||||
|
const { window, navigator } = createOtpEnv();
|
||||||
|
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator, onReceive: callback }));
|
||||||
|
result.onReceive(listener);
|
||||||
|
|
||||||
|
await result.receive();
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith('123456');
|
||||||
|
expect(listener).toHaveBeenCalledWith('123456');
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swallows AbortError without setting error or code', async () => {
|
||||||
|
const get = vi.fn(async () => {
|
||||||
|
throw new DOMException('aborted', 'AbortError');
|
||||||
|
});
|
||||||
|
const { window, navigator } = createOtpEnv(get);
|
||||||
|
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||||
|
|
||||||
|
await expect(result.receive()).resolves.toBeUndefined();
|
||||||
|
expect(result.error.value).toBeNull();
|
||||||
|
expect(result.code.value).toBeNull();
|
||||||
|
expect(result.isReceiving.value).toBeFalsy();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces non-abort errors via error ref, onError hook, and the callback', async () => {
|
||||||
|
const failure = new Error('boom');
|
||||||
|
const get = vi.fn(async () => {
|
||||||
|
throw failure;
|
||||||
|
});
|
||||||
|
const callback = vi.fn();
|
||||||
|
const listener = vi.fn();
|
||||||
|
const { window, navigator } = createOtpEnv(get);
|
||||||
|
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator, onError: callback }));
|
||||||
|
result.onError(listener);
|
||||||
|
|
||||||
|
await expect(result.receive()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(result.error.value).toBe(failure);
|
||||||
|
expect(callback).toHaveBeenCalledWith(failure);
|
||||||
|
expect(listener).toHaveBeenCalledWith(failure);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aborts the previous in-flight request when receive() is called again', async () => {
|
||||||
|
const signals: AbortSignal[] = [];
|
||||||
|
const get = vi.fn((options?: CredentialRequestOptions) =>
|
||||||
|
new Promise<{ code: string }>((resolve, reject) => {
|
||||||
|
const signal = options!.signal!;
|
||||||
|
signals.push(signal);
|
||||||
|
signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
|
||||||
|
// The second call resolves so the test can await a settled value.
|
||||||
|
if (signals.length === 2)
|
||||||
|
resolve({ code: '654321' });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { window, navigator } = createOtpEnv(get as never);
|
||||||
|
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||||
|
|
||||||
|
const first = result.receive();
|
||||||
|
const second = result.receive();
|
||||||
|
|
||||||
|
await expect(first).resolves.toBeUndefined();
|
||||||
|
await expect(second).resolves.toBe('654321');
|
||||||
|
|
||||||
|
expect(signals[0]!.aborted).toBeTruthy();
|
||||||
|
expect(result.code.value).toBe('654321');
|
||||||
|
expect(result.isReceiving.value).toBeFalsy();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a superseded request that resolves late clobber the newer result', async () => {
|
||||||
|
// A signal-unaware `get` (e.g. a polyfill, or an SMS landing as the abort
|
||||||
|
// propagates): it resolves only when the test says so, ignoring abort.
|
||||||
|
const resolvers: Array<(value: { code: string }) => void> = [];
|
||||||
|
const get = vi.fn(() => new Promise<{ code: string }>((resolve) => {
|
||||||
|
resolvers.push(resolve);
|
||||||
|
}));
|
||||||
|
const onReceiveSpy = vi.fn();
|
||||||
|
const { window, navigator } = createOtpEnv(get as never);
|
||||||
|
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||||
|
result.onReceive(onReceiveSpy);
|
||||||
|
|
||||||
|
const first = result.receive(); // #1 -> resolvers[0]
|
||||||
|
const second = result.receive(); // #2 supersedes #1 -> resolvers[1]
|
||||||
|
|
||||||
|
// The current request (#2) resolves first with the fresh code.
|
||||||
|
resolvers[1]!({ code: 'FRESH2' });
|
||||||
|
await expect(second).resolves.toBe('FRESH2');
|
||||||
|
|
||||||
|
// The superseded request (#1) resolves LATE with a stale code — it must be
|
||||||
|
// ignored: no clobber of `code`, no onReceive with the stale value.
|
||||||
|
resolvers[0]!({ code: 'STALE1' });
|
||||||
|
await expect(first).resolves.toBeUndefined();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(result.code.value).toBe('FRESH2');
|
||||||
|
expect(onReceiveSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onReceiveSpy).toHaveBeenCalledWith('FRESH2');
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps isReceiving true while a superseded request settles and a newer one is still in flight', async () => {
|
||||||
|
const { window, navigator } = createOtpEnv(abortableGet() as never);
|
||||||
|
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||||
|
|
||||||
|
const first = result.receive(); // A: pending
|
||||||
|
void result.receive(); // B: supersedes A (aborts it), still pending
|
||||||
|
|
||||||
|
await expect(first).resolves.toBeUndefined(); // A's AbortError swallowed
|
||||||
|
await Promise.resolve(); // let A's finally microtask run
|
||||||
|
|
||||||
|
// A is superseded, so its teardown must NOT clear B's in-flight flag.
|
||||||
|
expect(result.isReceiving.value).toBeTruthy();
|
||||||
|
|
||||||
|
result.abort();
|
||||||
|
expect(result.isReceiving.value).toBeFalsy();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears a previous error after a successful receive', async () => {
|
||||||
|
const failure = new Error('boom');
|
||||||
|
const get = vi.fn()
|
||||||
|
.mockRejectedValueOnce(failure)
|
||||||
|
.mockResolvedValueOnce({ code: '123456' });
|
||||||
|
const { window, navigator } = createOtpEnv(get as never);
|
||||||
|
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||||
|
|
||||||
|
await result.receive();
|
||||||
|
expect(result.error.value).toBe(failure);
|
||||||
|
|
||||||
|
const received = await result.receive();
|
||||||
|
expect(received).toBe('123456');
|
||||||
|
expect(result.error.value).toBeNull();
|
||||||
|
expect(result.code.value).toBe('123456');
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('abort() cancels the in-flight request and clears isReceiving', async () => {
|
||||||
|
const { window, navigator } = createOtpEnv(abortableGet() as never);
|
||||||
|
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||||
|
|
||||||
|
const pending = result.receive();
|
||||||
|
expect(result.isReceiving.value).toBeTruthy();
|
||||||
|
|
||||||
|
result.abort();
|
||||||
|
expect(result.isReceiving.value).toBeFalsy();
|
||||||
|
|
||||||
|
await expect(pending).resolves.toBeUndefined();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aborts when an already-aborted external signal is supplied', async () => {
|
||||||
|
const { window, navigator } = createOtpEnv(abortableGet() as never);
|
||||||
|
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(result.receive({ signal: controller.signal })).resolves.toBeUndefined();
|
||||||
|
expect(result.error.value).toBeNull();
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aborts the request when the surrounding scope is disposed', async () => {
|
||||||
|
const { window, navigator } = createOtpEnv(abortableGet() as never);
|
||||||
|
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator }));
|
||||||
|
|
||||||
|
const pending = result.receive();
|
||||||
|
stop();
|
||||||
|
|
||||||
|
await expect(pending).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts listening immediately when immediate is true', async () => {
|
||||||
|
const { window, navigator, get } = createOtpEnv();
|
||||||
|
const { result, stop } = withScope(() => useOtpCredentials({ window, navigator, immediate: true }));
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
expect(get).toHaveBeenCalledTimes(1);
|
||||||
|
// Allow the microtask chain from the immediate receive() to settle.
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(result.code.value).toBe('123456');
|
||||||
|
stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import { shallowReadonly, shallowRef } from 'vue';
|
||||||
|
import type { ComputedRef, ShallowRef } from 'vue';
|
||||||
|
import { noop } from '@robonen/stdlib';
|
||||||
|
import { defaultNavigator, defaultWindow } from '@/types';
|
||||||
|
import type { ConfigurableNavigator, ConfigurableWindow } from '@/types';
|
||||||
|
import { useSupported } from '@/composables/utilities/useSupported';
|
||||||
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||||
|
import { createEventHook } from '@/composables/utilities/createEventHook';
|
||||||
|
import type { EventHookOn } from '@/composables/utilities/createEventHook';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-call overrides for {@link UseOtpCredentialsReturn.receive}.
|
||||||
|
*/
|
||||||
|
export interface OtpCredentialsRequestOptions {
|
||||||
|
/**
|
||||||
|
* Transports the OTP may be delivered over. Currently only `'sms'` is
|
||||||
|
* specified by the WebOTP API.
|
||||||
|
*
|
||||||
|
* @default the composable-level `transport` option (`['sms']`)
|
||||||
|
*/
|
||||||
|
transport?: OTPTransportType[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An external `AbortSignal` merged with the composable's own controller, so
|
||||||
|
* the request is aborted when either fires (e.g. pair with
|
||||||
|
* `AbortSignal.timeout(30_000)` to give up after 30s).
|
||||||
|
*
|
||||||
|
* @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal
|
||||||
|
*/
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseOtpCredentialsOptions extends ConfigurableWindow, ConfigurableNavigator {
|
||||||
|
/**
|
||||||
|
* Transports the OTP may be delivered over. Currently only `'sms'` is
|
||||||
|
* specified by the WebOTP API.
|
||||||
|
*
|
||||||
|
* @default ['sms']
|
||||||
|
*/
|
||||||
|
transport?: OTPTransportType[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begin listening for an OTP immediately (on the client). The request is a
|
||||||
|
* no-op during SSR or when the API is unsupported.
|
||||||
|
*
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
immediate?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An external `AbortSignal` merged with the composable's own controller for
|
||||||
|
* every `receive()` call.
|
||||||
|
*/
|
||||||
|
signal?: AbortSignal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called with the OTP code whenever one is received. Equivalent to
|
||||||
|
* registering a listener via the returned `onReceive`.
|
||||||
|
*
|
||||||
|
* @default () => {}
|
||||||
|
*/
|
||||||
|
onReceive?: (code: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a request fails for a reason other than abort. Equivalent to
|
||||||
|
* registering a listener via the returned `onError`.
|
||||||
|
*
|
||||||
|
* @default () => {}
|
||||||
|
*/
|
||||||
|
onError?: (error: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseOtpCredentialsReturn {
|
||||||
|
/**
|
||||||
|
* Whether the [WebOTP API](https://developer.mozilla.org/en-US/docs/Web/API/WebOTP_API) is supported.
|
||||||
|
*/
|
||||||
|
isSupported: ComputedRef<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The most recently received OTP code, or `null` before the first one
|
||||||
|
* arrives. Writable so consumers can clear it.
|
||||||
|
*/
|
||||||
|
code: ShallowRef<string | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a request is currently in flight (waiting for the user to deliver
|
||||||
|
* the OTP).
|
||||||
|
*/
|
||||||
|
isReceiving: Readonly<ShallowRef<boolean>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The last non-abort error, or `null`. Aborts are part of normal lifecycle
|
||||||
|
* and are never surfaced here.
|
||||||
|
*/
|
||||||
|
error: Readonly<ShallowRef<unknown>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start listening for an OTP. Resolves with the received code, or
|
||||||
|
* `undefined` when the request is aborted, errors, or the API is
|
||||||
|
* unsupported. Only one request can be active at a time — calling `receive`
|
||||||
|
* again aborts the previous one. Never rejects; failures surface via
|
||||||
|
* `error` / `onError`.
|
||||||
|
*/
|
||||||
|
receive: (overrideOptions?: OtpCredentialsRequestOptions) => Promise<string | undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abort the in-flight request, if any.
|
||||||
|
*/
|
||||||
|
abort: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a listener fired with the code each time an OTP is received.
|
||||||
|
*/
|
||||||
|
onReceive: EventHookOn<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a listener fired with the error when a request fails (non-abort).
|
||||||
|
*/
|
||||||
|
onError: EventHookOn<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_TRANSPORT: OTPTransportType[] = ['sms'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useOtpCredentials
|
||||||
|
* @category Browser
|
||||||
|
* @description Reactive, SSR-safe wrapper around the [WebOTP API](https://developer.mozilla.org/en-US/docs/Web/API/WebOTP_API)
|
||||||
|
* (`navigator.credentials.get({ otp })`) for auto-reading one-time passwords
|
||||||
|
* delivered by SMS. Exposes the received `code`, in-flight/error state,
|
||||||
|
* `receive()`/`abort()` controls, and `onReceive`/`onError` hooks. Pairs with
|
||||||
|
* an `<input autocomplete="one-time-code">`.
|
||||||
|
*
|
||||||
|
* @param {UseOtpCredentialsOptions} [options={}] Options (`transport`, `immediate`, `signal`, `onReceive`, `onError`, custom `window`/`navigator`)
|
||||||
|
* @returns {UseOtpCredentialsReturn} `{ isSupported, code, isReceiving, error, receive, abort, onReceive, onError }`
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { isSupported, code, receive } = useOtpCredentials();
|
||||||
|
* if (isSupported.value) {
|
||||||
|
* const otp = await receive();
|
||||||
|
* if (otp)
|
||||||
|
* form.code = otp;
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Start listening on mount and react via the hook
|
||||||
|
* const { onReceive } = useOtpCredentials({ immediate: true });
|
||||||
|
* onReceive((code) => { form.code = code; });
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Give up after 30 seconds via an external signal
|
||||||
|
* const { receive } = useOtpCredentials();
|
||||||
|
* receive({ signal: AbortSignal.timeout(30_000) });
|
||||||
|
*
|
||||||
|
* @since 0.0.15
|
||||||
|
*/
|
||||||
|
export function useOtpCredentials(options: UseOtpCredentialsOptions = {}): UseOtpCredentialsReturn {
|
||||||
|
const {
|
||||||
|
window = defaultWindow,
|
||||||
|
navigator = defaultNavigator,
|
||||||
|
transport = DEFAULT_TRANSPORT,
|
||||||
|
immediate = false,
|
||||||
|
signal: defaultSignal,
|
||||||
|
onReceive: onReceiveCallback = noop,
|
||||||
|
onError: onErrorCallback = noop,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const isSupported = useSupported(() =>
|
||||||
|
!!window
|
||||||
|
&& 'OTPCredential' in window
|
||||||
|
&& !!navigator
|
||||||
|
&& 'credentials' in navigator,
|
||||||
|
);
|
||||||
|
|
||||||
|
const code = shallowRef<string | null>(null);
|
||||||
|
const isReceiving = shallowRef(false);
|
||||||
|
const error = shallowRef<unknown>(null);
|
||||||
|
|
||||||
|
const { on: onReceive, trigger: receiveTrigger } = createEventHook<string>();
|
||||||
|
const { on: onError, trigger: errorTrigger } = createEventHook<unknown>();
|
||||||
|
|
||||||
|
if (onReceiveCallback !== noop)
|
||||||
|
onReceive(onReceiveCallback);
|
||||||
|
if (onErrorCallback !== noop)
|
||||||
|
onError(onErrorCallback);
|
||||||
|
|
||||||
|
// Only one WebOTP request may be in flight at a time. We keep the active
|
||||||
|
// controller so a new receive() (or abort()) can cancel the previous one.
|
||||||
|
let controller: AbortController | null = null;
|
||||||
|
|
||||||
|
function abort(): void {
|
||||||
|
controller?.abort();
|
||||||
|
controller = null;
|
||||||
|
isReceiving.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function receive(overrideOptions: OtpCredentialsRequestOptions = {}): Promise<string | undefined> {
|
||||||
|
if (!isSupported.value || !navigator)
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
// Cancel any previous request before starting a new one.
|
||||||
|
controller?.abort();
|
||||||
|
|
||||||
|
const ownController = new AbortController();
|
||||||
|
controller = ownController;
|
||||||
|
|
||||||
|
// Merge an external signal: abort our controller when it fires.
|
||||||
|
const externalSignal = overrideOptions.signal ?? defaultSignal;
|
||||||
|
let unlinkExternal = noop;
|
||||||
|
if (externalSignal) {
|
||||||
|
if (externalSignal.aborted) {
|
||||||
|
ownController.abort();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const onExternalAbort = (): void => ownController.abort();
|
||||||
|
externalSignal.addEventListener('abort', onExternalAbort, { once: true });
|
||||||
|
unlinkExternal = () => externalSignal.removeEventListener('abort', onExternalAbort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
error.value = null;
|
||||||
|
isReceiving.value = true;
|
||||||
|
|
||||||
|
// A request is "current" only while it still owns the shared controller. A
|
||||||
|
// newer receive() or an abort() replaces/clears it, after which this
|
||||||
|
// request must neither commit its result nor tear down shared state — so a
|
||||||
|
// superseded or late-resolving request can never clobber a fresher one.
|
||||||
|
const isCurrent = (): boolean => controller === ownController;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credential = await navigator.credentials.get({
|
||||||
|
otp: { transport: overrideOptions.transport ?? transport },
|
||||||
|
signal: ownController.signal,
|
||||||
|
}) as OTPCredential | null;
|
||||||
|
|
||||||
|
const received = credential?.code;
|
||||||
|
if (!received || !isCurrent())
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
code.value = received;
|
||||||
|
receiveTrigger(received);
|
||||||
|
|
||||||
|
return received;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
// Aborts are part of normal lifecycle (unmount, re-request, timeout) —
|
||||||
|
// swallow them rather than surfacing as errors. A superseded request's
|
||||||
|
// error is likewise stale and must not overwrite the live one's state.
|
||||||
|
if (!isCurrent() || (err instanceof DOMException && err.name === 'AbortError'))
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
error.value = err;
|
||||||
|
errorTrigger(err);
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
unlinkExternal();
|
||||||
|
// Only the latest request owns the shared state.
|
||||||
|
if (isCurrent()) {
|
||||||
|
controller = null;
|
||||||
|
isReceiving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (immediate)
|
||||||
|
receive();
|
||||||
|
|
||||||
|
tryOnScopeDispose(abort);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isSupported,
|
||||||
|
code,
|
||||||
|
isReceiving: shallowReadonly(isReceiving),
|
||||||
|
error: shallowReadonly(error),
|
||||||
|
receive,
|
||||||
|
abort,
|
||||||
|
onReceive,
|
||||||
|
onError,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from './useCookie';
|
||||||
export * from './useLocalStorage';
|
export * from './useLocalStorage';
|
||||||
export * from './useSessionStorage';
|
export * from './useSessionStorage';
|
||||||
export * from './useStorage';
|
export * from './useStorage';
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watchEffect } from 'vue';
|
||||||
|
import { useCookie } from './index';
|
||||||
|
|
||||||
|
// One reactive cookie for the consent preferences. In browsers with the
|
||||||
|
// Cookie Store API changes from other tabs (and even server Set-Cookie
|
||||||
|
// responses) sync automatically; elsewhere writes still sync between
|
||||||
|
// components on this page.
|
||||||
|
const { state: consent, isReady } = useCookie('demo:consent', {
|
||||||
|
analytics: false,
|
||||||
|
marketing: false,
|
||||||
|
}, { maxAge: 60 * 60 * 24 });
|
||||||
|
|
||||||
|
// A second instance bound to the same cookie — flips in sync with the first.
|
||||||
|
// writeDefaults is off so this read-only mirror never races the first
|
||||||
|
// instance's attributes (it has no maxAge of its own).
|
||||||
|
const { state: mirror } = useCookie('demo:consent', {
|
||||||
|
analytics: false,
|
||||||
|
marketing: false,
|
||||||
|
}, { writeDefaults: false });
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
{ key: 'analytics', label: 'Analytics', hint: 'Usage metrics' },
|
||||||
|
{ key: 'marketing', label: 'Marketing', hint: 'Personalized ads' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function toggle(key: (typeof categories)[number]['key']) {
|
||||||
|
consent.value = { ...consent.value, [key]: !consent.value[key] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const supportsCookieStore = typeof window !== 'undefined' && 'cookieStore' in window;
|
||||||
|
|
||||||
|
// Show the raw cookie as the browser stores it.
|
||||||
|
const rawCookie = ref('');
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
void consent.value;
|
||||||
|
void mirror.value;
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined')
|
||||||
|
rawCookie.value = document.cookie.split('; ').find(part => part.startsWith('demo%3Aconsent=')) ?? '(no cookie)';
|
||||||
|
});
|
||||||
|
|
||||||
|
const accepted = computed(() => Object.values(consent.value).filter(Boolean).length);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full max-w-sm flex flex-col gap-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium uppercase tracking-wide text-(--fg-subtle)">Cookie consent</span>
|
||||||
|
<span class="inline-flex items-center gap-1.5 rounded-md border border-(--border) bg-(--bg-inset) px-2 py-0.5 text-xs font-medium text-(--fg-muted)">
|
||||||
|
<span class="h-1.5 w-1.5 rounded-full" :class="isReady ? 'bg-emerald-500' : 'bg-(--fg-subtle) animate-pulse'" />
|
||||||
|
{{ supportsCookieStore ? 'Cookie Store API' : 'document.cookie' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-(--border) bg-(--bg-elevated) p-4 flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
v-for="category in categories"
|
||||||
|
:key="category.key"
|
||||||
|
type="button"
|
||||||
|
class="flex items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left transition active:scale-[0.99] cursor-pointer"
|
||||||
|
:class="consent[category.key]
|
||||||
|
? 'border-transparent bg-(--accent)/10'
|
||||||
|
: 'border-(--border) bg-(--bg-inset) hover:border-(--border-strong)'"
|
||||||
|
@click="toggle(category.key)"
|
||||||
|
>
|
||||||
|
<span class="flex flex-col">
|
||||||
|
<span class="text-sm font-medium text-(--fg)">{{ category.label }}</span>
|
||||||
|
<span class="text-xs text-(--fg-subtle)">{{ category.hint }}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="relative h-5 w-9 shrink-0 rounded-full transition"
|
||||||
|
:class="consent[category.key] ? 'bg-(--accent)' : 'bg-(--border-strong)'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all"
|
||||||
|
:class="consent[category.key] ? 'left-4.5' : 'left-0.5'"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-(--border) bg-(--bg-inset) p-3 flex flex-col gap-2 font-mono text-xs text-(--fg)">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-(--fg-subtle)">second instance</span>
|
||||||
|
<span>{{ accepted }}/{{ categories.length }} accepted · {{ JSON.stringify(mirror) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="truncate text-(--fg-muted)" :title="rawCookie">
|
||||||
|
{{ rawCookie }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-(--fg-subtle)">
|
||||||
|
Both cards bind to the same cookie (24h Max-Age) — toggling one updates the other through cookie change events.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,738 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { effectScope, nextTick, ref } from 'vue';
|
||||||
|
import { useCookie } from '.';
|
||||||
|
import type { CookieStorageLike } from '.';
|
||||||
|
|
||||||
|
function flushWrites() {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawCookie(name: string): string | null {
|
||||||
|
const pair = document.cookie.split('; ').find(part => part.startsWith(`${name}=`));
|
||||||
|
|
||||||
|
return pair ? pair.slice(name.length + 1) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal in-memory `document` for asserting the exact strings assigned to
|
||||||
|
* `document.cookie` (jsdom normalizes attributes away on read-back).
|
||||||
|
*/
|
||||||
|
function createFakeDocument() {
|
||||||
|
const jar = new Map<string, string>();
|
||||||
|
const writes: string[] = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
writes,
|
||||||
|
document: {
|
||||||
|
get cookie() {
|
||||||
|
return [...jar].map(([name, value]) => `${name}=${value}`).join('; ');
|
||||||
|
},
|
||||||
|
set cookie(input: string) {
|
||||||
|
writes.push(input);
|
||||||
|
|
||||||
|
const [pair = ''] = input.split(';');
|
||||||
|
const separator = pair.indexOf('=');
|
||||||
|
const name = pair.slice(0, separator);
|
||||||
|
const value = pair.slice(separator + 1);
|
||||||
|
|
||||||
|
if (/Max-Age=(?:0|-\d+)/i.test(input))
|
||||||
|
jar.delete(name);
|
||||||
|
else
|
||||||
|
jar.set(name, value);
|
||||||
|
},
|
||||||
|
} as unknown as Document,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal Cookie Store API fake: same `get`/`set`/`delete` surface, fires the
|
||||||
|
* `change` event as a macrotask after each commit (like real browsers).
|
||||||
|
*/
|
||||||
|
class FakeCookieStore extends EventTarget {
|
||||||
|
jar = new Map<string, string>();
|
||||||
|
setCalls: CookieInit[] = [];
|
||||||
|
deleteCalls: CookieStoreDeleteOptions[] = [];
|
||||||
|
|
||||||
|
async get(name: string): Promise<CookieListItem | null> {
|
||||||
|
return this.jar.has(name) ? { name, value: this.jar.get(name)! } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async set(init: CookieInit): Promise<void> {
|
||||||
|
this.setCalls.push(init);
|
||||||
|
this.jar.set(init.name, init.value);
|
||||||
|
setTimeout(() => this.fireChange([{ name: init.name, value: init.value }], []), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(options: CookieStoreDeleteOptions): Promise<void> {
|
||||||
|
this.deleteCalls.push(options);
|
||||||
|
this.jar.delete(options.name);
|
||||||
|
setTimeout(() => this.fireChange([], [{ name: options.name }]), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fireChange(changed: CookieListItem[], deleted: CookieListItem[]) {
|
||||||
|
this.dispatchEvent(Object.assign(new Event('change'), { changed, deleted }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCookieStoreWindow() {
|
||||||
|
const cookieStore = new FakeCookieStore();
|
||||||
|
const fakeWindow = Object.create(globalThis) as Window;
|
||||||
|
|
||||||
|
Object.defineProperty(fakeWindow, 'cookieStore', { value: cookieStore });
|
||||||
|
|
||||||
|
return { cookieStore, window: fakeWindow };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe(useCookie, () => {
|
||||||
|
describe('document.cookie fallback', () => {
|
||||||
|
// --- Basic read/write ---
|
||||||
|
|
||||||
|
it('reads an existing cookie synchronously and is ready immediately', () => {
|
||||||
|
document.cookie = 'uc-read=stored; Path=/';
|
||||||
|
|
||||||
|
const { state, isReady } = useCookie('uc-read', 'default');
|
||||||
|
|
||||||
|
expect(state.value).toBe('stored');
|
||||||
|
expect(isReady.value).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is awaitable and resolves with the shell', async () => {
|
||||||
|
document.cookie = 'uc-await=stored; Path=/';
|
||||||
|
|
||||||
|
const { state, isReady } = await useCookie('uc-await', 'default');
|
||||||
|
|
||||||
|
expect(state.value).toBe('stored');
|
||||||
|
expect(isReady.value).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the cookie on state change', async () => {
|
||||||
|
const { state } = useCookie<string>('uc-write', 'initial');
|
||||||
|
|
||||||
|
state.value = 'updated';
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(rawCookie('uc-write')).toBe('updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes the cookie when state is set to null', async () => {
|
||||||
|
document.cookie = 'uc-del=exists; Path=/';
|
||||||
|
|
||||||
|
const { state } = useCookie<string | null>('uc-del', 'default');
|
||||||
|
|
||||||
|
state.value = null;
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(rawCookie('uc-del')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- writeDefaults ---
|
||||||
|
|
||||||
|
it('persists the default when the cookie does not exist', async () => {
|
||||||
|
useCookie('uc-defaults', 'fallback');
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(rawCookie('uc-defaults')).toBe('fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not persist the default when writeDefaults is false', async () => {
|
||||||
|
useCookie('uc-no-defaults', 'fallback', { writeDefaults: false });
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(rawCookie('uc-no-defaults')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Serialization & encoding ---
|
||||||
|
|
||||||
|
it('round-trips objects through encoding', async () => {
|
||||||
|
const { state } = useCookie('uc-obj', { theme: 'dark', items: [1, 2] });
|
||||||
|
|
||||||
|
state.value = { theme: 'light', items: [3] };
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
const { state: other } = useCookie('uc-obj', { theme: 'none', items: [] as number[] });
|
||||||
|
|
||||||
|
expect(other.value).toEqual({ theme: 'light', items: [3] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('percent-encodes values that cookies cannot contain', async () => {
|
||||||
|
const { state } = useCookie<string>('uc-enc', '');
|
||||||
|
|
||||||
|
state.value = 'a;b c';
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(rawCookie('uc-enc')).toBe('a%3Bb%20c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes the cookie name', async () => {
|
||||||
|
const { state } = useCookie<string>('uc enc name', '');
|
||||||
|
|
||||||
|
state.value = 'v';
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(rawCookie('uc%20enc%20name')).toBe('v');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a custom serializer', async () => {
|
||||||
|
document.cookie = 'uc-ser=1,2,3; Path=/';
|
||||||
|
|
||||||
|
const serializer = {
|
||||||
|
read: (v: string) => v.split(',').map(Number),
|
||||||
|
write: (v: number[]) => v.join(','),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { state } = useCookie('uc-ser', [0], { serializer });
|
||||||
|
|
||||||
|
expect(state.value).toEqual([1, 2, 3]);
|
||||||
|
|
||||||
|
state.value = [4, 5];
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
// The comma is percent-encoded at the cookie layer, below the serializer.
|
||||||
|
expect(rawCookie('uc-ser')).toBe('4%2C5');
|
||||||
|
|
||||||
|
const { state: other } = useCookie('uc-ser', [0], { serializer });
|
||||||
|
|
||||||
|
expect(other.value).toEqual([4, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Merge defaults ---
|
||||||
|
|
||||||
|
it('merges defaults with the stored value', () => {
|
||||||
|
document.cookie = `uc-merge=${encodeURIComponent(JSON.stringify({ hello: 'stored' }))}; Path=/`;
|
||||||
|
|
||||||
|
const { state } = useCookie('uc-merge', { hello: 'default', greeting: 'hi' }, { mergeDefaults: true });
|
||||||
|
|
||||||
|
expect(state.value).toEqual({ hello: 'stored', greeting: 'hi' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Attributes ---
|
||||||
|
|
||||||
|
it('applies cookie attributes to every write', async () => {
|
||||||
|
const { writes, document: fakeDocument } = createFakeDocument();
|
||||||
|
|
||||||
|
const { state } = useCookie<string>('uc-attrs', 'v', {
|
||||||
|
document: fakeDocument,
|
||||||
|
path: '/app',
|
||||||
|
domain: 'example.com',
|
||||||
|
maxAge: 3600,
|
||||||
|
secure: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
state.value = 'next';
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(writes).toHaveLength(2);
|
||||||
|
for (const write of writes)
|
||||||
|
expect(write).toContain('Path=/app; Domain=example.com; SameSite=Strict; Max-Age=3600; Secure');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('repeats identity attributes when deleting', async () => {
|
||||||
|
const { writes, document: fakeDocument } = createFakeDocument();
|
||||||
|
|
||||||
|
const { state } = useCookie<string | null>('uc-del-attrs', 'v', {
|
||||||
|
document: fakeDocument,
|
||||||
|
path: '/app',
|
||||||
|
});
|
||||||
|
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
state.value = null;
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(writes.at(-1)).toContain('uc-del-attrs=; Path=/app');
|
||||||
|
expect(writes.at(-1)).toContain('Max-Age=0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports invalid attribute combinations through onError', async () => {
|
||||||
|
const onError = vi.fn();
|
||||||
|
|
||||||
|
const { state } = useCookie<string>('uc-invalid', 'v', {
|
||||||
|
sameSite: 'none',
|
||||||
|
secure: false,
|
||||||
|
writeDefaults: false,
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
|
||||||
|
state.value = 'next';
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(onError).toHaveBeenCalledOnce();
|
||||||
|
expect(rawCookie('uc-invalid')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Multi-instance sync (BroadcastChannel ping + local re-read) ---
|
||||||
|
|
||||||
|
it('syncs two instances through BroadcastChannel', async () => {
|
||||||
|
const writer = useCookie<string>('uc-sync', 'initial');
|
||||||
|
const reader = useCookie<string>('uc-sync', 'initial');
|
||||||
|
|
||||||
|
writer.state.value = 'from-writer';
|
||||||
|
await nextTick();
|
||||||
|
// Channel delivery is a task — give it two macrotask turns
|
||||||
|
await flushWrites();
|
||||||
|
await flushWrites();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(reader.state.value).toBe('from-writer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs two instances through the CustomEvent fallback without BroadcastChannel', async () => {
|
||||||
|
vi.stubGlobal('BroadcastChannel', undefined);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const writer = useCookie<string>('uc-sync-ce', 'initial');
|
||||||
|
const reader = useCookie<string>('uc-sync-ce', 'initial');
|
||||||
|
|
||||||
|
writer.state.value = 'from-writer';
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(reader.state.value).toBe('from-writer');
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not echo a write back into its own instance', async () => {
|
||||||
|
const { state } = useCookie<string>('uc-echo', 'initial');
|
||||||
|
|
||||||
|
state.value = 'next';
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(state.value).toBe('next');
|
||||||
|
expect(rawCookie('uc-echo')).toBe('next');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Reactive name ---
|
||||||
|
|
||||||
|
it('re-reads when the reactive name changes', async () => {
|
||||||
|
document.cookie = 'uc-name-a=value-a; Path=/';
|
||||||
|
document.cookie = 'uc-name-b=value-b; Path=/';
|
||||||
|
|
||||||
|
const nameRef = ref('uc-name-a');
|
||||||
|
const { state } = useCookie<string>(nameRef, 'default');
|
||||||
|
|
||||||
|
expect(state.value).toBe('value-a');
|
||||||
|
|
||||||
|
nameRef.value = 'uc-name-b';
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(state.value).toBe('value-b');
|
||||||
|
expect(rawCookie('uc-name-a')).toBe('value-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- eventFilter ---
|
||||||
|
|
||||||
|
it('applies the event filter to writes', async () => {
|
||||||
|
let captured: (() => void) | undefined;
|
||||||
|
|
||||||
|
const { state } = useCookie<string>('uc-filter', 'initial', {
|
||||||
|
eventFilter: (invoke) => { captured = invoke; },
|
||||||
|
writeDefaults: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
state.value = 'filtered';
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(rawCookie('uc-filter')).toBeNull();
|
||||||
|
expect(captured).toBeDefined();
|
||||||
|
|
||||||
|
captured!();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(rawCookie('uc-filter')).toBe('filtered');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Error handling ---
|
||||||
|
|
||||||
|
it('falls back to defaults and reports through onError when deserialization fails', () => {
|
||||||
|
document.cookie = 'uc-bad=not-json; Path=/';
|
||||||
|
const onError = vi.fn();
|
||||||
|
|
||||||
|
const { state } = useCookie('uc-bad', { ok: true }, { onError });
|
||||||
|
|
||||||
|
expect(onError).toHaveBeenCalledOnce();
|
||||||
|
expect(state.value).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cookie Store API', () => {
|
||||||
|
let cookieStore: FakeCookieStore;
|
||||||
|
let storeWindow: Window;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ cookieStore, window: storeWindow } = createCookieStoreWindow());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads asynchronously: defaults until ready, stored value after', async () => {
|
||||||
|
cookieStore.jar.set('cs-read', 'stored');
|
||||||
|
|
||||||
|
const result = useCookie('cs-read', 'default', { window: storeWindow });
|
||||||
|
|
||||||
|
expect(result.state.value).toBe('default');
|
||||||
|
expect(result.isReady.value).toBeFalsy();
|
||||||
|
|
||||||
|
const { state, isReady } = await result;
|
||||||
|
|
||||||
|
expect(state.value).toBe('stored');
|
||||||
|
expect(isReady.value).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes through cookieStore.set with the configured attributes', async () => {
|
||||||
|
const { state } = await useCookie<string>('cs-write', 'initial', {
|
||||||
|
window: storeWindow,
|
||||||
|
path: '/app',
|
||||||
|
sameSite: 'strict',
|
||||||
|
maxAge: 3600,
|
||||||
|
});
|
||||||
|
|
||||||
|
const before = Date.now();
|
||||||
|
|
||||||
|
state.value = 'updated';
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(cookieStore.jar.get('cs-write')).toBe('updated');
|
||||||
|
|
||||||
|
const call = cookieStore.setCalls.at(-1)!;
|
||||||
|
|
||||||
|
expect(call.path).toBe('/app');
|
||||||
|
expect(call.sameSite).toBe('strict');
|
||||||
|
expect(call.expires).toBeGreaterThanOrEqual(before + 3600 * 1000 - 1000);
|
||||||
|
expect(call.expires).toBeLessThanOrEqual(Date.now() + 3600 * 1000 + 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes through cookieStore.delete when state is set to null', async () => {
|
||||||
|
cookieStore.jar.set('cs-del', 'exists');
|
||||||
|
|
||||||
|
const { state } = await useCookie<string | null>('cs-del', 'default', {
|
||||||
|
window: storeWindow,
|
||||||
|
path: '/app',
|
||||||
|
});
|
||||||
|
|
||||||
|
state.value = null;
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(cookieStore.jar.has('cs-del')).toBeFalsy();
|
||||||
|
expect(cookieStore.deleteCalls.at(-1)).toMatchObject({ name: 'cs-del', path: '/app' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists defaults through cookieStore when the cookie is missing', async () => {
|
||||||
|
await useCookie('cs-defaults', 'fallback', { window: storeWindow });
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(cookieStore.jar.get('cs-defaults')).toBe('fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates state on an external change event', async () => {
|
||||||
|
cookieStore.jar.set('cs-ext', 'initial');
|
||||||
|
|
||||||
|
const { state } = await useCookie('cs-ext', 'default', { window: storeWindow });
|
||||||
|
|
||||||
|
cookieStore.jar.set('cs-ext', 'external');
|
||||||
|
cookieStore.fireChange([{ name: 'cs-ext', value: 'external' }], []);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(state.value).toBe('external');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets to defaults on an external delete event', async () => {
|
||||||
|
cookieStore.jar.set('cs-ext-del', 'stored');
|
||||||
|
|
||||||
|
const { state } = await useCookie('cs-ext-del', 'default', { window: storeWindow });
|
||||||
|
|
||||||
|
expect(state.value).toBe('stored');
|
||||||
|
|
||||||
|
cookieStore.jar.delete('cs-ext-del');
|
||||||
|
cookieStore.fireChange([], [{ name: 'cs-ext-del' }]);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(state.value).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores change events for other cookies', async () => {
|
||||||
|
cookieStore.jar.set('cs-other', 'mine');
|
||||||
|
|
||||||
|
const { state } = await useCookie('cs-other', 'default', { window: storeWindow });
|
||||||
|
|
||||||
|
cookieStore.fireChange([{ name: 'unrelated', value: 'x' }], []);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(state.value).toBe('mine');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not bounce its own change-event echo back into the state', async () => {
|
||||||
|
const { state } = await useCookie<string>('cs-echo', 'initial', { window: storeWindow, writeDefaults: false });
|
||||||
|
|
||||||
|
state.value = 'a';
|
||||||
|
await nextTick();
|
||||||
|
state.value = 'b';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// Let queued writes and their macrotask change events all land.
|
||||||
|
await flushWrites();
|
||||||
|
await flushWrites();
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(state.value).toBe('b');
|
||||||
|
expect(cookieStore.jar.get('cs-echo')).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converges two instances writing conflicting values in the same tick', async () => {
|
||||||
|
cookieStore.jar.set('cs-conflict', 'initial');
|
||||||
|
|
||||||
|
const a = await useCookie<string>('cs-conflict', 'initial', { window: storeWindow });
|
||||||
|
const b = await useCookie<string>('cs-conflict', 'initial', { window: storeWindow });
|
||||||
|
|
||||||
|
a.state.value = 'from-a';
|
||||||
|
b.state.value = 'from-b';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// Let queued writes, macrotask change events, and reconciling re-reads settle
|
||||||
|
for (let round = 0; round < 8; round++)
|
||||||
|
await flushWrites();
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
const final = cookieStore.jar.get('cs-conflict');
|
||||||
|
|
||||||
|
expect(a.state.value).toBe(final);
|
||||||
|
expect(b.state.value).toBe(final);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops writing once the owning scope is disposed, even after async init', async () => {
|
||||||
|
const scope = effectScope();
|
||||||
|
|
||||||
|
const result = scope.run(() => useCookie<string>('cs-scope', 'init', { window: storeWindow }))!;
|
||||||
|
const { state } = await result;
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
const writesBefore = cookieStore.setCalls.length;
|
||||||
|
|
||||||
|
scope.stop();
|
||||||
|
|
||||||
|
state.value = 'after-stop';
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(cookieStore.setCalls).toHaveLength(writesBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces the document.cookie path when secure is explicitly false', async () => {
|
||||||
|
const { state } = await useCookie<string>('cs-insecure', 'initial', {
|
||||||
|
window: storeWindow,
|
||||||
|
secure: false,
|
||||||
|
writeDefaults: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
state.value = 'plain';
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(cookieStore.setCalls).toHaveLength(0);
|
||||||
|
expect(rawCookie('cs-insecure')).toBe('plain');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('custom store', () => {
|
||||||
|
/**
|
||||||
|
* In-memory CookieStorageLike — the kind of adapter a framework (e.g.
|
||||||
|
* Nuxt) would provide to bridge a server request context.
|
||||||
|
*/
|
||||||
|
function createMemoryCookieStore() {
|
||||||
|
const jar = new Map<string, string>();
|
||||||
|
const listeners = new Map<string, Set<(value: string | null) => void>>();
|
||||||
|
|
||||||
|
function notify(name: string, value: string | null) {
|
||||||
|
listeners.get(name)?.forEach(callback => callback(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const store: CookieStorageLike = {
|
||||||
|
getItem: name => jar.get(name) ?? null,
|
||||||
|
setItem: (name, value) => {
|
||||||
|
jar.set(name, value);
|
||||||
|
notify(name, value);
|
||||||
|
},
|
||||||
|
removeItem: (name) => {
|
||||||
|
jar.delete(name);
|
||||||
|
notify(name, null);
|
||||||
|
},
|
||||||
|
onChange: (name, callback) => {
|
||||||
|
if (!listeners.has(name))
|
||||||
|
listeners.set(name, new Set());
|
||||||
|
|
||||||
|
listeners.get(name)!.add(callback);
|
||||||
|
|
||||||
|
return () => listeners.get(name)!.delete(callback);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { jar, notify, store };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ssr = { window: null as unknown as Window, document: null as unknown as Document };
|
||||||
|
|
||||||
|
it('works without window and document when a store is provided (server)', async () => {
|
||||||
|
const { jar, store } = createMemoryCookieStore();
|
||||||
|
jar.set('srv', 'from-request');
|
||||||
|
|
||||||
|
const { state, isReady } = useCookie<string>('srv', 'default', { ...ssr, store });
|
||||||
|
|
||||||
|
// A synchronous backend initializes synchronously
|
||||||
|
expect(isReady.value).toBeTruthy();
|
||||||
|
expect(state.value).toBe('from-request');
|
||||||
|
|
||||||
|
state.value = 'updated';
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(jar.get('srv')).toBe('updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the provided store over the environment', async () => {
|
||||||
|
const { jar, store } = createMemoryCookieStore();
|
||||||
|
document.cookie = 'custom-pref=from-document; Path=/';
|
||||||
|
jar.set('custom-pref', 'from-store');
|
||||||
|
|
||||||
|
const { state } = await useCookie('custom-pref', 'default', { store });
|
||||||
|
|
||||||
|
expect(state.value).toBe('from-store');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies external changes from the store subscription without echoing them back', async () => {
|
||||||
|
const { jar, store, notify } = createMemoryCookieStore();
|
||||||
|
const setItem = vi.spyOn(store, 'setItem');
|
||||||
|
|
||||||
|
const { state } = useCookie('sub', 'initial', { ...ssr, store });
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
jar.set('sub', 'external');
|
||||||
|
notify('sub', 'external');
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(state.value).toBe('external');
|
||||||
|
// Only the writeDefaults write — the external change must not bounce back
|
||||||
|
expect(setItem).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converges two instances writing conflicting values in the same tick', async () => {
|
||||||
|
const { jar, store } = createMemoryCookieStore();
|
||||||
|
jar.set('conflict', 'initial');
|
||||||
|
|
||||||
|
const a = useCookie<string>('conflict', 'initial', { ...ssr, store });
|
||||||
|
const b = useCookie<string>('conflict', 'initial', { ...ssr, store });
|
||||||
|
|
||||||
|
a.state.value = 'from-a';
|
||||||
|
b.state.value = 'from-b';
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
await flushWrites();
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
const final = jar.get('conflict');
|
||||||
|
|
||||||
|
expect(a.state.value).toBe(final);
|
||||||
|
expect(b.state.value).toBe(final);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finishes an in-flight write against the old name after a same-tick name switch', async () => {
|
||||||
|
const { jar, store } = createMemoryCookieStore();
|
||||||
|
jar.set('switch-a', 'va0');
|
||||||
|
jar.set('switch-b', 'vb0');
|
||||||
|
|
||||||
|
const nameRef = ref('switch-a');
|
||||||
|
const { state } = useCookie<string>(nameRef, 'default', { ...ssr, store });
|
||||||
|
|
||||||
|
state.value = 'new-a';
|
||||||
|
nameRef.value = 'switch-b';
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
// The write lands on the cookie it was meant for, never on the new one
|
||||||
|
expect(jar.get('switch-a')).toBe('new-a');
|
||||||
|
expect(jar.get('switch-b')).toBe('vb0');
|
||||||
|
expect(state.value).toBe('vb0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resubscribes when the reactive name changes', async () => {
|
||||||
|
const { jar, store, notify } = createMemoryCookieStore();
|
||||||
|
jar.set('name-a', 'value-a');
|
||||||
|
jar.set('name-b', 'value-b');
|
||||||
|
|
||||||
|
const nameRef = ref('name-a');
|
||||||
|
const { state } = useCookie<string>(nameRef, 'default', { ...ssr, store });
|
||||||
|
|
||||||
|
expect(state.value).toBe('value-a');
|
||||||
|
|
||||||
|
nameRef.value = 'name-b';
|
||||||
|
await nextTick();
|
||||||
|
await flushWrites();
|
||||||
|
|
||||||
|
expect(state.value).toBe('value-b');
|
||||||
|
|
||||||
|
// The old subscription is gone, the new one is live
|
||||||
|
jar.set('name-a', 'stale');
|
||||||
|
notify('name-a', 'stale');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(state.value).toBe('value-b');
|
||||||
|
|
||||||
|
jar.set('name-b', 'fresh');
|
||||||
|
notify('name-b', 'fresh');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(state.value).toBe('fresh');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ssr', () => {
|
||||||
|
it('returns an immediately-ready in-memory ref without window and document', async () => {
|
||||||
|
// Explicit `undefined` would fall back to the defaults via destructuring,
|
||||||
|
// so simulate the server with nulls.
|
||||||
|
const ssr = { window: null as unknown as Window, document: null as unknown as Document };
|
||||||
|
|
||||||
|
const { state, isReady } = useCookie<string>('ssr-key', 'default', ssr);
|
||||||
|
|
||||||
|
expect(state.value).toBe('default');
|
||||||
|
expect(isReady.value).toBeTruthy();
|
||||||
|
|
||||||
|
state.value = 'changed';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(state.value).toBe('changed');
|
||||||
|
|
||||||
|
const awaited = await useCookie('ssr-key', 'default', ssr);
|
||||||
|
|
||||||
|
expect(awaited.state.value).toBe('default');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,874 @@
|
|||||||
|
import { computed, nextTick, ref, shallowRef, toValue, watch } from 'vue';
|
||||||
|
import type { MaybeRefOrGetter, Ref, ShallowRef, UnwrapRef } from 'vue';
|
||||||
|
import { SyncMutex, isFunction } from '@robonen/stdlib';
|
||||||
|
import {
|
||||||
|
decodeCookieValue,
|
||||||
|
encodeCookieName,
|
||||||
|
encodeCookieValue,
|
||||||
|
getCookieValue,
|
||||||
|
serializeCookie,
|
||||||
|
} from '@robonen/platform/browsers';
|
||||||
|
import type { CookieAttributes } from '@robonen/platform/browsers';
|
||||||
|
import type { ConfigurableDocument, ConfigurableFlush, ConfigurableWindow } from '@/types';
|
||||||
|
import { defaultDocument, defaultWindow } from '@/types';
|
||||||
|
import type { ConfigurableEventFilter, EventFilter } from '@/utils/filters';
|
||||||
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||||
|
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
|
||||||
|
import { guessSerializer, shallowMerge } from '../useStorage';
|
||||||
|
import type { StorageSerializer } from '../useStorage';
|
||||||
|
|
||||||
|
// CookieAttributes is part of this module's public contract (options extend
|
||||||
|
// it, CookieStorageLike methods receive it) — re-export it so adapter authors
|
||||||
|
// don't need a direct dependency on @robonen/platform.
|
||||||
|
export type { CookieAttributes } from '@robonen/platform/browsers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cookie backend the composable depends on — never on a concrete API.
|
||||||
|
* Names are raw (unencoded); values are already encoded cookie values.
|
||||||
|
* Methods may be sync or async, so an implementation can sit on the Cookie
|
||||||
|
* Store API, `document.cookie`, or a server request context (e.g. a Nuxt
|
||||||
|
* adapter reading request cookies and appending `Set-Cookie` headers).
|
||||||
|
*/
|
||||||
|
export interface CookieStorageLike {
|
||||||
|
/**
|
||||||
|
* Read the raw (encoded) cookie value, `null` when absent.
|
||||||
|
*/
|
||||||
|
getItem: (name: string) => string | null | Promise<string | null>;
|
||||||
|
/**
|
||||||
|
* Write the raw value. Attributes carry the cookie's scope and lifetime.
|
||||||
|
*/
|
||||||
|
setItem: (name: string, value: string, attributes: CookieAttributes) => void | Promise<void>;
|
||||||
|
/**
|
||||||
|
* Delete the cookie. Attributes carry the identity (`path`/`domain`) the
|
||||||
|
* deletion must repeat to match.
|
||||||
|
*/
|
||||||
|
removeItem: (name: string, attributes: CookieAttributes) => void | Promise<void>;
|
||||||
|
/**
|
||||||
|
* Optional: observe changes of the given cookie (other tabs, server
|
||||||
|
* `Set-Cookie` responses, other instances). The callback receives the new
|
||||||
|
* raw value (`null` = deleted). Returns an unsubscribe function.
|
||||||
|
*/
|
||||||
|
onChange?: (name: string, callback: (newValue: string | null) => void) => () => void;
|
||||||
|
/**
|
||||||
|
* Whether `onChange` also reports this context's own writes (e.g. the
|
||||||
|
* Cookie Store API `change` event does, a BroadcastChannel does not). The
|
||||||
|
* composable uses this to know when an own write will come back as a
|
||||||
|
* notification that must be swallowed instead of re-applied.
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
readonly echoesOwnWrites?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const customCookieEventName = 'vuetools-cookie';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detail of the {@link customCookieEventName} CustomEvent the
|
||||||
|
* `document.cookie` adapter dispatches on `window` after every write, for
|
||||||
|
* same-tab sync in environments without the Cookie Store API and without
|
||||||
|
* BroadcastChannel. The same string also names the adapter's BroadcastChannel.
|
||||||
|
*/
|
||||||
|
export interface CookieChangeLike {
|
||||||
|
/**
|
||||||
|
* The raw (unencoded) cookie name.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* The encoded cookie value, `null` for a deletion.
|
||||||
|
*/
|
||||||
|
newValue: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cookieStoreExpires(attributes: CookieAttributes): number | null {
|
||||||
|
if (attributes.maxAge !== undefined)
|
||||||
|
return Date.now() + attributes.maxAge * 1000;
|
||||||
|
if (attributes.expires !== undefined)
|
||||||
|
return attributes.expires instanceof Date ? attributes.expires.getTime() : attributes.expires;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name createCookieStoreAdapter
|
||||||
|
* @category Storage
|
||||||
|
* @description {@link CookieStorageLike} adapter over the
|
||||||
|
* [Cookie Store API](https://developer.mozilla.org/en-US/docs/Web/API/Cookie_Store_API):
|
||||||
|
* async reads/writes plus a `change`-event subscription that observes other
|
||||||
|
* tabs, server `Set-Cookie` responses, and expiry. The API can only write
|
||||||
|
* `Secure` cookies, so `secure: false` is rejected — use
|
||||||
|
* {@link createDocumentCookieAdapter} for that.
|
||||||
|
*
|
||||||
|
* @param {CookieStore} cookieStore The `window.cookieStore` instance to wrap
|
||||||
|
* @returns {CookieStorageLike} The adapter
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const store = createCookieStoreAdapter(window.cookieStore);
|
||||||
|
*
|
||||||
|
* @since 0.0.14
|
||||||
|
*/
|
||||||
|
export function createCookieStoreAdapter(cookieStore: CookieStore): CookieStorageLike {
|
||||||
|
return {
|
||||||
|
// The change event fires for this document's own writes too
|
||||||
|
echoesOwnWrites: true,
|
||||||
|
getItem(name) {
|
||||||
|
return cookieStore.get(encodeCookieName(name)).then(item => item?.value ?? null);
|
||||||
|
},
|
||||||
|
async setItem(name, value, attributes) {
|
||||||
|
if (attributes.secure === false)
|
||||||
|
throw new TypeError('[useCookie] the Cookie Store API can only write Secure cookies — use the document.cookie adapter for secure: false');
|
||||||
|
|
||||||
|
await cookieStore.set({
|
||||||
|
name: encodeCookieName(name),
|
||||||
|
value,
|
||||||
|
expires: cookieStoreExpires(attributes),
|
||||||
|
domain: attributes.domain ?? null,
|
||||||
|
path: attributes.path ?? '/',
|
||||||
|
sameSite: attributes.sameSite ?? 'lax',
|
||||||
|
partitioned: attributes.partitioned ?? false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async removeItem(name, attributes) {
|
||||||
|
await cookieStore.delete({
|
||||||
|
name: encodeCookieName(name),
|
||||||
|
domain: attributes.domain ?? null,
|
||||||
|
path: attributes.path ?? '/',
|
||||||
|
partitioned: attributes.partitioned ?? false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onChange(name, callback) {
|
||||||
|
const encodedName = encodeCookieName(name);
|
||||||
|
|
||||||
|
const handler = (event: Event) => {
|
||||||
|
const { changed, deleted } = event as CookieChangeEvent;
|
||||||
|
|
||||||
|
for (const item of deleted) {
|
||||||
|
if (item.name === encodedName)
|
||||||
|
return callback(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of changed) {
|
||||||
|
if (item.name === encodedName)
|
||||||
|
return callback(item.value ?? '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
cookieStore.addEventListener('change', handler);
|
||||||
|
|
||||||
|
return () => cookieStore.removeEventListener('change', handler);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentCookieAdapterOptions extends ConfigurableWindow {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name createDocumentCookieAdapter
|
||||||
|
* @category Storage
|
||||||
|
* @description {@link CookieStorageLike} adapter over `document.cookie`:
|
||||||
|
* synchronous reads/writes that work in every browser and can express
|
||||||
|
* non-`Secure` cookies. Changes are observed through the Cookie Store API
|
||||||
|
* `change` event when the browser has one (covering other tabs, server
|
||||||
|
* responses, and expiry even though writes stay on `document.cookie`);
|
||||||
|
* otherwise every write pings a BroadcastChannel by cookie name and receivers
|
||||||
|
* re-read their own `document.cookie` (same-tab and cross-tab, ordering-proof
|
||||||
|
* since no value travels), with a same-tab CustomEvent as the last resort.
|
||||||
|
* Without the Cookie Store API, changes made outside the adapter (server
|
||||||
|
* `Set-Cookie`, other libraries) are not observed.
|
||||||
|
*
|
||||||
|
* @param {Document} document The document whose cookies to read/write
|
||||||
|
* @param {DocumentCookieAdapterOptions} [options={}] Options (`window`)
|
||||||
|
* @returns {CookieStorageLike} The adapter
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const store = createDocumentCookieAdapter(document);
|
||||||
|
*
|
||||||
|
* @since 0.0.14
|
||||||
|
*/
|
||||||
|
export function createDocumentCookieAdapter(document: Document, options: DocumentCookieAdapterOptions = {}): CookieStorageLike {
|
||||||
|
const { window = defaultWindow } = options;
|
||||||
|
const cookieStore = window?.cookieStore;
|
||||||
|
const supportsBroadcast = typeof BroadcastChannel !== 'undefined';
|
||||||
|
|
||||||
|
function read(name: string): string | null {
|
||||||
|
return getCookieValue(document.cookie, name, value => value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One lazy channel per adapter, shared by posts and subscriptions — the
|
||||||
|
// posting channel object never receives its own messages, which is exactly
|
||||||
|
// the echo-free behavior the composable expects (echoesOwnWrites: false).
|
||||||
|
let channel: BroadcastChannel | undefined;
|
||||||
|
let subscribers = 0;
|
||||||
|
|
||||||
|
function broadcastChannel(): BroadcastChannel {
|
||||||
|
if (!channel) {
|
||||||
|
channel = new BroadcastChannel(customCookieEventName);
|
||||||
|
// Node's BroadcastChannel holds the event loop open — release it (no-op in browsers)
|
||||||
|
(channel as { unref?: () => void }).unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
return channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(name: string, newValue: string | null) {
|
||||||
|
// The Cookie Store API observes document.cookie writes by itself.
|
||||||
|
if (cookieStore)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (supportsBroadcast) {
|
||||||
|
// Ping by name only — receivers re-read their own document.cookie, so a
|
||||||
|
// late or reordered message can never apply a stale value.
|
||||||
|
broadcastChannel().postMessage(name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window?.dispatchEvent(new CustomEvent<CookieChangeLike>(customCookieEventName, {
|
||||||
|
detail: { name, newValue },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Own document.cookie writes echo back only through paths that observe
|
||||||
|
// the cookie jar itself (Cookie Store API) or the same window (CustomEvent);
|
||||||
|
// BroadcastChannel posts never return to the posting adapter.
|
||||||
|
echoesOwnWrites: !!cookieStore || (!supportsBroadcast && !!window),
|
||||||
|
getItem: read,
|
||||||
|
setItem(name, value, attributes) {
|
||||||
|
document.cookie = serializeCookie(encodeCookieName(name), value, attributes);
|
||||||
|
notify(name, value);
|
||||||
|
},
|
||||||
|
removeItem(name, attributes) {
|
||||||
|
// Deletion must repeat the identity attributes (path/domain) or it
|
||||||
|
// silently misses the cookie it is meant to remove.
|
||||||
|
document.cookie = serializeCookie(encodeCookieName(name), '', { ...attributes, maxAge: 0, expires: 0 });
|
||||||
|
notify(name, null);
|
||||||
|
},
|
||||||
|
onChange(name, callback) {
|
||||||
|
let teardown: () => void;
|
||||||
|
|
||||||
|
// The Cookie Store API observes document.cookie writes too — prefer its
|
||||||
|
// change event, which also covers other tabs and server responses.
|
||||||
|
if (cookieStore) {
|
||||||
|
const encodedName = encodeCookieName(name);
|
||||||
|
|
||||||
|
const handler = (event: Event) => {
|
||||||
|
const { changed, deleted } = event as CookieChangeEvent;
|
||||||
|
|
||||||
|
for (const item of deleted) {
|
||||||
|
if (item.name === encodedName)
|
||||||
|
return callback(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of changed) {
|
||||||
|
if (item.name === encodedName)
|
||||||
|
return callback(item.value ?? '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
cookieStore.addEventListener('change', handler);
|
||||||
|
teardown = () => cookieStore.removeEventListener('change', handler);
|
||||||
|
}
|
||||||
|
else if (supportsBroadcast) {
|
||||||
|
const handler = (event: MessageEvent) => {
|
||||||
|
if (event.data === name)
|
||||||
|
callback(read(name));
|
||||||
|
};
|
||||||
|
|
||||||
|
const bc = broadcastChannel();
|
||||||
|
subscribers++;
|
||||||
|
bc.addEventListener('message', handler);
|
||||||
|
|
||||||
|
teardown = () => {
|
||||||
|
bc.removeEventListener('message', handler);
|
||||||
|
|
||||||
|
// Close the channel with the last subscriber; posts reopen it lazily
|
||||||
|
if (--subscribers === 0 && channel) {
|
||||||
|
channel.close();
|
||||||
|
channel = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const handler = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<CookieChangeLike>).detail;
|
||||||
|
|
||||||
|
if (detail.name === name)
|
||||||
|
callback(detail.newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
window?.addEventListener(customCookieEventName, handler);
|
||||||
|
teardown = () => window?.removeEventListener(customCookieEventName, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => teardown();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseCookieOptions<T, Shallow extends boolean = true>
|
||||||
|
extends CookieAttributes, ConfigurableWindow, ConfigurableDocument, ConfigurableFlush, ConfigurableEventFilter {
|
||||||
|
/**
|
||||||
|
* The cookie backend. Defaults to {@link createCookieStoreAdapter} when the
|
||||||
|
* browser has the Cookie Store API (unless `secure: false`),
|
||||||
|
* {@link createDocumentCookieAdapter} otherwise. Pass a custom
|
||||||
|
* implementation to integrate a framework's cookie context (e.g. Nuxt) so
|
||||||
|
* the composable also works during SSR.
|
||||||
|
*/
|
||||||
|
store?: CookieStorageLike;
|
||||||
|
/**
|
||||||
|
* Use shallowRef instead of ref for the internal state
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
shallow?: Shallow;
|
||||||
|
/**
|
||||||
|
* Watch for deep changes
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
deep?: boolean;
|
||||||
|
/**
|
||||||
|
* Listen to cookie changes through the store's `onChange` subscription
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
listenToStorageChanges?: boolean;
|
||||||
|
/**
|
||||||
|
* Write the default value to the cookie when it does not exist
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
writeDefaults?: boolean;
|
||||||
|
/**
|
||||||
|
* Merge the default value with the stored value
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
mergeDefaults?: boolean | ((stored: T, defaults: T) => T);
|
||||||
|
/**
|
||||||
|
* Custom serializer for reading/writing the cookie value
|
||||||
|
*/
|
||||||
|
serializer?: StorageSerializer<T>;
|
||||||
|
/**
|
||||||
|
* Encodes the serialized string into an RFC 6265-safe cookie value
|
||||||
|
* @default encodeCookieValue
|
||||||
|
*/
|
||||||
|
encode?: (value: string) => string;
|
||||||
|
/**
|
||||||
|
* Decodes a raw cookie value before deserialization
|
||||||
|
* @default decodeCookieValue
|
||||||
|
*/
|
||||||
|
decode?: (value: string) => string;
|
||||||
|
/**
|
||||||
|
* Called once when the initial value has been loaded from the cookie
|
||||||
|
*/
|
||||||
|
onReady?: (value: T) => void;
|
||||||
|
/**
|
||||||
|
* Error handler for read/write failures
|
||||||
|
*/
|
||||||
|
onError?: (error: unknown) => void;
|
||||||
|
/**
|
||||||
|
* Wait for the component to be mounted before reading the cookie
|
||||||
|
*
|
||||||
|
* Useful for SSR hydration to prevent mismatch
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
initOnMounted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseCookieReturnBase<T, Shallow extends boolean> {
|
||||||
|
state: Shallow extends true ? ShallowRef<T> : Ref<UnwrapRef<T>>;
|
||||||
|
isReady: Ref<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseCookieReturn<T, Shallow extends boolean>
|
||||||
|
= & UseCookieReturnBase<T, Shallow>
|
||||||
|
& PromiseLike<UseCookieReturnBase<T, Shallow>>;
|
||||||
|
|
||||||
|
function isThenable(value: unknown): value is PromiseLike<unknown> {
|
||||||
|
return !!value && isFunction((value as { then?: unknown }).then);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name useCookie
|
||||||
|
* @category Storage
|
||||||
|
* @description Reactive cookie binding — creates a ref synced with a cookie
|
||||||
|
* through a pluggable {@link CookieStorageLike} backend. By default that is
|
||||||
|
* the [Cookie Store API](https://developer.mozilla.org/en-US/docs/Web/API/Cookie_Store_API)
|
||||||
|
* when the browser supports it (async, with `change`-event sync across tabs
|
||||||
|
* and server `Set-Cookie` responses) and `document.cookie` otherwise
|
||||||
|
* (synchronous, BroadcastChannel same-tab/cross-tab sync); pass a custom
|
||||||
|
* `store` to run on top of a framework's cookie context (e.g. Nuxt)
|
||||||
|
* including during SSR. Setting the state to `null` deletes the cookie. Cookie
|
||||||
|
* attributes (`path`, `domain`, `maxAge`/`expires`, `secure`, `sameSite`,
|
||||||
|
* `partitioned`) apply to every write; `secure` defaults to the page's
|
||||||
|
* secure-context status, and an explicit `secure: false` selects the
|
||||||
|
* `document.cookie` adapter since the Cookie Store API can only write
|
||||||
|
* `Secure` cookies.
|
||||||
|
*
|
||||||
|
* @param {MaybeRefOrGetter<string>} name The cookie name (can be reactive)
|
||||||
|
* @param {MaybeRefOrGetter<T>} initialValue The initial/default value
|
||||||
|
* @param {UseCookieOptions<T>} [options={}] Options
|
||||||
|
* @returns {UseCookieReturn<T, Shallow>} An object with state ref and isReady flag, also awaitable
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { state: theme } = useCookie('theme', 'system');
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { state: session } = await useCookie('session', '', { maxAge: 3600, sameSite: 'strict' });
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // Nuxt (h3) integration: bridge the SSR request so the same call works on
|
||||||
|
* // the server (reads the request's Cookie header, writes Set-Cookie response
|
||||||
|
* // headers) and falls back to the built-in browser adapters on the client.
|
||||||
|
* // ~/composables/createNuxtCookieAdapter.ts
|
||||||
|
* import { deleteCookie, getRequestHeader, setCookie } from 'h3';
|
||||||
|
* import { getCookieValue } from '@robonen/platform/browsers';
|
||||||
|
* import type { CookieStorageLike } from '@robonen/vue';
|
||||||
|
*
|
||||||
|
* export function createNuxtCookieAdapter(): CookieStorageLike | undefined {
|
||||||
|
* if (import.meta.client)
|
||||||
|
* return undefined; // browser: the default adapters take over
|
||||||
|
*
|
||||||
|
* const event = useRequestEvent()!;
|
||||||
|
* // Set-Cookie does not update the incoming Cookie header, so reads must
|
||||||
|
* // overlay this request's own writes (same trick Nuxt's useCookie uses)
|
||||||
|
* const written = new Map<string, string | null>();
|
||||||
|
*
|
||||||
|
* return {
|
||||||
|
* getItem(name) {
|
||||||
|
* if (written.has(name))
|
||||||
|
* return written.get(name)!;
|
||||||
|
*
|
||||||
|
* // identity decode — the composable owns decoding of raw values
|
||||||
|
* return getCookieValue(getRequestHeader(event, 'cookie') ?? '', name, raw => raw);
|
||||||
|
* },
|
||||||
|
* setItem(name, value, attributes) {
|
||||||
|
* written.set(name, value);
|
||||||
|
* setCookie(event, name, value, {
|
||||||
|
* path: attributes.path,
|
||||||
|
* domain: attributes.domain,
|
||||||
|
* maxAge: attributes.maxAge,
|
||||||
|
* expires: typeof attributes.expires === 'number' ? new Date(attributes.expires) : attributes.expires,
|
||||||
|
* secure: attributes.secure,
|
||||||
|
* sameSite: attributes.sameSite,
|
||||||
|
* partitioned: attributes.partitioned,
|
||||||
|
* encode: raw => raw, // value arrives already encoded
|
||||||
|
* });
|
||||||
|
* },
|
||||||
|
* removeItem(name, attributes) {
|
||||||
|
* written.set(name, null);
|
||||||
|
* // deletion must repeat the identity attributes to match the cookie
|
||||||
|
* deleteCookie(event, name, { path: attributes.path, domain: attributes.domain });
|
||||||
|
* },
|
||||||
|
* // no onChange: a server request has no cookie change events
|
||||||
|
* };
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // anywhere in the app — works during SSR and in the browser
|
||||||
|
* const { state: locale } = useCookie('locale', 'en', { store: createNuxtCookieAdapter() });
|
||||||
|
*
|
||||||
|
* @since 0.0.14
|
||||||
|
*/
|
||||||
|
export function useCookie<T extends string, Shallow extends boolean = true>(name: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseCookieOptions<T, Shallow>): UseCookieReturn<T, Shallow>;
|
||||||
|
export function useCookie<T extends number, Shallow extends boolean = true>(name: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseCookieOptions<T, Shallow>): UseCookieReturn<T, Shallow>;
|
||||||
|
export function useCookie<T extends boolean, Shallow extends boolean = true>(name: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseCookieOptions<T, Shallow>): UseCookieReturn<T, Shallow>;
|
||||||
|
export function useCookie<T, Shallow extends boolean = true>(name: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<T>, options?: UseCookieOptions<T, Shallow>): UseCookieReturn<T, Shallow>;
|
||||||
|
export function useCookie<T = unknown, Shallow extends boolean = true>(name: MaybeRefOrGetter<string>, initialValue: MaybeRefOrGetter<null>, options?: UseCookieOptions<T, Shallow>): UseCookieReturn<T, Shallow>;
|
||||||
|
export function useCookie<T, Shallow extends boolean = true>(
|
||||||
|
name: MaybeRefOrGetter<string>,
|
||||||
|
initialValue: MaybeRefOrGetter<T>,
|
||||||
|
options: UseCookieOptions<T, Shallow> = {},
|
||||||
|
): UseCookieReturn<T, Shallow> {
|
||||||
|
const {
|
||||||
|
path = '/',
|
||||||
|
domain,
|
||||||
|
maxAge,
|
||||||
|
expires,
|
||||||
|
sameSite = 'lax',
|
||||||
|
partitioned = false,
|
||||||
|
shallow = true,
|
||||||
|
deep = true,
|
||||||
|
flush = 'pre',
|
||||||
|
listenToStorageChanges = true,
|
||||||
|
writeDefaults = true,
|
||||||
|
mergeDefaults = false,
|
||||||
|
encode = encodeCookieValue,
|
||||||
|
decode = decodeCookieValue,
|
||||||
|
eventFilter,
|
||||||
|
initOnMounted = false,
|
||||||
|
onReady,
|
||||||
|
onError = console.error, // eslint-disable-line no-console
|
||||||
|
window = defaultWindow,
|
||||||
|
document = defaultDocument,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// The Cookie Store API can only write `Secure` cookies, so when it backs the
|
||||||
|
// default store the default must be true; an explicit `secure: false`
|
||||||
|
// selects the document.cookie adapter instead.
|
||||||
|
const secure = options.secure ?? (window?.cookieStore ? true : window?.isSecureContext ?? false);
|
||||||
|
|
||||||
|
const store = options.store ?? (
|
||||||
|
window?.cookieStore && secure
|
||||||
|
? createCookieStoreAdapter(window.cookieStore)
|
||||||
|
: document
|
||||||
|
? createDocumentCookieAdapter(document, { window })
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const attributes: CookieAttributes = { path, domain, maxAge, expires, secure, sameSite, partitioned };
|
||||||
|
|
||||||
|
const defaults = toValue(initialValue);
|
||||||
|
const serializer = options.serializer ?? guessSerializer(defaults);
|
||||||
|
|
||||||
|
const state = (shallow ? shallowRef : ref)(defaults) as Shallow extends true ? ShallowRef<T> : Ref<UnwrapRef<T>>;
|
||||||
|
const isReady = ref(false);
|
||||||
|
const rawName = computed(() => toValue(name));
|
||||||
|
|
||||||
|
const shell: UseCookieReturnBase<T, Shallow> = {
|
||||||
|
state,
|
||||||
|
isReady,
|
||||||
|
};
|
||||||
|
|
||||||
|
// No backend at all (SSR without a custom store): a plain in-memory ref,
|
||||||
|
// immediately ready.
|
||||||
|
if (!store) {
|
||||||
|
isReady.value = true;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...shell,
|
||||||
|
// eslint-disable-next-line unicorn/no-thenable
|
||||||
|
then(onFulfilled, onRejected) {
|
||||||
|
return Promise.resolve(shell).then(onFulfilled, onRejected);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRaw(value: T): string | null {
|
||||||
|
return value === undefined || value === null ? null : encode(serializer.write(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromRaw(raw: string | null, merge: boolean): T {
|
||||||
|
if (raw === null)
|
||||||
|
return defaults;
|
||||||
|
|
||||||
|
const value = serializer.read(decode(raw));
|
||||||
|
|
||||||
|
if (merge && mergeDefaults) {
|
||||||
|
return isFunction(mergeDefaults)
|
||||||
|
? mergeDefaults(value, defaults)
|
||||||
|
: shallowMerge(value, defaults);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The raw value the cookie is known to hold (last read, observed, or
|
||||||
|
// written) — lets writes skip no-ops without a read-before-write roundtrip.
|
||||||
|
let knownRaw: string | null | undefined;
|
||||||
|
|
||||||
|
// Raw values of own in-flight writes — only for stores whose onChange
|
||||||
|
// reports our own writes back (echoesOwnWrites): during rapid queued writes
|
||||||
|
// a stale own echo must not bounce back into the state and clobber a newer
|
||||||
|
// value. Echo-free stores (BroadcastChannel) never need this.
|
||||||
|
const selfEchoes: Array<string | null> = [];
|
||||||
|
const observesChanges = listenToStorageChanges && !!store.onChange;
|
||||||
|
const tracksEchoes = observesChanges && store.echoesOwnWrites !== false;
|
||||||
|
|
||||||
|
// Bumped on every reactive name switch: in-flight writes finish against
|
||||||
|
// their snapshotted name without touching the new name's bookkeeping.
|
||||||
|
let nameEpoch = 0;
|
||||||
|
|
||||||
|
// The name writes target. Updated ONLY by the name watcher — by watcher
|
||||||
|
// flush time the rawName computed already reflects a same-tick name change,
|
||||||
|
// so a write enqueued in that flush would otherwise land on the new cookie.
|
||||||
|
let currentName = rawName.value;
|
||||||
|
|
||||||
|
// Writes still queued or in flight. Foreign change notifications arriving
|
||||||
|
// while > 0 are ordering-ambiguous and are deferred to a reconciling
|
||||||
|
// re-read once the queue drains.
|
||||||
|
let pendingWrites = 0;
|
||||||
|
let needsReconcile = false;
|
||||||
|
|
||||||
|
// FIFO write queue: keeps rapid writes ordered even when the backend
|
||||||
|
// resolves them out of order, and serializes delete-after-set.
|
||||||
|
let writeQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
function queueWrite(value: T, onlyIfAbsent = false) {
|
||||||
|
// Snapshot the target: a write enqueued before a name switch must land on
|
||||||
|
// the cookie it was meant for, never on the new name.
|
||||||
|
const epoch = nameEpoch;
|
||||||
|
const target = currentName;
|
||||||
|
|
||||||
|
pendingWrites++;
|
||||||
|
|
||||||
|
writeQueue = writeQueue.then(async () => {
|
||||||
|
// A defaults write re-checks at execution time: another instance (or
|
||||||
|
// tab) may have persisted a value since this was enqueued, and writing
|
||||||
|
// the defaults over it would stomp that newer value.
|
||||||
|
if (onlyIfAbsent) {
|
||||||
|
const existing = await store!.getItem(target);
|
||||||
|
|
||||||
|
if (existing !== undefined && existing !== null) {
|
||||||
|
needsReconcile = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = toRaw(value);
|
||||||
|
|
||||||
|
// The no-op skip and all bookkeeping belong to the current name only
|
||||||
|
const current = () => epoch === nameEpoch;
|
||||||
|
|
||||||
|
if (current() && raw === knownRaw)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Push before the write: a synchronous backend notifies during it.
|
||||||
|
const trackEcho = tracksEchoes && current();
|
||||||
|
|
||||||
|
if (trackEcho)
|
||||||
|
selfEchoes.push(raw);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (raw === null)
|
||||||
|
await store!.removeItem(target, attributes);
|
||||||
|
else
|
||||||
|
await store!.setItem(target, raw, attributes);
|
||||||
|
|
||||||
|
if (current())
|
||||||
|
knownRaw = raw;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (trackEcho) {
|
||||||
|
const index = selfEchoes.indexOf(raw);
|
||||||
|
|
||||||
|
if (index !== -1)
|
||||||
|
selfEchoes.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}).catch(onError).then(() => {
|
||||||
|
pendingWrites--;
|
||||||
|
maybeReconcile();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a change deferred by in-flight writes: re-read the source of
|
||||||
|
// truth once instead of trusting possibly-reordered notifications.
|
||||||
|
function maybeReconcile() {
|
||||||
|
if (pendingWrites > 0 || !needsReconcile)
|
||||||
|
return;
|
||||||
|
|
||||||
|
needsReconcile = false;
|
||||||
|
|
||||||
|
const epoch = nameEpoch;
|
||||||
|
const stamp = changeStamp;
|
||||||
|
|
||||||
|
Promise.resolve(store!.getItem(rawName.value)).then((raw) => {
|
||||||
|
// A name switch or a newer external change supersedes this snapshot
|
||||||
|
if (epoch !== nameEpoch || stamp !== changeStamp)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Any echoes still inbound are indistinguishable from the re-read truth
|
||||||
|
selfEchoes.length = 0;
|
||||||
|
applyExternal(raw);
|
||||||
|
}).catch(onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply event filter if provided
|
||||||
|
const writeWithFilter: (value: T) => void = eventFilter
|
||||||
|
? (value: T) => (eventFilter as EventFilter)(() => queueWrite(value))
|
||||||
|
: queueWrite;
|
||||||
|
|
||||||
|
// Write-lock prevents the state watcher from writing back when state is
|
||||||
|
// updated programmatically (initial/external reads, name changes). Released
|
||||||
|
// via nextTick so it persists through the pre-flush watcher cycle.
|
||||||
|
const writeLock = new SyncMutex();
|
||||||
|
|
||||||
|
function lockWritesUntilFlush() {
|
||||||
|
writeLock.lock();
|
||||||
|
nextTick(() => writeLock.unlock());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The defaults never change, so their raw form is computed once and lazily —
|
||||||
|
// external deletes compare against it without re-serializing.
|
||||||
|
let defaultsRawCache: string | null | undefined;
|
||||||
|
|
||||||
|
function defaultsRaw(): string | null {
|
||||||
|
if (defaultsRawCache === undefined)
|
||||||
|
defaultsRawCache = toRaw(defaults);
|
||||||
|
|
||||||
|
return defaultsRawCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bumped when an external change lands in the state — an async snapshot
|
||||||
|
// read (init, name switch, reconcile) that started earlier compares stamps
|
||||||
|
// so it never clobbers the newer value.
|
||||||
|
let changeStamp = 0;
|
||||||
|
|
||||||
|
function applyExternal(raw: string | null) {
|
||||||
|
// The known value re-seen (an echo of an observed write, a redundant
|
||||||
|
// notification) — and while an own write is in flight, knownRaw lags the
|
||||||
|
// state on purpose, so this also drops events that would clobber it.
|
||||||
|
if (raw === knownRaw)
|
||||||
|
return;
|
||||||
|
|
||||||
|
knownRaw = raw;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Compare serialized forms so an external delete while the state already
|
||||||
|
// equals the defaults is a no-op; deserialize only on a real change.
|
||||||
|
const incomingRaw = raw === null ? defaultsRaw() : raw;
|
||||||
|
|
||||||
|
if (incomingRaw === toRaw(state.value as T))
|
||||||
|
return;
|
||||||
|
|
||||||
|
changeStamp++;
|
||||||
|
lockWritesUntilFlush();
|
||||||
|
(state as Ref).value = fromRaw(raw, false);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
onError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstMounted = false;
|
||||||
|
const skipUntilMounted = () => initOnMounted && !firstMounted;
|
||||||
|
|
||||||
|
function handleChange(newValue: string | null) {
|
||||||
|
if (skipUntilMounted())
|
||||||
|
return;
|
||||||
|
|
||||||
|
const echoIndex = selfEchoes.indexOf(newValue);
|
||||||
|
|
||||||
|
if (echoIndex !== -1) {
|
||||||
|
selfEchoes.splice(echoIndex, 1);
|
||||||
|
// A foreign change deferred behind this echo may be ready to resolve now
|
||||||
|
maybeReconcile();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A foreign change interleaved with own in-flight writes (queued, or
|
||||||
|
// committed with echoes still inbound) is ordering-ambiguous: applying it
|
||||||
|
// could revert a newer own value. Defer to one reconciling re-read.
|
||||||
|
if (pendingWrites > 0 || selfEchoes.length > 0) {
|
||||||
|
needsReconcile = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyExternal(newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The change subscription is name-bound, so a reactive name change
|
||||||
|
// resubscribes (see the name watcher in finishInit)
|
||||||
|
let unsubscribe: (() => void) | undefined;
|
||||||
|
|
||||||
|
function subscribe() {
|
||||||
|
unsubscribe?.();
|
||||||
|
unsubscribe = observesChanges ? store!.onChange!(rawName.value, handleChange) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe();
|
||||||
|
|
||||||
|
let stopWatch: (() => void) | undefined;
|
||||||
|
let stopNameWatch: (() => void) | undefined;
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
tryOnScopeDispose(() => {
|
||||||
|
disposed = true;
|
||||||
|
unsubscribe?.();
|
||||||
|
stopWatch?.();
|
||||||
|
stopNameWatch?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyRead(raw: string | null, stamp: number) {
|
||||||
|
// An external change applied while this snapshot read was in flight is
|
||||||
|
// fresher than the snapshot — keep it.
|
||||||
|
if (stamp !== changeStamp)
|
||||||
|
return;
|
||||||
|
|
||||||
|
knownRaw = raw;
|
||||||
|
|
||||||
|
if (raw === null && writeDefaults && defaults !== undefined && defaults !== null)
|
||||||
|
queueWrite(defaults as T, true);
|
||||||
|
|
||||||
|
lockWritesUntilFlush();
|
||||||
|
(state as Ref).value = fromRaw(raw, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishInit() {
|
||||||
|
// The scope died before the async init resolved — leave no watchers behind
|
||||||
|
if (disposed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
isReady.value = true;
|
||||||
|
onReady?.(state.value as T);
|
||||||
|
|
||||||
|
// Set up watchers AFTER initial state is set — avoids write-back on init
|
||||||
|
stopWatch = watch(state, (newValue) => {
|
||||||
|
if (writeLock.isLocked)
|
||||||
|
return;
|
||||||
|
|
||||||
|
writeWithFilter(newValue as T);
|
||||||
|
}, { flush, deep });
|
||||||
|
|
||||||
|
// Watch for reactive name changes
|
||||||
|
stopNameWatch = watch(rawName, () => {
|
||||||
|
nameEpoch++;
|
||||||
|
currentName = rawName.value;
|
||||||
|
selfEchoes.length = 0;
|
||||||
|
needsReconcile = false;
|
||||||
|
subscribe();
|
||||||
|
|
||||||
|
const stamp = changeStamp;
|
||||||
|
|
||||||
|
Promise.resolve(store!.getItem(rawName.value))
|
||||||
|
.then(raw => applyRead(raw, stamp))
|
||||||
|
.catch(onError);
|
||||||
|
}, { flush });
|
||||||
|
}
|
||||||
|
|
||||||
|
function performInit(): Promise<UseCookieReturnBase<T, Shallow>> | UseCookieReturnBase<T, Shallow> {
|
||||||
|
const stamp = changeStamp;
|
||||||
|
const raw = store!.getItem(rawName.value);
|
||||||
|
|
||||||
|
// A synchronous backend (document.cookie, a server request context)
|
||||||
|
// initializes synchronously — state is correct right after the call.
|
||||||
|
if (isThenable(raw)) {
|
||||||
|
return Promise.resolve(raw)
|
||||||
|
.then(resolved => applyRead(resolved, stamp))
|
||||||
|
.catch(onError)
|
||||||
|
.then(() => {
|
||||||
|
finishInit();
|
||||||
|
return shell;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
applyRead(raw, stamp);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
onError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
finishInit();
|
||||||
|
|
||||||
|
return shell;
|
||||||
|
}
|
||||||
|
|
||||||
|
let readyPromise: Promise<UseCookieReturnBase<T, Shallow>>;
|
||||||
|
|
||||||
|
if (initOnMounted) {
|
||||||
|
readyPromise = new Promise<UseCookieReturnBase<T, Shallow>>((resolve) => {
|
||||||
|
tryOnMounted(() => {
|
||||||
|
firstMounted = true;
|
||||||
|
resolve(performInit());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
readyPromise = Promise.resolve(performInit());
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...shell,
|
||||||
|
// eslint-disable-next-line unicorn/no-thenable
|
||||||
|
then(onFulfilled, onRejected) {
|
||||||
|
return readyPromise.then(onFulfilled, onRejected);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -359,6 +359,175 @@ describe(useStorageAsync, () => {
|
|||||||
expect(state.value).toBe('value-b');
|
expect(state.value).toBe('value-b');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Same-tab cross-instance sync (custom backends) ---
|
||||||
|
|
||||||
|
it('syncs two instances sharing the same custom async backend', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
|
||||||
|
const writer = await useStorageAsync<string>('shared', 'initial', storage);
|
||||||
|
const reader = await useStorageAsync<string>('shared', 'initial', storage);
|
||||||
|
|
||||||
|
writer.state.value = 'from-writer';
|
||||||
|
await nextTick();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(reader.state.value).toBe('from-writer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not echo a received event back into storage', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
|
||||||
|
const writer = await useStorageAsync<string>('echo', 'initial', storage);
|
||||||
|
await useStorageAsync<string>('echo', 'initial', storage);
|
||||||
|
|
||||||
|
const setItem = vi.spyOn(storage, 'setItem');
|
||||||
|
|
||||||
|
writer.state.value = 'next';
|
||||||
|
await nextTick();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
await nextTick();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
// Only the writer persists; the reader applies the event without writing back
|
||||||
|
expect(setItem).toHaveBeenCalledTimes(1);
|
||||||
|
expect(storage.store.get('echo')).toBe('next');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets to defaults on a clear event (key: null)', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
storage.store.set('clearable', 'stored');
|
||||||
|
|
||||||
|
const { state } = await useStorageAsync<string>('clearable', 'default', storage);
|
||||||
|
|
||||||
|
expect(state.value).toBe('stored');
|
||||||
|
|
||||||
|
globalThis.dispatchEvent(new CustomEvent('vuetools-storage', {
|
||||||
|
detail: { key: null, oldValue: null, newValue: null, storageArea: storage },
|
||||||
|
}));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(state.value).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- No-op writes ---
|
||||||
|
|
||||||
|
it('skips the write when storage already holds the serialized value', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
storage.store.set('noop', JSON.stringify({ a: 1 }));
|
||||||
|
|
||||||
|
const { state } = await useStorageAsync('noop', { a: 0 }, storage);
|
||||||
|
const setItem = vi.spyOn(storage, 'setItem');
|
||||||
|
|
||||||
|
state.value = { a: 1 };
|
||||||
|
await nextTick();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(setItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Key switch must not copy the previous key's value ---
|
||||||
|
|
||||||
|
it('does not write the old value to the new key on reactive key change', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
storage.store.set('key-a', 'value-a');
|
||||||
|
storage.store.set('key-b', 'value-b');
|
||||||
|
|
||||||
|
const keyRef = ref('key-a');
|
||||||
|
const { state } = await useStorageAsync<string>(keyRef, 'default', storage);
|
||||||
|
const setItem = vi.spyOn(storage, 'setItem');
|
||||||
|
|
||||||
|
keyRef.value = 'key-b';
|
||||||
|
await nextTick();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
await nextTick();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
expect(state.value).toBe('value-b');
|
||||||
|
expect(setItem).not.toHaveBeenCalled();
|
||||||
|
expect(storage.store.get('key-a')).toBe('value-a');
|
||||||
|
expect(storage.store.get('key-b')).toBe('value-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converges two instances writing conflicting values in the same tick', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
storage.store.set('conflict', 'initial');
|
||||||
|
|
||||||
|
const a = await useStorageAsync<string>('conflict', 'initial', storage);
|
||||||
|
const b = await useStorageAsync<string>('conflict', 'initial', storage);
|
||||||
|
|
||||||
|
a.state.value = 'from-a';
|
||||||
|
b.state.value = 'from-b';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// Let queued writes, dispatched events, and reconciling re-reads settle
|
||||||
|
for (let round = 0; round < 6; round++)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
await nextTick();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
const final = storage.store.get('conflict');
|
||||||
|
|
||||||
|
expect(a.state.value).toBe(final);
|
||||||
|
expect(b.state.value).toBe(final);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finishes an in-flight write against the old key after a same-tick key switch', async () => {
|
||||||
|
const storage = createMockAsyncStorage();
|
||||||
|
storage.store.set('key-a', 'va0');
|
||||||
|
storage.store.set('key-b', 'vb0');
|
||||||
|
|
||||||
|
const keyRef = ref('key-a');
|
||||||
|
const { state } = await useStorageAsync<string>(keyRef, 'default', storage);
|
||||||
|
|
||||||
|
state.value = 'new-a';
|
||||||
|
keyRef.value = 'key-b';
|
||||||
|
await nextTick();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
await nextTick();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
// The write lands on the key it was meant for, never on the new one
|
||||||
|
expect(storage.store.get('key-a')).toBe('new-a');
|
||||||
|
expect(storage.store.get('key-b')).toBe('vb0');
|
||||||
|
expect(state.value).toBe('vb0');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Write ordering ---
|
||||||
|
|
||||||
|
it('keeps queued writes ordered when the backend resolves out of order', async () => {
|
||||||
|
// Pre-seed so writeDefaults does not consume the slow first write
|
||||||
|
const store = new Map<string, string>([['ordered', 'initial']]);
|
||||||
|
let delay = 30;
|
||||||
|
|
||||||
|
const storage: StorageLikeAsync = {
|
||||||
|
getItem: async (key: string) => store.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
// First write is slow, subsequent ones fast — without a queue the
|
||||||
|
// fast write would be overwritten by the slow one landing late.
|
||||||
|
const currentDelay = delay;
|
||||||
|
delay = 1;
|
||||||
|
|
||||||
|
return new Promise(resolve => setTimeout(() => {
|
||||||
|
store.set(key, value);
|
||||||
|
resolve();
|
||||||
|
}, currentDelay));
|
||||||
|
},
|
||||||
|
removeItem: async (key: string) => { store.delete(key); },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { state } = await useStorageAsync<string>('ordered', 'initial', storage);
|
||||||
|
|
||||||
|
state.value = 'slow';
|
||||||
|
await nextTick();
|
||||||
|
state.value = 'fast';
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
expect(store.get('ordered')).toBe('fast');
|
||||||
|
});
|
||||||
|
|
||||||
// --- eventFilter ---
|
// --- eventFilter ---
|
||||||
|
|
||||||
it('applies event filter to writes', async () => {
|
it('applies event filter to writes', async () => {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { computed, ref, shallowRef, toValue, watch } from 'vue';
|
import { computed, nextTick, ref, shallowRef, toValue, watch } from 'vue';
|
||||||
import type { MaybeRefOrGetter, Ref, ShallowRef, UnwrapRef } from 'vue';
|
import type { MaybeRefOrGetter, Ref, ShallowRef, UnwrapRef } from 'vue';
|
||||||
import { isFunction } from '@robonen/stdlib';
|
import { SyncMutex, isFunction } from '@robonen/stdlib';
|
||||||
import type { ConfigurableFlush, ConfigurableWindow } from '@/types';
|
import type { ConfigurableFlush, ConfigurableWindow } from '@/types';
|
||||||
import { defaultWindow } from '@/types';
|
import { defaultWindow } from '@/types';
|
||||||
import type { ConfigurableEventFilter, EventFilter } from '@/utils/filters';
|
import type { ConfigurableEventFilter, EventFilter } from '@/utils/filters';
|
||||||
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
import { tryOnScopeDispose } from '@/composables/lifecycle/tryOnScopeDispose';
|
||||||
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
|
import { tryOnMounted } from '@/composables/lifecycle/tryOnMounted';
|
||||||
import { useEventListener } from '@/composables/browser/useEventListener';
|
import { useEventListener } from '@/composables/browser/useEventListener';
|
||||||
import { guessSerializer, shallowMerge } from '../useStorage';
|
import { customStorageEventName, guessSerializer, shallowMerge } from '../useStorage';
|
||||||
import type { StorageEventLike } from '../useStorage';
|
import type { StorageEventLike } from '../useStorage';
|
||||||
|
|
||||||
export interface StorageSerializerAsync<T> {
|
export interface StorageSerializerAsync<T> {
|
||||||
@@ -141,12 +141,11 @@ export function useStorageAsync<T, Shallow extends boolean = true>(
|
|||||||
|
|
||||||
if (rawValue === undefined || rawValue === null) {
|
if (rawValue === undefined || rawValue === null) {
|
||||||
if (writeDefaults && defaults !== undefined && defaults !== null) {
|
if (writeDefaults && defaults !== undefined && defaults !== null) {
|
||||||
try {
|
// Through the FIFO queue so the write is ordered with user writes
|
||||||
await storage.setItem(keyComputed.value, await serializer.write(defaults));
|
// and dispatches a change event like any other write; awaited so
|
||||||
}
|
// the defaults are persisted by the time the composable is ready.
|
||||||
catch (e) {
|
queueWrite(defaults, true);
|
||||||
onError(e);
|
await writeQueue;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return defaults;
|
return defaults;
|
||||||
@@ -168,14 +167,62 @@ export function useStorageAsync<T, Shallow extends boolean = true>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function write(value: T) {
|
// Reentrancy guard: dispatchEvent runs same-tab listeners synchronously, so
|
||||||
|
// while it is on the stack the only incoming event is this instance's own —
|
||||||
|
// which must be ignored. During rapid queued writes the state may already
|
||||||
|
// hold a newer value, and consuming the own (stale) event would clobber it
|
||||||
|
// and ping-pong with the write queue.
|
||||||
|
let dispatchingWriteEvent = false;
|
||||||
|
|
||||||
|
function dispatchWriteEvent(key: string, oldValue: string | null, newValue: string | null) {
|
||||||
|
if (!window)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
key,
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
storageArea: storage as Storage,
|
||||||
|
};
|
||||||
|
|
||||||
|
dispatchingWriteEvent = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Use native StorageEvent for built-in Storage, CustomEvent for custom backends
|
||||||
|
window.dispatchEvent(
|
||||||
|
storage instanceof Storage
|
||||||
|
? new StorageEvent('storage', payload)
|
||||||
|
: new CustomEvent<StorageEventLike>(customStorageEventName, { detail: payload }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
dispatchingWriteEvent = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function write(value: T, key: string, onlyIfAbsent = false) {
|
||||||
|
try {
|
||||||
|
const oldValue = await storage.getItem(key) ?? null;
|
||||||
|
|
||||||
|
// A defaults write re-checks at execution time: another instance may
|
||||||
|
// have persisted a value since this was enqueued, and writing the
|
||||||
|
// defaults over it would stomp that newer value.
|
||||||
|
if (onlyIfAbsent && oldValue !== null) {
|
||||||
|
needsReconcile = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
await storage.removeItem(keyComputed.value);
|
await storage.removeItem(key);
|
||||||
|
dispatchWriteEvent(key, oldValue, null);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const raw = await serializer.write(value);
|
const serialized = await serializer.write(value);
|
||||||
await storage.setItem(keyComputed.value, raw);
|
|
||||||
|
if (oldValue !== serialized) {
|
||||||
|
await storage.setItem(key, serialized);
|
||||||
|
dispatchWriteEvent(key, oldValue, serialized);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
@@ -183,10 +230,122 @@ export function useStorageAsync<T, Shallow extends boolean = true>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bumped on every reactive key switch: in-flight writes finish against
|
||||||
|
// their snapshotted key without touching the new key's state.
|
||||||
|
let keyEpoch = 0;
|
||||||
|
|
||||||
|
// The key writes target. Updated ONLY by the key watcher — by watcher flush
|
||||||
|
// time the keyComputed already reflects a same-tick key change, so a write
|
||||||
|
// enqueued in that flush would otherwise land on the new key.
|
||||||
|
let currentKey = keyComputed.value;
|
||||||
|
|
||||||
|
// Writes still queued or in flight. Foreign events arriving while > 0 are
|
||||||
|
// ordering-ambiguous and deferred to a reconciling re-read after the drain.
|
||||||
|
let pendingWrites = 0;
|
||||||
|
let needsReconcile = false;
|
||||||
|
|
||||||
|
// Bumped when an external event lands in the state — an async snapshot
|
||||||
|
// read (init, key switch, reconcile) that started earlier compares stamps
|
||||||
|
// so it never clobbers the newer value
|
||||||
|
let changeStamp = 0;
|
||||||
|
|
||||||
|
// FIFO write queue: keeps rapid writes ordered when the backend resolves
|
||||||
|
// them out of order, and keeps dispatched event payloads in commit order.
|
||||||
|
let writeQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
function queueWrite(value: T, onlyIfAbsent = false) {
|
||||||
|
// Snapshot the target: a write enqueued before a key switch must land on
|
||||||
|
// the key it was meant for, never on the new one.
|
||||||
|
const target = currentKey;
|
||||||
|
|
||||||
|
pendingWrites++;
|
||||||
|
|
||||||
|
writeQueue = writeQueue
|
||||||
|
.then(() => write(value, target, onlyIfAbsent))
|
||||||
|
.then(() => {
|
||||||
|
pendingWrites--;
|
||||||
|
maybeReconcile();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a change deferred by in-flight writes: re-read the source of
|
||||||
|
// truth once instead of trusting possibly-reordered events.
|
||||||
|
function maybeReconcile() {
|
||||||
|
if (pendingWrites > 0 || !needsReconcile)
|
||||||
|
return;
|
||||||
|
|
||||||
|
needsReconcile = false;
|
||||||
|
|
||||||
|
const epoch = keyEpoch;
|
||||||
|
const stamp = changeStamp;
|
||||||
|
|
||||||
|
read().then((value) => {
|
||||||
|
// A key switch or a newer external event supersedes this snapshot
|
||||||
|
if (epoch !== keyEpoch || stamp !== changeStamp)
|
||||||
|
return;
|
||||||
|
|
||||||
|
lockWritesUntilFlush();
|
||||||
|
(state as Ref).value = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Apply event filter if provided
|
// Apply event filter if provided
|
||||||
const writeWithFilter: (value: T) => void = eventFilter
|
const writeWithFilter: (value: T) => void = eventFilter
|
||||||
? (value: T) => (eventFilter as EventFilter)(() => write(value))
|
? (value: T) => (eventFilter as EventFilter)(() => queueWrite(value))
|
||||||
: (value: T) => { write(value); };
|
: queueWrite;
|
||||||
|
|
||||||
|
// Write-lock prevents the state watcher from writing the just-read value
|
||||||
|
// back to storage when state is updated programmatically (key changes,
|
||||||
|
// cross-instance events). Released via nextTick so it persists through the
|
||||||
|
// pre-flush watcher cycle.
|
||||||
|
const writeLock = new SyncMutex();
|
||||||
|
|
||||||
|
function lockWritesUntilFlush() {
|
||||||
|
writeLock.lock();
|
||||||
|
nextTick(() => writeLock.unlock());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(event: StorageEventLike) {
|
||||||
|
if (dispatchingWriteEvent)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (event.storageArea !== (storage as unknown as StorageEventLike['storageArea']))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (event.key === null) {
|
||||||
|
changeStamp++;
|
||||||
|
lockWritesUntilFlush();
|
||||||
|
(state as Ref).value = defaults;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key !== keyComputed.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// A foreign event interleaved with own in-flight writes is ordering-
|
||||||
|
// ambiguous: applying it could revert a newer own value with no later
|
||||||
|
// event to correct it. Defer to one reconciling re-read after the drain.
|
||||||
|
if (pendingWrites > 0) {
|
||||||
|
needsReconcile = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentSerialized = await serializer.write(state.value as T);
|
||||||
|
|
||||||
|
if (event.newValue === currentSerialized)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const value = await read(event);
|
||||||
|
|
||||||
|
changeStamp++;
|
||||||
|
lockWritesUntilFlush();
|
||||||
|
(state as Ref).value = value;
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
onError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let stopWatch: (() => void) | null = null;
|
let stopWatch: (() => void) | null = null;
|
||||||
let stopKeyWatch: (() => void) | null = null;
|
let stopKeyWatch: (() => void) | null = null;
|
||||||
@@ -196,22 +355,27 @@ export function useStorageAsync<T, Shallow extends boolean = true>(
|
|||||||
stopKeyWatch?.();
|
stopKeyWatch?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Event listeners for cross-tab synchronization
|
// Event listeners for cross-tab (native Storage) and same-tab cross-instance
|
||||||
|
// (custom backends) synchronization
|
||||||
let firstMounted = false;
|
let firstMounted = false;
|
||||||
|
|
||||||
if (window && listenToStorageChanges) {
|
if (window && listenToStorageChanges) {
|
||||||
|
if (storage instanceof Storage) {
|
||||||
useEventListener(window, 'storage', (ev: StorageEvent) => {
|
useEventListener(window, 'storage', (ev: StorageEvent) => {
|
||||||
if (initOnMounted && !firstMounted)
|
if (initOnMounted && !firstMounted)
|
||||||
return;
|
return;
|
||||||
if (ev.key !== keyComputed.value)
|
|
||||||
return;
|
update(ev);
|
||||||
if (ev.storageArea !== storage)
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
useEventListener(window as any, customStorageEventName as any, ((ev: CustomEvent<StorageEventLike>) => {
|
||||||
|
if (initOnMounted && !firstMounted)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
Promise.resolve().then(() => read(ev)).then((value) => {
|
update(ev.detail);
|
||||||
(state as Ref).value = value;
|
}) as any);
|
||||||
});
|
}
|
||||||
}, { passive: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const shell: UseStorageAsyncReturnBase<T, Shallow> = {
|
const shell: UseStorageAsyncReturnBase<T, Shallow> = {
|
||||||
@@ -220,13 +384,23 @@ export function useStorageAsync<T, Shallow extends boolean = true>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
function performInit() {
|
function performInit() {
|
||||||
|
const stamp = changeStamp;
|
||||||
|
|
||||||
return read().then((value) => {
|
return read().then((value) => {
|
||||||
|
// An external event applied while the init read was in flight is
|
||||||
|
// fresher than the snapshot — keep it
|
||||||
|
if (stamp === changeStamp) {
|
||||||
(state as Ref).value = value;
|
(state as Ref).value = value;
|
||||||
|
}
|
||||||
|
|
||||||
isReady.value = true;
|
isReady.value = true;
|
||||||
onReady?.(value);
|
onReady?.(state.value as T);
|
||||||
|
|
||||||
// Set up watcher AFTER initial state is set — avoids write-back on init
|
// Set up watcher AFTER initial state is set — avoids write-back on init
|
||||||
const stop = watch(state, (newValue) => {
|
const stop = watch(state, (newValue) => {
|
||||||
|
if (writeLock.isLocked)
|
||||||
|
return;
|
||||||
|
|
||||||
writeWithFilter(newValue as T);
|
writeWithFilter(newValue as T);
|
||||||
}, { flush, deep });
|
}, { flush, deep });
|
||||||
|
|
||||||
@@ -234,7 +408,17 @@ export function useStorageAsync<T, Shallow extends boolean = true>(
|
|||||||
|
|
||||||
// Watch for key changes
|
// Watch for key changes
|
||||||
stopKeyWatch = watch(keyComputed, () => {
|
stopKeyWatch = watch(keyComputed, () => {
|
||||||
|
keyEpoch++;
|
||||||
|
currentKey = keyComputed.value;
|
||||||
|
needsReconcile = false;
|
||||||
|
|
||||||
|
const stamp = changeStamp;
|
||||||
|
|
||||||
read().then((v) => {
|
read().then((v) => {
|
||||||
|
if (stamp !== changeStamp)
|
||||||
|
return;
|
||||||
|
|
||||||
|
lockWritesUntilFlush();
|
||||||
(state as Ref).value = v;
|
(state as Ref).value = v;
|
||||||
});
|
});
|
||||||
}, { flush });
|
}, { flush });
|
||||||
|
|||||||
@@ -90,6 +90,22 @@ declare global {
|
|||||||
interface Gamepad {
|
interface Gamepad {
|
||||||
readonly hapticActuators?: readonly GamepadHapticActuator[];
|
readonly hapticActuators?: readonly GamepadHapticActuator[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- WebOTP API (https://wicg.github.io/web-otp/) ----
|
||||||
|
|
||||||
|
type OTPTransportType = 'sms';
|
||||||
|
|
||||||
|
interface OTPOptions {
|
||||||
|
transport?: OTPTransportType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CredentialRequestOptions {
|
||||||
|
otp?: OTPOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OTPCredential extends Credential {
|
||||||
|
readonly code: string;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
Reference in New Issue
Block a user