# Lynx — Technical Documentation This reference covers Lynx **1.0.0** (ProcessWire module version **100**). Lynx is an open-source link-in-bio engine for ProcessWire. This document covers the data model, public API, routing, multilingual output, theming, the front-end editor, and the companion Lynx Manager module. For a marketing-level overview see `README.md`; for version history see `CHANGELOG.md`. The two modules: - **Lynx** — the data and rendering engine (custom tables, routing, REST API, server- and client-side rendering, multilingual public output, and the front-end editor). - **Lynx Manager** — the admin Process module: dashboard, global defaults, theme gallery, per-profile click stats, and JSON export/import. Lynx itself has no admin page; all management happens here or in the front-end editor. Its UI follows the ProcessWire AdminThemeUikit "Konkat" design system ([pw-design-system](https://github.com/mxmsmnv/pw-design-system)): native UIkit markup, a `uk-tab` section nav, an inner `pw-wrap` workspace, module heads, action groups, stat values, table panels, empty states, and `--pw-*` theme tokens, with the module-workspace bridge scoped under `.pw-module-workspace`. ### Code layout `Lynx.module.php` is a thin shell: module info, configuration, lifecycle and the routing hooks. The implementation is composed from focused traits under `src/` (all part of the `Lynx` class): | File | Responsibility | |---|---| | `src/LynxSchema.php` | install / uninstall / upgrade | | `src/LynxData.php` | thin data-layer composition facade | | `src/Lynx*Data.php` | profile, link, media, block, sanitization, analytics and cache persistence concerns | | `src/LynxContent.php` | themes, fonts, social presets, block types (memoized) | | `src/LynxTranslations.php` | language + translation helpers | | `src/LynxRouting.php` | public route dispatch, view counting, page cache | | `src/LynxApi.php` | REST API, CSRF, JSON helpers, public projection | | `src/LynxRender.php` | `render()`, blocks, full page, dynamic CSS, Alpine variant | | `src/LynxEditor.php` | front-end editor composition facade | | `src/LynxEditorActions.php` / `src/LynxEditorView.php` | editor routing, persistence, uploads and presentation | | `src/LynxImportExport.php` | JSON export/import | | `src/LynxDemoProfiles.php` | built-in multilingual demo catalogue | `LynxManager.module.php` is the thin Process module composition shell. Its admin workspaces are grouped by responsibility in `src/LynxManagerUi.php`, `src/LynxManagerDashboard.php`, `src/LynxManagerStats.php`, `src/LynxManagerTransfer.php`, `src/LynxManagerThemes.php`, and `src/LynxManagerSettings.php`. Front-end assets are static files under `assets/`: `public.css` (base public styles), `editor.css` + `editor.js` (the front-end editor), and `admin-links.js` (the admin link-row reorder/preset helper). Standalone public profiles inline the small `public.css` payload to remove a render-blocking request; embeds continue loading the reusable static stylesheet. Theme blueprints live under `blueprints/themes/`. Each theme is one JSON file. `blueprints/theme-utilities.json` contains the Tailwind-like utility whitelist used by theme JSON files. ProcessWire language packs live under `languages/`. They use the same CSV import shape as the Cookie module: English source string, translated string, description, source file, and hash. --- ## 1. Data model All data lives in four custom tables. No public profile pages are created in the tree. Link/block/click relations use cascading foreign keys on new installs; upgrades add them when legacy data contains no orphan rows. ### lynx_profiles One row per bio page. | Column | Type | Notes | |---|---|---| | id | INT PK | | | user_id | INT | owner; used for per-user scoping | | slug | VARCHAR(128) UNIQUE | public URL segment | | lang | VARCHAR(16) | base language code | | title | VARCHAR(255) | display name | | bio | TEXT | short description | | avatar | VARCHAR(255) | URL (may point at an uploaded file) | | theme | VARCHAR(64) | theme id (see Theming) | | font | VARCHAR(64) | font id from the curated open-source font list | | accent | VARCHAR(16) | hex color | | seo_title | VARCHAR(255) | falls back to title | | seo_description | VARCHAR(512) | meta + OG description | | og_image | VARCHAR(255) | falls back to avatar | | noindex | TINYINT | adds robots noindex | | custom_css | TEXT | sanitized; gated by permission | | bg_type | VARCHAR(16) | '', color, gradient, image | | bg_value | VARCHAR(512) | matches bg_type | | active | TINYINT | inactive profiles 404 publicly | | views | INT | incremented on each public render | | translations | MEDIUMTEXT | JSON keyed by language code | | created / modified | INT | unix timestamps | ### lynx_links One row per link. Ordered by `sort`. | Column | Type | Notes | |---|---|---| | id | INT PK | | | profile_id | INT | | | label | VARCHAR(255) | | | url | VARCHAR(1024) | | | icon | VARCHAR(64) | Font Awesome 4.7 name | | is_social | TINYINT | flagged as a social link | | start_date / end_date | INT | scheduling window (0 = unbounded) | | sort | INT | display order | | active | TINYINT | | | clicks | INT | running total | | translations | MEDIUMTEXT | JSON keyed by language code | ### lynx_clicks One row per tracked click. Purged on a retention schedule. | Column | Type | Notes | |---|---|---| | id | INT PK | | | link_id | INT | | | ts | INT | unix timestamp | | ref | VARCHAR(255) | HTTP referer (truncated) | ### lynx_blocks One row per portfolio block. `data` is a JSON blob whose shape depends on `type`. | Column | Type | Notes | |---|---|---| | id | INT PK | | | profile_id | INT | | | type | VARCHAR(32) | gallery, project, video, quote | | title | VARCHAR(255) | optional section heading | | translations | MEDIUMTEXT | JSON keyed by language code | | data | MEDIUMTEXT | JSON, see below | | sort | INT | display order | | active | TINYINT | | Block translations may include partial nested `data` values. At render time translated `data` is merged into the base block data, so a translated caption or quote does not need to repeat image URLs, embed URLs, links, or other structural fields. Nested translated values are sanitized by the same type-aware URL and text policy as base block data, then revalidated at the public output boundary. #### Block `data` shapes ```jsonc // gallery { "images": [ { "src": "...", "caption": "...", "link": "..." } ], "columns": 3 } // project { "items": [ { "heading": "...", "body": "...", "image": "...", "link": "...", "linkLabel": "View" } ] } // video { "videos": [ { "url": "https://youtu.be/...", "embed": "https://www.youtube.com/embed/...", "caption": "..." } ] } // quote { "quotes": [ { "text": "...", "author": "...", "role": "..." } ] } ``` `embed` for videos is derived on save by `videoEmbedUrl()` (YouTube and Vimeo are recognized); unrecognized URLs are dropped. --- ## 2. Routing All public routing is handled by a `ProcessPageView::pageNotFound` hook, so the module needs no template pages. The root segment (default `l`) is configurable. | URL | Method | Purpose | |---|---|---| | `/{root}/{slug}` | GET | Rendered public profile page | | `/{root}/{lang}/{slug}` | GET | Rendered public profile page localized to a supported language | | `/{root}/go/{linkId}` | GET | Click-tracking redirect to the link target | | `/{root}/api/profiles` | GET | JSON list of active profiles | | `/{root}/api/profiles/{slug}` | GET | One profile with links + blocks | | `/{root}/api/profiles/{lang}/{slug}` | GET | One localized profile with links + blocks | | `/{root}/api/reorder` | POST | Reorder links (admin + CSRF) | | `/{root}/edit` | GET | Front-end editor dashboard (login required) | | `/{root}/edit/{slug}` | GET | Front-end visual editor (login + ownership) | | `/{root}/edit/{slug}` | POST | Save editor changes (JSON body + CSRF) | | `/{root}/upload` | POST | AJAX image upload (login + CSRF) | `{slug}` is sanitized as a page name. `{lang}` must be listed in `supportedLanguages`; unsupported language segments are treated as normal slugs. Inactive profiles are treated as not found on the public route. The segments `api`, `go`, `edit` and `upload` are reserved sub-routes, so profiles cannot use them as slugs (`reservedSlugs()` enforces this on save). Route shapes are strict: unexpected trailing segments are returned as not found. The public route increments the profile's view counter once per request. Requests carrying `?lynxpreview=1` (used by the editor's live preview) render normally but do not count as views. Profiles can enable several translation languages in the front-end editor. Each enabled language has its own profile, link, and block fields; missing values fall back to the base language. Profiles with more than one language in `availableLanguages()` render a language switcher at the bottom of the standalone page. The base language links to `/{root}/{slug}` and translations link to `/{root}/{lang}/{slug}`. The same URLs are emitted as `rel="alternate"` / `hreflang` metadata in the document head. Profiles with only one available language render no switcher. --- ## 3. REST API `GET /{root}/api/profiles` returns active public projections with runtime/identity fields omitted. Add `?lang=ru` to localize profile fields where translations exist. `GET /{root}/api/profiles/{slug}` returns a single active profile with embedded `links` (active + in-schedule) and `blocks` (active). Use either `?lang=ru` or `/{root}/api/profiles/ru/{slug}` for localized single-profile responses. Responses are JSON with `JSON_UNESCAPED_UNICODE`. `POST /{root}/api/reorder` expects `{ "ids": [3,1,2] }`, requires the `lynx-admin` permission and a valid CSRF token (header `X-XSRF-Token` or a `csrf` body field). It is intended for headless tooling; the editors save order on submit and do not need it. --- ## 4. Rendering Three rendering paths share the same `.lynx-*` markup and CSS: - **`render($slugOrProfile, $options)`** — returns the inner profile HTML (avatar, title, bio, links, portfolio blocks). For embedding in your own templates. Click tracking is applied automatically when enabled. - **`renderPage($profile)`** (internal) — wraps `render()` in a full HTML document with SEO/OG/Twitter meta, the theme, background override, and custom CSS. Used by the public route. - **`renderAlpine($slug, $options)`** — a self-contained Alpine.js widget that fetches the profile from the REST API and renders client-side. It includes the Lynx public stylesheet, Font Awesome 4.7, and Alpine.js by default. Pass `['noCdn' => true]` if Alpine is already on the page, `['noCss' => true]` if your template already loads `assets/public.css`, or `['noFontAwesome' => true]` if icons are already available. Portfolio blocks are rendered by `renderBlock()` into semantic markup (`
`). Galleries use CSS grid, videos use a responsive 16:9 iframe wrapper. ### Single-profile homepage For a one-profile website, render a Lynx profile directly from `site/templates/home.php`: ```php get('Lynx'); echo $lynx->render('max'); ``` Replace `max` with the profile slug. The normal public root can stay enabled for `/l/{slug}` routes, or be treated as an editor/admin preview path while the homepage is the canonical public page. To embed Lynx inside a larger ProcessWire template instead of making the whole homepage the profile, use the Alpine widget: ```php echo $lynx->renderAlpine('max'); ``` --- ## 5. Theming & appearance Each profile selects a theme from `getThemes()`. Built-ins are loaded from `blueprints/themes/*.json` files with `"kind": "builtIn"` and are currently `default`, `dark`, `glass`, `mono`, `pill`. Lynx Manager can append site-specific themes from its `customThemes` JSON setting. Files with `"kind": "customExample"` are examples and are not automatically enabled. Copy one from `blueprints/themes/` into the Lynx Manager `Custom themes JSON` setting, rename the id if needed, and save the module settings. ```json { "studio": { "label": "Studio", "styles": { "tailwind": { "body": "bg-neutral-50 text-neutral-900", ".lynx-link": "bg-white text-neutral-900 border border-neutral-200", ".lynx-project,.lynx-quote": "bg-white text-neutral-900 border border-neutral-200" } } } } ``` Custom theme IDs are sanitized as field names, built-in IDs cannot be overridden, and `styles.tailwind` utilities are compiled from `blueprints/theme-utilities.json`. Raw `styles.css` is still supported and is passed through the same sanitizer used for per-profile custom CSS. When creating a theme: - Use `body` for page-level background and text color. - Use `.lynx-link` for public link buttons. - Use `.lynx-bio` for profile subtitle/bio color. - Use `.lynx-project`, `.lynx-quote`, `.lynx-gallery`, `.lynx-video` for content blocks. - Use CSS variables such as `var(--lynx-accent)` and `var(--lynx-radius-md)` so profile accent and radius settings still work. - Keep CSS flat and static. Lynx strips interactive visual effects, imports, script-like values and unsafe declarations. On top of the selected theme: - **Font** (`font`): a curated open-source font id. Public pages load the selected family from Bunny Fonts and fall back to system UI when empty. - **Background override** (`bg_type` + `bg_value`): solid color, CSS gradient, or image URL. Values are filtered before being injected into CSS. - **Custom CSS** (`custom_css`): appended after the theme. Sanitized on save — `