All API endpoints are under the /admin/api prefix. All request and response bodies are JSON unless noted.

Base URL: http://localhost:3000/admin/api

Common error shape:

{ "error": "Human-readable message" }

Status codes used:

Code Meaning
200 Success
201 Created
400 Bad request (missing field, invalid value, failed validation)
404 Resource not found
409 Conflict (slug already taken, has children)
500 Server error — always includes error and message
502 Upstream fetch failed (marketplace, remote install)

Pages

GET /admin/api/pages Returns all pages (drafts and published), sorted by depth then order then title.

Response 200

{
    "pages": [
        {
            "slug": "about",
            "title": "About",
            "description": "About this site",
            "order": 1,
            "root": true,
            "parent": null,
            "depth": 1,
            "status": "published",
            "featured_image": "/images/hero.webp",
            "url": "/about/"
        },
        {
            "slug": "getting-started",
            "title": "Getting Started",
            "description": "",
            "order": null,
            "root": false,
            "parent": "documentation",
            "depth": 2,
            "status": "published",
            "featured_image": null,
            "url": "/documentation/getting-started/"
        }
    ]
}
GET /admin/api/pages/:slug Returns a single page including its raw Markdown body (not rendered HTML).

Response 200

{
    "slug": "about",
    "frontmatter": {
        "title": "About",
        "description": "About this site",
        "slug": "about",
        "order": 1,
        "root": false,
        "status": "published"
    },
    "body": "## Hello\n\nThis is the about page."
}

Returns 404 if not found.

GET /admin/api/pages/slug-check?slug=<slug>&current=<slug> Checks whether a slug is available. Pass current when editing to exclude the page being renamed.

Response 200

{ "slug": "about", "available": true }
POST /admin/api/pages Creates a new page. Required fields: title, slug.

Request body

Field Type Required Notes
title string Yes
slug string Yes Will be slugified
description string No
parent string No Cleared automatically when root: true. Max 3 levels deep.
order number No
root boolean No If true, served at /{slug}
featured_image string No
status "draft" | "published" No Defaults to "draft"
body string No Raw Markdown content

Response 201

{ "slug": "about", "message": "Page created" }

Returns 409 if the slug is already taken.

PUT /admin/api/pages/:slug Updates a page. If slug in the body differs from :slug in the URL, the file is renamed and child pages cascade-updated.

Accepts the same fields as POST. If slug in the body differs from :slug in the URL, the page file is renamed and the parent field of all direct children is updated automatically.

Response 200

{ "slug": "about", "message": "Page updated" }

Returns 404 if the current page doesn't exist. Returns 409 if the new slug is taken.

DELETE /admin/api/pages/:slug Deletes a page. Blocked if the page is a parent — re-assign or delete children first.

Response 200

{ "message": "Page deleted" }

Returns 404 if not found. Returns 409 if the page has children.

POST /admin/api/pages/bulk Bulk publish, draft, or delete multiple pages. Pages with children are skipped on delete.

Request body

{ "action": "publish", "slugs": ["about", "contact", "docs"] }

action must be "publish", "draft", or "delete".

Response 200

{
    "ok": ["about", "contact"],
    "skipped": [{ "slug": "docs", "reason": "has children: getting-started" }]
}

Always returns 200. Results are reported per-slug.


Posts

Posts share the same endpoint shape as Pages, substituting /admin/api/posts for the base. Differences are noted below.

GET /admin/api/posts Returns all posts sorted newest-first by date. Posts without a date sort to the end.

Response 200

{
    "posts": [
        {
            "slug": "hello-world",
            "title": "Hello World",
            "description": "My first post",
            "date": "2025-06-01",
            "category": "General",
            "tags": ["intro", "news"],
            "status": "published",
            "featured_image": null,
            "author": "LebCit"
        }
    ]
}

Post-specific fields in addition to shared page fields:

Field Type Notes
date string ISO date YYYY-MM-DD. Defaults to today if omitted.
category string A single category string
tags string | array Comma-separated string or array
author string A single author string

The root and order fields do not apply to posts. The parent hierarchy does not apply to posts.

GET /admin/api/posts/:slug Returns a single post including its raw Markdown body (not rendered HTML).

Response 200

{
    "slug": "hello-world",
    "frontmatter": {
        "title": "Hello World",
        "description": "My first post",
        "slug": "hello-world",
        "date": "2025-06-01",
        "category": "General",
        "tags": ["intro", "news"],
        "status": "published",
        "featured_image": null,
        "related_posts": ["another-post", "yet-another"],
        "author": "LebCit"
    },
    "body": "## Hello\n\nThis is the post body."
}

