Features#
This is the canonical catalog of Lesstruct’s product features. The homepage
and README.md curate subsets from this page; when feature wording changes here,
update those surfaces too. Each feature links to the deeper reference where one
exists (configuration.md, plugin-development.md, api-reference.md, etc.).
Screenshots are captured from a running demo by
make screenshotsand served in both light and dark themes. A referenced-but-missing screenshot fails the docs build on purpose, so the catalog cannot ship a broken image by accident.
Deploy & run#
- One binary, no Docker required. A single static Go binary (
CGO_ENABLED=0) runs the whole CMS. SQLite is built in. No runtime, no container, nonode_modulesin production. Containerizing is fully supported if you prefer it —FROM scratchworks. - Static site generation (SSG). Generate a fully static HTML site (tar.gz) with
AMP variants for content pages, listing pages, author pages, tag pages, a
sitemap, robots.txt, a
404.htmlnot-found page, and an RSS 2.0 feed of recent posts (index.xml) — all rendered from the same data layer as the live site. The archive bundles the active theme’sstatic/directory overlaid on only the built-in assets your rendered pages actually reference (plus the two core stylesheets), so exports stay lean and render identically offline. Legacy URL aliases ship as self-contained meta-refresh redirect pages — their targets, like sitemap entries, AMP canonical links, and RSS feed item links, all use the trailing-slash directory form (/<slug>/) matching the<slug>/index.htmlexport layout so they address the page rather than a flat alias stub; hosts should resolve directory indexes before flat.htmlfiles — and the theme’sroot/files (e.g.webpushr-sw.js) land at the archive root. Download from the admin panel under Export, via the agent API, or vialesstruct-cli ssg(use--extract-dirto unpack the files straight into a deploy directory instead of keeping the archive). - Multi-database. Embedded SQLite is the default; PostgreSQL and MySQL are
first-class via
DB_DRIVER. Schema migrations run automatically on first start, per driver. - Two-layer configuration.
config.tomlholds your content schema (languages, post types, fields, thumbnails);.envholds deployment state (host, port, DB, secrets, SMTP, AI keys). OnlyJWT_SECRETis required; everything else has a sensible default. See configuration.md. - Plugin hot-reload. In
DEV_MODE, a file watcher reloads.wasmplugins without a server restart. - Headless mode.
[headless] enabled = trueinconfig.tomlturns the instance into a pure headless CMS: the server-rendered content site (catch-all and/static/*) is not served, only the admin panel and the REST API. The sitemap and robots.txt follow suit. See configuration.md. - Comments can be switched off entirely.
[comments] enabled = falsehard-disables the comment system end to end — no comment routes, no comment UI,allowCommentsforced off on every item, and self-registration blocked unless a[registration]block re-enables it. With a[registration]block, registration is decoupled from the comment system entirely:enabled,default_role, andadmin_approvalare configurable, so a site can let the public register into any role. See configuration.md.
Content & authoring#

Custom post types, built in. Define post types in
config.toml— no plugin, no library. The admin list, form, storage, and queries all read from that file. Built-in slugs (post,page,media,comment) extend instead of collide.Admin content list with lazy loading. The admin list pages through every item server-side (
limit/offset+ a total count) instead of capping at the first page, so sites with thousands of articles stay fully browsable. Scrolling loads the next page automatically, and a total-count badge on the page header shows how many items the current filter matches.Custom fields, built in. Add typed fields to any post type (and to user profiles) in
config.toml. The admin form renders them, the service validates them, and they are queryable. No code required.TipTap rich-text editor. Tables, math (KaTeX), syntax-highlighted code blocks, emoji, YouTube embeds, links, images, and text alignment — all first-class. External links (
http://,https://,//) always render exactlyrel="noopener noreferrer" target="_blank"on the published page — nevernofollow— while internal and relative links never carrytargetorrel; pasting a link leaves the next typed character as plain text (the link does not swallow following text).Draft and publish. A two-state workflow (
draft,published) with publish/unpublish actions exposed in the admin and the CLI.Soft-delete and restore. Deleted content is recoverable from the admin trash view.
Per-content SEO. Meta description, OpenGraph title and description, and a live preview — collapsible inside the editor. SEO metadata generation is format-aware: TipTap content uses JSON extraction, HTML content uses tag-stripping — so HTML-format imports (e.g. WordPress Elementor pages) get real extracted text in their meta descriptions, not empty strings. Image extraction accepts both absolute and root-relative (
/uploads/…) image URLs; derived descriptions collapse newlines/whitespace; an unset og:description mirrors the resolved meta description (Hugo parity); and a post without any image emits noog:imageat all instead of a bare domain, letting the theme’s default image apply.Immutable URL slugs. The slug is the public URL (
/<slug>). Slugs may contain lowercase letters, digits, hyphens, and dots (no leading/trailing dot, no..);jquery.semantic-tabsis a valid slug, with legacyjquery.semantic-tabs.htmlserved via the alias redirect. Any authenticated user can set a custom slug when creating content (in the editor or via--slugonlesstruct-cli content create); otherwise the slug is auto-generated from the title. The slug stays editable while the content is a draft (manually, via title auto-suggest, or via Enhance with AI). Once published, the slug is locked for everyone — editing the title never regenerates it, so published URLs stay stable for SEO and inbound links. Uniqueness is enforced per language; a collision returns a clear error rather than silently suffixing.HTML/CSS content authoring. Set
format: htmlon create to author raw HTML directly — stored and served as-is, no TipTap conversion. The admin editor provides a CodeMirror 6 editor with syntax highlighting and a live preview (sandboxed iframe). HTML content is sanitized on write (dangerous elements/attributes stripped via bluemonday; inlinestyleand class attributes preserved) and on read (rendered through the same policy). Root-relative URLs (/uploads/media/..., same-site links) survive the sanitizer, and<iframe>embeds are kept when their host is allowed by the CSPframe-srcdirective (defaults + appends,*.hostwildcards cover subdomains only — YouTube works out of the box). Ideal for WordPress Elementor imports and hand-authored HTML pages.Markdown as first-class ingest. The CLI and
/api/v1accept Markdown bodies; the server converts them to canonical TipTap JSON. Raw Markdown is never persisted.WordPress importer (async). Upload a WordPress WXR export to migrate posts, pages, custom post types (and their custom fields), media, and authors into Lesstruct. The import runs in a background goroutine and returns
202 Acceptedimmediately with ajobId; pollGET /api/admin/wordpress/import/status/{jobId}to track progress. Authors are auto-created as Contributor users and their posts are assigned to them. Custom post types and field schemas are read fromconfig.toml; items whose post type is not registered are silently skipped. Featured images (_thumbnail_id) are resolved from attachment items, downloaded, transcoded to WebP, and prepended to each post’s content body — unless the body already shows the same picture among its first images (same source URL, or a perceptually identical image under a different URL), in which case the prepend is skipped so the cover is never duplicated; perceptual skips are reported aswarning:entries. Inline body images are likewise downloaded and remapped (downloading is concurrent with a bounded worker pool; transient errors are retried with backoff). Failed downloads fall back to hotlinking the original WordPress URL. Elementor-built pages are imported asformat=htmlusing the rendered HTML from the Elementor cache, preserving their original layout.- Skip-media option. When importing, you can optionally skip downloading
media (inline images and featured images). Content imports with original
WordPress URLs hotlinked — useful for fast imports when you already have
the media or want to defer downloading. Available in the admin UI
(checkbox) and the CLI (
--skip-mediaflag). - CLI import. The
lesstruct-cli import wordpress --file <path> [--skip-media]command imports the same WXR file via the/api/v1agent-realm endpoint using an API key. It uploads the file, then polls the status every few seconds, printing live progress until the job completes.
- Skip-media option. When importing, you can optionally skip downloading
media (inline images and featured images). Content imports with original
WordPress URLs hotlinked — useful for fast imports when you already have
the media or want to defer downloading. Available in the admin UI
(checkbox) and the CLI (
Hugo importer. Import a Hugo site to migrate posts (HTML or Markdown with YAML frontmatter) into Lesstruct. The import runs asynchronously as a background job (202 Accepted + job ID) with a status endpoint for progress tracking — identical to the WordPress importer. Hugo
{{< highlight >}}shortcodes (including whitespace-tolerant closing tags) are converted to server-rendered, syntax-highlighted code blocks via chroma — class-based HTML honoringlinenos=table|inlineand escaping code bodies, so themes can style them with their existing.chromaCSS; unknown languages fall back to plain escaped code blocks.{{< iframe >}}shortcodes become<iframe>elements. YAML frontmatter fields (title,date,tags,description,url,aliases,draft,language,hasMath, etc.) are mapped to their Lesstruct equivalents — includingdate, which becomes the content’s publish date instead of the import time. Old.htmlURLs are stored as aliases in thecontent_aliasestable so the public content site can issue 301 redirects from the old path to the new clean slug. Bilingual en/id pairs are linked as translations when they share a directory.- Media migration. Images referenced by the content (local
static/files and remotehttps://URLs) are downloaded and re-uploaded as Lesstruct media (WebP passthrough for already-WebP input — no generation loss — transcode for other formats, SHA-256 dedup); body<img src>paths are rewritten to the new media URLs and the first frontmatterimages:entry is prepended as a featured image — unless the body’s first images already show that picture, in which case the prepend is skipped so the cover is never duplicated. The duplicate check compares the body’s first three image URLs exactly and their perceptual hashes (8×8 average hash), catching covers that reappear under a different URL — a re-upload, resized export, or hotlink variant that SHA-256 dedup cannot see. Perceptual skips are reported aswarning:entries in the import result. Use the skip-media option to import text only with images hotlinked — available in the admin UI (checkbox) and the CLI (--skip-mediaflag). - Static references survive. References that resolve to files under the
Hugo
static/dir — links, iframe demos, stylesheets, and images that could not be migrated — are rewritten to/static/<path>, the documented convention (operators mirror their Hugostatic/into the theme’sstatic/). Failed media migrations and references left unresolved (dead links, missing images) are surfaced aswarning:entries in the import job’serrorslist — each exactly once — instead of failing silently; content permalinks and aliases stay silent. - Idempotent re-runs. Items whose slug already exists are skipped as “already imported”, so re-running after a partial failure is safe.
- Legacy aliases survive delete + re-import. Deleting a post removes its
alias rows (no more dangling
content_aliases), and a re-import automatically re-points an existing alias whose target no longer exists onto the freshly imported item — the documented delete-and-reimport remediation no longer breaks legacy.htmlredirects. Archives built with defaulttar -czf site.tar.gz .flags (including the leading./entry) are accepted. - Admin UI. Upload a
.tar.gzarchive (containing at least acontent/directory) under Import → Hugo; the UI polls the job status and shows a progress bar plus per-item issues. The site’sconfig.toml/hugo.tomlis read forbaseURLanddefaultContentLanguage. - CLI import.
lesstruct-cli import hugo --source <path> [--skip-media]accepts either a Hugo project directory (auto-archived: onlycontent/andstatic/are sent) or an existing.tar.gzarchive, and uploads it via the/api/v1/hugo/importagent-realm endpoint using an API key, then polls the job status until it completes. - Known limitations. Hugo sections are imported as
posttype (custom sections are not yet mapped to post types, and_index.mdsection landing pages are not skipped); directory-based i18n (content/en/+content/id/) is not yet detected — only the.id.htmlfilename suffix andlanguage:frontmatter are recognized; Disqus comments are not migrated. See project-context.md for the architecture and api-reference.md for the endpoint.
- Media migration. Images referenced by the content (local
Content export. Download all content as a Hugo-compatible
tar.gzarchive (lesstruct-export-<timestamp>.tar.gz). Each content item becomes a<postType>/<slug>.<language>.htmlfile with YAML frontmatter (title,date,description,tags,url,language,aliases,draft,lastmod, custom fields). TipTip body content is rendered to HTML before export. Referenced media files are bundled understatic/uploads/media/with the HTMLsrcattributes unchanged (Hugo servesstatic/at site root, matching Lesstruct’s own/uploads/media/path). Available from the admin panel under Export, via the admin API (/api/admin/export), and via CLI (lesstruct-cli export --output-dir <dir>).
Media & images#

- Media library. Browse, search, and date-filter uploads from the admin panel.
- Automatic WebP conversion. Every uploaded image is served as WebP: already-WebP uploads pass through without re-encoding (no generation loss, and their EXIF/XMP/ICC metadata chunks are stripped — the same privacy behavior the transcode path always had), every other format is transcoded to WebP (quality 80) on upload, so images never weigh down your content. Extended VP8X/alpha WebP files are fully supported; animated WebP is rejected with a clear error.
- Configurable thumbnail variants. Defaults ship
_thumb(370px),_medium(800px),_large(1600px); all editable inconfig.toml. Post bodies emit a responsivesrcsetfrom the variants plus the original at its intrinsic width when it is larger than the largest variant — so wide/high-DPI viewports get the full-resolution image (matching the editor), while small screens get appropriately sized variants. Post cards also expose anImageVariantsmap (keyed by configured suffix) and anOriginalURLfield for the unscaled original, enabling hero backgrounds and high-DPI layouts. - SHA-256 dedup. Identical uploads are detected and rejected (with a force-upload escape hatch).
- AI image generation. Generate images from the media library and the content editor via Google Imagen, Gemini, or GPT-Image. Bring your own key. Gemini and GPT-Image models additionally accept up to 3 reference images (uploads or media-library picks) to guide style, subject, or composition. An “Open Graph image” mode generates an exact 1200x630 social preview (center-cropped) and, from the editor, inserts it at the top of the content — og:image is always the content’s first image.
Internationalization#
- Multilingual by default. Declare your languages in
config.toml(e.g.languages = ["en", "id"]). Content carries aLanguageand authors link translations into translation groups. - Listing fallback. Public listings (homepage, sections, post-type, tag and author pages) show each translation group once, preferring your configured language order — a post without a primary-language version still appears via its translation instead of disappearing.
- Translation-aware SEO. The sitemap declares
hreflangalternates from translation groups. - Localized date formatting. Post dates follow each content item’s
Languagefield: Indonesian content renders1 Januari 2026, English content rendersJanuary 2, 2006. Custom-field dates localize automatically. - AI translation. Translate content between your configured languages from the editor — translation also covers the title and SEO meta description (mirrored to the OG title).
AI#
- Opt-in, bring-your-own-key. Text via any OpenAI-compatible endpoint
(
AI_TEXT_GENERATION_BASE_URL); images via Google or OpenAI. Nothing runs without your keys;/api/healthhonestly reports which features are enabled. - Text enhancement and translation. Refine rich-text (TipTap) post bodies from the editor — enhancement also rewrites the title and SEO meta description (mirrored to the OG title) — or translate between languages.
- AI-powered HTML/CSS authoring. Describe what you want in plain language — the AI generates production-ready HTML & CSS with semantic markup, responsive layouts, and accessible design. Output is on-brand by default: the AI reuses your active theme’s design tokens (
var(--color-primary), spacing, radius, fonts) and component classes rather than inventing arbitrary colors and fonts. Includes 9 quick-start presets (hero, pricing, testimonials, features, CTA, FAQ, stats, contact, newsletter) and iterative refinement — the AI can modify existing HTML based on your follow-up instructions. Your media library images are automatically surfaced as context. This replaces the need for a drag-and-drop page builder: describe, generate, refine, ship. - Image generation. Generate images from the media library and the editor. Gemini and GPT-Image models accept optional reference images (uploaded or picked from the library) to guide the result. Open Graph mode produces an exact 1200x630 preview, auto-inserted at the top of the content it was generated from.
- Built for agents.
lesstruct-cliis a thin Cobra client over/api/v1designed for AI agents and terminal-first humans. Markdown ingest, cursor pagination, and a standard response envelope make it easy to script. - Agent skills. Lesstruct ships installable skills for theme and plugin
development that work from your installed site (no source tree needed).
Install them with
npx skills add aristorinjuang/lesstruct— works with Claude Code, OpenCode, Cursor, Codex, and 25+ other agents. - Crawlable docs. This site publishes
/llms.txt(page index),/llms-full.txt(every page concatenated), and a per-page Markdown mirror for retrieval pipelines.
Themes & rendering#
- Server-rendered by default. The content site is rendered server-side with
Go
html/template— fast and SEO-friendly. - One default theme. Lesstruct ships a single embedded default theme that is the working starting point; it does not generate a new theme per release cycle.
- Customizable. Point
THEME_DIRat athemes/<name>/directory to override CSS, JS, and HTML templates. The contract (CSS variables, layout blocks, JS DOM ids, CDN assets) is documented so fork-and-modify is safe. See theme-development.md. - Root-level theme files. A theme’s optional
root/directory is served at the site root (and copied to the static-export archive root) — so fixed-URL files like/webpushr-sw.jsor.well-known/verifications ship with the theme instead of needing host-level copy hacks. Existing paths always win: aroot/file never shadows content pages or aliases. - Per-post-type templates. Each post type gets its own content template
(e.g.
page.html,event.html), falling back topost.html, then the embedded default. Theme authors can ship apage.htmlwithout blog chrome (related posts, author box, date metadata) while keeping the full layout for blog posts — no config changes needed. - Per-slug template overrides. A theme can ship a template that applies
to one specific content row by naming the file
<postType>-<slug>.html(e.g.page-about.html,article-spotlight.html). Mirrors the WordPresspage-{slug}.phpconvention but generalizes to every post type. Pure additive fallback — no existing theme breaks. See theme-development.md. - Per-post scripts. Declare a
post_scriptcustom field (textarea) on a post type inconfig.tomlto opt in; its raw HTML is emitted verbatim at the end of the post via{{.PostScripts}}(excluded from the visible custom-fields section and stripped from AMP). Only declare it on types whose editors are fully trusted (like Ghost’s per-post code injection), and ensure your CSP allows what you emit — externalsrcis'self'-clean, inline needs'unsafe-inline'. - Multi-type aware. Every post card and single-page template receives the
item’s
.PostType, so a theme can branch layouts for articles, events, and any custom post type from one template set. Cards also carry the post’s raw.CustomFields(e.g.{{index .CustomFields "link"}}), so list templates can branch links or badges on field presence server-side — no client-side JS. - Magazine homepages. Optional
[[homepage_section]]blocks inconfig.tomlrender per-post-type groupings (latest articles, upcoming events, …) alongside the latest-posts list — backward compatible (omitted = flat list). Each section supportsoffsetfor non-overlapping content (e.g. “Featured” items 1–6, “Recommendations” items 7–26). The homepage renders via a dedicatedhomepage.htmltemplate (distinct fromindex.htmlused by listing pages). - Content archive API.
GET /api/v1/public/archivereturns year/month counts for building archive widgets; listing pages accept?year=/?month=for date filtering. - Site identity from config. An optional
[site_config]block inconfig.tomlsets the sitenameand an optionallogo, which drive the browser-tab title suffix,og:site_name, the default logo text, and the footer. This is the one branding surface a theme override cannot reach (the name is baked into handler-sidePageTitlestrings); everything else (social links, analytics, custom<head>, image/multi-logo layouts) stays aTHEME_DIRtheme concern. - Paginated listings. Homepage, author, tag, and post-type listing pages
accept
?page=Nand expose prev/next state to templates. Page size is tuned viaPOSTS_PER_PAGE. - SEO built in.
sitemap.xml,robots.txt, JSON sitemap, andhreflangare generated for you.
Extensibility#
- WebAssembly plugins. Drop a compiled
.wasmintoplugins/and it hooks into the content lifecycle. Any language that compiles to Wasm works. - Familiar hook model. Explicit registration, priority-based execution,
immutable data flow. Invoked hooks:
before_save(create, update, and admin system-fields updates),after_create,after_publish,before_delete,after_unpublish; reserved for forward compatibility:on_plugin_loaded. - Host functions. Plugins call into the host for HTTP (
http_get,http_post), the database (db_query,db_exec), and logging (log_info,log_error). - Sandboxed. Each plugin declares a capability manifest (memory ceiling, allowed HTTP URL patterns, DB permissions) and runs under a per-call timeout.
- See plugin-development.md and plugin-capabilities.md.
API & automation#

- Versioned REST API.
/api/v1covers Content, Media, and Comments. See api-reference.md. - Standard response envelope.
{"data": ..., "error": {...}, "meta": {...}}, with bare-array lists and cursor pagination on list endpoints. - API keys. Personal
lesstruct_<keyID>_<secret>Bearer tokens, scoped to the creating user, with revoke and expiry. Created from the admin profile. lesstruct-cli. A Cobra client for the same API —content,media,comment, andconfigsubcommands;--output text|json; auth via--api-key, env, or config file.- Public content & author APIs. Unauthenticated
/api/v1/public/*endpoints serve rendered content, search, post types, and a published-authors listing (/v1/public/authors) for author directories and “most active contributors” widgets — only safe fields, never email/role/custom-fields. - Public custom-field filter & sort.
/api/v1/public/content_itemsand/api/v1/public/authorsacceptcf_<field>,cf_<field>_min,cf_<field>_max, andsort_by=cf:<field>&order=asc|descso theme authors can build dynamic regions (recent-posts grids, “top N by ranking” sidebars, scoped directories) client-side without server-side queries. Each publicly-queryable field must be opted in via a[[public_field]]block inconfig.toml— the default is fail-closed (400field_not_queryable) so sensitive fields are never accidentally exposed. Fields allowlisted with the"expose"operation are also included in the response body (publicFieldsmap on the authors endpoint), enabling client-side rendering of points, ranks, badges, and other live values. See api-reference.md and configuration.md.
Users, roles & security#
- Configurable roles. Three built-in roles — Admin, Contributor, and
Commentator — enforced by dedicated middleware on each realm. Sites can add
custom roles or override the built-ins via
[[role]]entries inconfig.toml, each granting a per-post-type manage set plus publish, media, and comment capabilities. Content, media, and comment endpoints enforce these capabilities, and the admin UI gates navigation and forms off the caller’s derived capabilities — the content editor hides its Publish button (and the Published status option) for roles without thepublishcapability. - First-run setup. A default
admin/adminaccount is auto-created on first start; the first login forces a password change. Self-registration createspendingCommentators an admin approves (or a configurable default role; see[registration]). - User management. Admins CRUD users, assign roles, suspend/unsuspend,
soft-delete, and moderate the registration queue (approve / reject / mark-as-spam).
The pending queue shows each registrant’s email-verification status; with
[registration] admin_approval = truea registrant must verify their email before an admin can activate them (409 EMAIL_NOT_VERIFIEDotherwise). - Profiles. Self-service profile (name, email, password, custom profile fields, avatar), self-service data export, and self-service account deletion.
- JWT auth (admin realm). Bearer-JWT sessions for the admin SPA, with Argon2id password hashing and transparent rehash-on-login for legacy bcrypt hashes.
- Failed-login lockout. An account locks for 15 minutes after 3 failed attempts, with an email notification.
- Email verification and password reset. Self-registration verifies via email
token — verification is always mandatory — and with
admin_approval = truethe verified registrant stayspendinguntil an administrator activates the account; forgot-password / reset-password flows are built in. - Rate limiting. Separate per-minute limits for auth, API, and public realms; per-key limiting on the agent API.
- CSRF and security headers. CSRF token validation plus CSP,
X-Frame-Options,X-Content-Type-Options, andReferrer-Policy. The CSP is configurable fromconfig.toml— operators append sources per directive, switch to Report-Only for safe rollout, add new directives, or override entirely (or disable when behind a CDN that manages its own CSP). Built-in defaults includeyoutube-nocookie.com(privacy-enhanced YouTube) alongside the existingunsafe-inline/ known CDN hosts. The sameframe-srcsources also govern which<iframe>embeds survive the HTML sanitizer in HTML-format content — a fullpolicyoverride replaces the sanitizer allowlist too, whiledisable/report_onlykeep the built-in allowlist as a safety net. - Configurable frame protection. Same-origin framing is opt-in via
[csp] frame_ancestors:["'self'"]relaxes the defaultframe-ancestors 'none'and switchesX-Frame-OptionstoSAMEORIGIN(host allowlists omit the legacy header entirely, flooring toDENYunder report-only so trials stay protected), so sites can embed their own demo/interactive pages in<iframe>s — withX-Frame-Optionsauto-derived from the same knob so the two headers never contradict.
Engagement#

- Comments with moderation. Per-content comments with a moderation queue
(
pending/approved/rejected/spam) and a per-content allow/deny toggle. - Public search. An on-site search box backed by
/api/v1/public/search. - Dashboard. Published/draft counts, users, pending registrations, media stats, recent content, and a per-post-type content breakdown (each card links to that type’s filtered list) in one view.
Missing something, or a feature reads stronger than it should? Features are kept honest against the source tree — open an issue or PR. For the architecture behind these features, read project-context.md.