# @changke/staticnext-lib

A zero-dependency TypeScript library providing browser-side utility services for the
[StaticNext](https://bitbucket.org/changke/staticnext-lib) framework. Includes AJAX,
PubSub, DOM helpers, cookie management, lazy loading, logging, and URL utilities.

- **Zero runtime dependencies**
- **ESM-only** (`"type": "module"`)
- **Targets modern browsers** (ES2024, real DOM APIs)
- **Built with plain `tsc`** (no bundler)

## Installation

```bash
npm install @changke/staticnext-lib
```

Requires Node >= 22 for development tooling. The library itself runs in the browser.

## Quick Start

```typescript
import {dom, pubSub, ajaxP, logger} from '@changke/staticnext-lib';

// DOM class manipulation (chainable)
dom.addClass(document.body, 'loaded').removeClass(document.body, 'loading');

// Pub/Sub events -- declare a typed topic, then subscribe/publish against it
declare module '@changke/staticnext-lib' {
  interface PubSubTopics {
    'user:login': {id: number};
  }
}

const sub = pubSub.subscribe('user:login', data => {
  logger.log('App', `User logged in: ${data.id}`);
});
pubSub.publish('user:login', {id: 42});
sub.remove();

// Fetch JSON
const users = await ajaxP.getJSON<User[]>('/api/users', {page: '1'});
```

## API Reference

All services (except `Module`, `autoInit`, and `getUrlPath`) are exported as
**singleton instances** -- import them directly and call methods on them.

### AjaxP

Promise-based fetch wrapper with JSON support.

```typescript
import {ajaxP} from '@changke/staticnext-lib';

// GET with query params
const data = await ajaxP.getJSON<MyType>('/api/items', {page: '1', limit: '10'});

// POST JSON
const result = await ajaxP.postJSON<InputType, ResponseType>('/api/items', {name: 'foo'});

// PUT JSON
await ajaxP.putJSON('/api/items/1', {name: 'updated'});

// DELETE
await ajaxP.deleteRequest('/api/items/1', {'X-Token': 'abc'});

// URL utilities
ajaxP.toQueryString({a: '1', b: '2'}); // => 'a=1&b=2'
ajaxP.buildFullUrl('/api?foo=bar', {a: '1'}); // => '/api?foo=bar&a=1'
```

**Methods:**

| Method          | Signature                                   | Description                        |
| --------------- | ------------------------------------------- | ---------------------------------- |
| `getJSON`       | `<T>(url, data?, opts?) => Promise<T>`      | GET request, returns parsed JSON   |
| `postJSON`      | `<T, U>(url, data, headers?) => Promise<U>` | POST with JSON body                |
| `putJSON`       | `<T, U>(url, data, headers?) => Promise<U>` | PUT with JSON body                 |
| `deleteRequest` | `<T>(url, headers) => Promise<T>`           | DELETE request                     |
| `toQueryString` | `(obj?) => string`                          | Convert object to URL query string |
| `buildFullUrl`  | `(url, data?) => string`                    | Append query params to a URL       |

Errors throw a `ResponseError` (extends `Error`) with a `.response` property containing the
raw `Response` object:

```typescript
import type {ResponseError} from '@changke/staticnext-lib/dist/services/ajaxp.js';
```

---

### PubSub

Simple publish/subscribe event hub. Topics are **type-safe by default**: both
`subscribe` and `publish` key the payload type through a `PubSubTopics` registry
interface, so any topic you declare is enforced end to end. Undeclared topics
fall back to `unknown` (the legacy behavior), so existing code keeps working
without changes.

#### Declaring typed topics

Augment the `PubSubTopics` interface via `declare module`. Once a topic is
declared, every `subscribe`/`publish`/`subscribeOnce` call on that topic is
checked against the declared payload type.

```typescript
import {pubSub} from '@changke/staticnext-lib';

declare module '@changke/staticnext-lib' {
  interface PubSubTopics {
    'cart:add': {sku: string; qty: number};
    'app:ready': void;
  }
}

// Subscribe (returns handle with .remove()); `data` is typed as {sku, qty}
const sub = pubSub.subscribe('cart:add', data => {
  console.log(data.sku, data.qty);
});

// Publish -- payload shape is checked
pubSub.publish('cart:add', {sku: 'A1', qty: 2});
pubSub.publish('cart:add', {wrong: 1}); // ❌ compile error

// Void topics: omit the payload
pubSub.subscribe('app:ready', () => {
  console.log('ready');
});
pubSub.publish('app:ready');

// Unsubscribe
sub.remove();

// One-time subscription (returns a Subscribed handle so you can cancel early)
const once = pubSub.subscribeOnce('cart:add', data => {
  console.log('first add:', data.sku);
});
once.remove();
```

Undeclared topics are still allowed and behave as before -- the listener
receives `unknown`:

```typescript
pubSub.subscribe('legacy:topic', data => {
  console.log(data); // unknown
});
pubSub.publish('legacy:topic', {anything: true});
```

**Methods:**

| Method          | Signature                                           | Description                                                   |
| --------------- | --------------------------------------------------- | ------------------------------------------------------------- |
| `subscribe`     | `<K extends string>(topic, listener) => Subscribed` | Subscribe to a topic; returns `{remove: () => void}`          |
| `publish`       | `<K extends string>(topic, data?) => void`          | Publish data to all topic listeners                           |
| `subscribeOnce` | `<K extends string>(topic, listener) => Subscribed` | Subscribe for a single event only (returns cancelable handle) |

**Types:**

```typescript
import type {ListenerFn, PubSubTopics, Subscribed} from '@changke/staticnext-lib';

type ListenerFn<T = unknown> = (data: T) => void;
interface Subscribed {
  remove: () => void;
}
// Augment this interface via `declare module` to register typed topics
interface PubSubTopics {
  [topic: string]: unknown;
}
```

---

### DOM

Utility functions for common DOM tasks. Methods that modify elements return `this` for
chaining.

```typescript
import {dom} from '@changke/staticnext-lib';

// Class manipulation (chainable)
dom.addClass(element, 'active').removeClass(element, 'hidden');

// Works with single elements or NodeList/HTMLCollection
dom.addClass(document.querySelectorAll('.item'), 'highlight');

// Check class
dom.hasClass(element, 'active'); // => true

// Remove all children
dom.removeAllChildren(container);

// Create DocumentFragment from HTML string
const fragment = dom.fromHtml('<p>Hello <strong>world</strong></p>');
document.body.appendChild(fragment);

// Iterate over NodeList
dom.forEach(document.querySelectorAll('li'), (el, i) => {
  el.textContent = `Item ${i}`;
});
```

**Methods:**

| Method              | Signature                      | Description                            |
| ------------------- | ------------------------------ | -------------------------------------- |
| `hasClass`          | `(elem, className) => boolean` | Check if element has class (null-safe) |
| `addClass`          | `(elems, className) => this`   | Add class to element(s)                |
| `removeClass`       | `(elems, className) => this`   | Remove class from element(s)           |
| `removeAllChildren` | `(node) => void`               | Remove all child nodes                 |
| `fromHtml`          | `(html) => DocumentFragment`   | Parse HTML string to fragment          |
| `forEach`           | `(elements, callback) => void` | Iterate over ArrayLike\<Element\>      |

---

### Cookies

Full-featured cookie management with unicode support, SameSite, and domain/path control.
Based on Mozilla's cookie framework.

```typescript
import {docCookies} from '@changke/staticnext-lib';

// Set a cookie (expires in 3600 seconds, path /, SameSite=Lax)
docCookies.setItem('token', 'abc123', 3600, '/', undefined, true, 'lax');

// Get
docCookies.getItem('token'); // => 'abc123'

// Check existence
docCookies.hasItem('token'); // => true

// List all cookie names
docCookies.keys(); // => ['token', ...]

// Remove
docCookies.removeItem('token', '/');

// Clear all
docCookies.clear('/');
```

**Methods:**

| Method       | Signature                                                              | Description              |
| ------------ | ---------------------------------------------------------------------- | ------------------------ |
| `getItem`    | `(key) => string \| null`                                              | Get decoded cookie value |
| `setItem`    | `(key, value, expiry?, path?, domain?, secure?, sameSite?) => boolean` | Set a cookie             |
| `removeItem` | `(key, path?, domain?, secure?, sameSite?) => boolean`                 | Remove a cookie          |
| `hasItem`    | `(key) => boolean`                                                     | Check if cookie exists   |
| `keys`       | `() => string[]`                                                       | List all cookie names    |
| `clear`      | `(path?, domain?, secure?, sameSite?) => void`                         | Remove all cookies       |

The `expiry` parameter accepts: a `number` (max-age in seconds, `Infinity` for persistent),
a `string` (expires date string), or a `Date` object.

The `sameSite` parameter accepts: `'lax'`, `'strict'`, `'none'`, `'no_restriction'`,
`true` (= lax), `1` (= lax), or a negative number (= none).

---

### Loader

Lazy loader for CSS and JS files with deduplication.

```typescript
import {loader} from '@changke/staticnext-lib';

// Load CSS (returns the <link> element, or null if already loaded)
const linkEl = loader.loadCSS('/styles/theme.css');

// Load JS with callbacks
loader.loadJS(
  '/scripts/chart.js',
  () => {
    console.log('Chart library loaded');
  },
  err => {
    console.error('Failed to load:', err.message);
  }
);

// Load JS with crossorigin attribute
loader.loadJS('https://cdn.example.com/lib.js', onLoad, onError, true);

// Reset internal tracking (useful for testing)
loader.reset();
```

**Methods:**

| Method    | Signature                                  | Description                       |
| --------- | ------------------------------------------ | --------------------------------- |
| `loadJS`  | `(src, cb?, errCb?, crossorigin?) => void` | Load a JS file via `<script>` tag |
| `loadCSS` | `(href) => HTMLLinkElement \| null`        | Load a CSS file via `<link>` tag  |
| `reset`   | `() => void`                               | Clear all internal tracking state |

Files are deduplicated by URL -- requesting the same file twice will call the callback
immediately (JS) or return null (CSS).

---

### Logger

Console logging wrapper with mute/unmute and formatted output.

```typescript
import {logger} from '@changke/staticnext-lib';

// Formatted log (with colors and timestamp)
logger.log('MyComponent', 'initialized successfully');

// Raw object log
logger.log('MyComponent', {users: 42, active: true}, true);

// Log as error
logger.log('MyComponent', 'something failed', false, console.error);

// Suppress all logging
logger.mute();
logger.log('MyComponent', 'this is silent');

// Re-enable
logger.unmute();
```

**Methods:**

| Method   | Signature                            | Description                           |
| -------- | ------------------------------------ | ------------------------------------- |
| `log`    | `(name, msg, raw?, conLog?) => void` | Log a message with module name prefix |
| `mute`   | `() => void`                         | Suppress all logging output           |
| `unmute` | `() => void`                         | Re-enable logging output              |

When `raw` is `false` (default), output is color-formatted with CSS in the console.
When `raw` is `true`, a plain object `{time, name, msg}` is logged.

---

### Module

Base class for UI modules. Provides PubSub and Logger integration out of the box.
Subclass this to create components that can be auto-initialized on DOM elements.

```typescript
import {Module} from '@changke/staticnext-lib';

declare module '@changke/staticnext-lib' {
  interface PubSubTopics {
    'app:ready': {ts: number};
  }
}

class MyWidget extends Module {
  constructor(root: Element) {
    super(root);
    this.name_ = 'myWidget';
  }

  start(): void {
    this.log('started');
    this.subscribe('app:ready', data => {
      this.log(`app is ready @ ${data.ts}`);
    });
  }

  static override attachTo(root: Element): MyWidget {
    return new MyWidget(root);
  }
}
```

**Properties:**

| Property | Type      | Description                                               |
| -------- | --------- | --------------------------------------------------------- |
| `root_`  | `Element` | The DOM element this module is attached to                |
| `name_`  | `string`  | Module name (default: `'module'`), override in subclasses |

**Methods:**

| Method          | Signature                                          | Description                                                        |
| --------------- | -------------------------------------------------- | ------------------------------------------------------------------ |
| `attachTo`      | `static (root) => Module`                          | Factory method to create and return a new instance                 |
| `start`         | `() => void`                                       | Lifecycle hook, called after initialization (override in subclass) |
| `subscribe`     | `<K extends string>(topic, handler) => Subscribed` | Subscribe to a PubSub topic (typed via `PubSubTopics`)             |
| `subscribeOnce` | `<K extends string>(topic, handler) => Subscribed` | Subscribe to a topic once (typed via `PubSubTopics`)               |
| `publish`       | `<K extends string>(topic, data?) => void`         | Publish to a PubSub topic (typed via `PubSubTopics`)               |
| `log`           | `(msg, raw?, error?) => void`                      | Log a message prefixed with the module name                        |

---

### autoInit

Auto-initializes modules on DOM elements marked with `data-mod-name` attributes.

```html
<!-- HTML markup -->
<div data-mod-name="myWidget">...</div>
<div data-mod-name="carousel" data-mod-nocss>...</div>
<div data-mod-name="skipped" data-mod-bypass>not initialized</div>
```

```typescript
import {autoInit} from '@changke/staticnext-lib';
import type {Attachable} from '@changke/staticnext-lib/dist/services/auto-init.js';

// Register a module constructor
autoInit.register('myWidget', MyWidget as unknown as Attachable);

// Initialize all modules in the document
autoInit(document);

// Or within a specific container
autoInit(document.getElementById('app')!);

// Deregister
autoInit.deregister('myWidget');
autoInit.deregisterAll();
```

**Data attributes:**

| Attribute         | Description                            |
| ----------------- | -------------------------------------- |
| `data-mod-name`   | Module name to initialize (required)   |
| `data-mod-bypass` | Skip initialization for this element   |
| `data-mod-nojs`   | Skip JS loading (CSS only)             |
| `data-mod-nocss`  | Skip CSS loading (JS only)             |
| `data-inited`     | Set automatically after initialization |

**Function signature:**

```typescript
function autoInit(root?: Document | Element, warn?, loader?, assetPath?): void;
```

When `loader` and `assetPath` are provided, CSS and JS files are loaded from a
conventional path: `${assetPath.modules}/mod-${name}/${name}.{css,js}`.

**Static methods:**

| Method          | Signature                     | Description                        |
| --------------- | ----------------------------- | ---------------------------------- |
| `register`      | `(name, Ctor, warn?) => void` | Register a module constructor      |
| `deregister`    | `(name) => void`              | Remove a registered constructor    |
| `deregisterAll` | `() => void`                  | Remove all registered constructors |

---

### getUrlPath

Extracts the base URL path from a `<script>` element in the document.

```typescript
import {getUrlPath} from '@changke/staticnext-lib';

// Given: <script src="https://cdn.example.com/assets/js/entry.js"></script>
getUrlPath('entry.js');
// => 'https://cdn.example.com/assets/js'

// Returns '/assets/js' if the script element is not found
```

## Development

```bash
# Install dependencies
npm install

# Lint
npm run lint

# Run all tests (headless Chromium via Puppeteer)
npm test

# Run a single test file
npx wtr ./test/services/pubsub.test.ts --node-resolve --puppeteer

# Build (TypeScript to ESM JavaScript + declarations)
npm run build

# Full pre-publish check (lint + test + build)
npm run prepublishOnly
```

## License

ISC