Returns 404 if not found.

GET /admin/api/posts/slug-check?slug=<slug>&current=<slug> Checks whether a slug is available. Pass current when editing to exclude the post being renamed.

Response 200

{ "slug": "hello-world", "available": true }
POST /admin/api/posts Creates a new post. Required fields: title, slug.

Request body

Field Type Required Notes
title string Yes
slug string Yes Will be slugified
description string No
date string No ISO date YYYY-MM-DD. Defaults to today if omitted.
category string No A single category string
tags string | array No Comma-separated string or array
featured_image string No
related_posts string | array No Comma-separated string or array of post slugs. Max 4. Order is preserved.
status "draft" | "published" No Defaults to "draft"
body string No Raw Markdown content
author string No

Response 201

{ "slug": "hello-world", "message": "Post created" }

Returns 409 if the slug is already taken.

PUT /admin/api/posts/:slug Updates a post. If slug in the body differs from :slug in the URL, the file is renamed.

Accepts the same fields as POST. If slug in the body differs from :slug in the URL, the post file is renamed automatically.

Response 200

{ "slug": "hello-world", "message": "Post updated" }

Returns 404 if the current post doesn't exist. Returns 409 if the new slug is taken.

DELETE /admin/api/posts/:slug Deletes a post.

Response 200

{ "message": "Post deleted" }

Returns 404 if not found.

POST /admin/api/posts/bulk Bulk publish, draft, or delete multiple posts.

Request body

{ "action": "publish", "slugs": ["hello-world", "another-post"] }

action must be "publish", "draft", or "delete". Posts have no parent-child constraint, so delete never produces skipped entries due to children.

Response 200

{
    "ok": ["hello-world", "another-post"],
    "skipped": []
}

Always returns 200. Results are reported per-slug.


Images

GET /admin/api/images Returns all uploaded images sorted newest-first.

Response 200

{
    "images": [
        {
            "name": "hero.webp",
            "url": "/images/hero.webp",
            "size": 48210,
            "mtime": "2025-06-01T10:00:00.000Z"
        }
    ]
}
POST /admin/api/images Uploads one or more images. multipart/form-data, field name: images.
  • Content-Type: multipart/form-data
  • Field name: images
  • Size limit: 10 MB per upload
  • Allowed extensions: .jpg, .jpeg, .png, .gif, .webp, .svg, .avif
  • Filenames are sanitized — lowercased, special chars stripped, spaces replaced with hyphens
  • Collisions resolved by appending a counter (hero-1.webp)

Response 201

{
    "uploaded": [{ "name": "hero.webp", "url": "/images/hero.webp", "size": 48210 }],
    "errors": []
}
DELETE /admin/api/images/:name Deletes an image by filename. Path traversal characters are rejected.

Returns 400 if the filename contains .., /, or \. Returns 404 if not found.

Response 200

{ "message": "Image deleted" }
POST /admin/api/images/bulk-delete Deletes multiple images in a single request.

Request body

{ "names": ["hero.webp", "logo.png"] }

Response 200

{
    "ok": ["hero.webp"],
    "skipped": [{ "name": "logo.png", "reason": "not found" }]
}

Themes

GET /admin/api/themes Returns all installed themes and the active theme ID.

Response 200

{
    "themes": [
        {
            "id": "default",
            "name": "Default",
            "version": "1.0.0",
            "features": ["blog"],
            "menus": ["primary", "footer"]
        }
    ],
    "activeTheme": "default"
}
POST /admin/api/themes/activate/:id Activates a theme. Validates that home.html and 404.html are present before writing.

Response 200

{ "activeTheme": "default", "message": "Theme \"Default\" activated" }

Returns 404 if the theme ID doesn't exist. Returns 400 if a required template is missing.

POST /admin/api/themes/install Installs a theme from a ZIP upload. multipart/form-data, field name: theme.

The ZIP must contain a valid theme.json with at minimum name, id, and version. Both templates/home.html and templates/404.html must be present inside the ZIP — these are validated before any files touch disk.

Response 201

{ "themeId": "my-theme", "name": "My Theme", "message": "Theme \"My Theme\" installed" }

Returns 400 for non-ZIP uploads, missing theme.json, invalid manifest, or missing required templates.

GET /admin/api/themes/marketplace Fetches the remote theme manifest and returns it with update detection. Cached for 30 minutes.

The manifest is fetched from the official Blog-Doc themes repository on Codeberg and cached in memory for 30 minutes. Update detection compares each remote theme's version against the locally installed copy with the same id.

Response 200

{
    "fetchedAt": 1748800000000,
    "themes": [
        {
            "id": "my-theme",
            "name": "My Theme",
            "version": "1.2.0",
            "author": "Jane Doe",
            "description": "A clean, minimal theme.",
            "screenshot": "https://example.com/screenshot.webp",
            "download": "https://example.com/my-theme.zip"
        }
    ],
    "updates": ["my-theme"]
}

updates is an array of IDs of installed themes whose local version differs from the remote. Returns 502 if the remote manifest cannot be fetched.

POST /admin/api/themes/install-remote Downloads a theme ZIP from a remote URL and installs it. Invalidates the marketplace cache on success.

Request body

{ "url": "https://example.com/my-theme.zip" }

The ZIP is fetched into memory and passed through the same pipeline as the local install endpoint. The marketplace cache is invalidated after a successful install.

Response 201

{ "themeId": "my-theme", "name": "My Theme", "message": "Theme \"My Theme\" installed" }

Returns 400 if url is missing or the ZIP fails validation. Returns 502 if the remote download fails.

DELETE /admin/api/themes/:id Deletes an installed theme. The active theme and the default theme cannot be deleted.

Response 200

{ "message": "Theme \"My Theme\" deleted" }

Menus

GET /admin/api/menus Returns all menus and the menu locations declared by the active theme.

Response 200

{
    "menus": {
        "primary": [
            { "label": "Home", "url": "/" },
            { "label": "Blog", "url": "/blog" }
        ],
        "footer": []
    },
    "supportedMenus": ["primary", "footer"]
}

supportedMenus comes from the active theme's theme.json menus array.

PUT /admin/api/menus Replaces all menus. Body is an object keyed by menu location name.

Request body

{
    "primary": [
        { "label": "Home", "url": "/" },
        { "label": "About", "url": "/about" }
    ],
    "footer": [{ "label": "Privacy", "url": "/privacy" }]
}

Blog-Doc does not enforce a schema on menu items — store whatever shape your theme expects.

Response 200

{ "menus": { "..." }, "message": "Menus saved" }
PUT /admin/api/menus/:name Replaces a single named menu. Body is an array of menu item objects.

Request body

[
    { "label": "Home", "url": "/" },
    { "label": "About", "url": "/about" }
]

Response 200

{ "menu": "primary", "items": ["..."], "message": "Menu \"primary\" saved" }

Settings

GET /admin/api/settings Returns the current site settings.

Response 200

{
    "settings": {
        "title": "My Site",
        "description": "A personal blog",
        "url": "https://example.com",
        "postsPerPage": 5,
        "language": "en",
        "logo": "/images/logo.png",
        "icon": "/images/favicon.png",
        "headerCode": "<!-- custom head HTML -->",
        "footerCode": "<!-- custom pre-body HTML -->"
    }
}
PUT /admin/api/settings Updates one or more settings. Partial updates — send only the fields you want to change.

Accepted keys: title, description, url, postsPerPage, language, logo, icon, headerCode, footerCode. All other keys are silently ignored. Settings are merged (Object.assign-style).

Send an empty string for logo or icon to clear the field.

Response 200

{ "settings": { "..." }, "message": "Settings saved" }

Build

POST /admin/api/build Triggers a full static site generation. Idempotent — wipes _site/ and rebuilds from scratch every time.

Response 200 (success)

{
    "success": true,
    "stats": { "pages": 3, "posts": 12, "assets": 8, "taxonomyPages": 6 },
    "errors": [],
    "duration": "0.45s",
    "message": "Site built in 0.45s — 3 pages, 12 posts",
    "builtAt": "2025-06-01T10:00:00.000Z"
}

Response 500 (partial failure)

{
    "success": false,
    "stats": { "pages": 2, "posts": 11, "assets": 8, "taxonomyPages": 4 },
    "errors": ["Post \"broken-post\": Template not found"],
    "duration": "0.41s",
    "message": "Build completed with 1 error(s)",
    "builtAt": "2025-06-01T10:00:00.000Z"
}
GET /admin/api/build/status Returns the result of the most recent build stored in memory.

Returns { "message": "No build has been run yet" } if no build has run since server start. Otherwise returns the last build result object (same shape as POST /admin/api/build).

GET /admin/api/build/download Streams the current _site/ directory as a ZIP archive. Filename: blog-doc-site-YYYY-MM-DD.zip
  • Content-Type: application/zip
  • Returns 404 if no build exists yet.

Health

GET /health Simple liveness check. No authentication required.

Response 200

{ "status": "ok", "app": "Blog-Doc", "version": "4.0.0" }