Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/fork-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Fork staging image

on:
workflow_dispatch:
push:
branches: [main, chore/mittwald-staging]

permissions:
contents: read
packages: write

concurrency:
group: fork-image-${{ github.ref }}
cancel-in-progress: false

jobs:
image:
if: github.repository == 'Hubertoink/Instatic'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and publish fork image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: true
tags: ghcr.io/hubertoink/instatic:staging-${{ github.sha }}
labels: |
org.opencontainers.image.source=https://github.com/Hubertoink/Instatic
org.opencontainers.image.url=https://github.com/Hubertoink/Instatic
build-args: |
INSTATIC_VERSION=staging-${{ github.sha }}
INSTATIC_REVISION=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
70 changes: 70 additions & 0 deletions docs/deployment/hochstaett-fork.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Hochstaett deployment fork

This fork preserves the locally tested CMS customizations used by the Hochstaett site.

- Fork: https://github.com/Hubertoink/Instatic
- Upstream: https://github.com/CoreBunch/Instatic
- Initial integration branch: `feat/hochstaett-deployment-baseline`
- Upstream base: `f92e8dc1` (0.0.20)

## Source and runtime data

Git contains application code, tests and documentation. Website content lives in
the CMS database and uploads volume. Database files, uploaded media, backups,
environment files and secret keys are not deployment source and must be
transferred separately through a private backup/restore process.

The integration branch includes relation/repeater loops, preview improvements,
publication-date handling, animation presets, and content image sizing,
lightbox and link insertion. Review these local changes through the fork's draft
pull request before merging; the integration branch is not a production release.

## Git remotes

`origin` points to this fork; `upstream` points to CoreBunch. Feature branches
and pull requests belong to the fork unless a contribution to CoreBunch is
explicitly intended.

```sh
git clone https://github.com/Hubertoink/Instatic.git
cd Instatic
git remote add upstream https://github.com/CoreBunch/Instatic.git
git switch --track origin/feat/hochstaett-deployment-baseline
```

Fetch upstream updates and integrate them on a separate review branch. Do not
reset the fork to upstream when preserving these customizations.

## Container handoff

Build the image from the reviewed fork commit:

```sh
docker build -t instatic-hochstaett:staging .
```

The fork image workflow, `.github/workflows/fork-image.yml`, builds Linux amd64
images on pushes to `main` and `chore/mittwald-staging`, and supports manual
dispatch after merging. Images are tagged
`ghcr.io/hubertoink/instatic:staging-<full-commit-sha>`. The Docker build runs
TypeScript checking and the production frontend build. It does not run tests
or lint; those remain separate verification steps.

The upstream release workflow still targets `ghcr.io/corebunch/instatic`;
do not create release tags to publish fork images. The fork workflow only
publishes images and does not change a running Mittwald container.

Mittwald needs an image available in a registry, persistent storage, HTTPS
routing and the application environment described in [docker-image.md](docker-image.md).
For a copy of the existing SQLite installation, preserve the data and uploads
volumes and its secret encryption key using [backup-restore.md](backup-restore.md).

Publishing inside a local CMS instance does not deploy that instance to Mittwald.
An online staging instance publishes to its own staging site.

## Validation at initial handoff

The production Docker build (TypeScript and Vite), ESLint, targeted content tests,
and desktop/mobile lightbox checks passed locally. The full Windows test run
was not green; unrelated architecture, platform and UI failures remain for
review. Consult the draft pull request for the handoff results.
13 changes: 13 additions & 0 deletions docs/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ The frontend is a single React 19 + Vite SPA mounted at `/admin`. Inside it, two

## TL;DR

- **Entrance animations:** select a class, then **Styles → Effects → Animation** to choose Fade in, Slide up/down/from left/from right, or Zoom in. Duration, delay (milliseconds), and easing are editable. Choose Trigger: On load or On scroll into view. Scroll effects run once when the element intersects the viewport; the configured delay starts at that point. Design mode keeps scroll effects visible for editing; Live mode and published pages use the same IntersectionObserver runtime, including entries inserted by loops. Without JavaScript/Web Animations support, or with reduced motion, scroll content remains visible. None disables an inherited animation; Inherited clears the local value. Custom CSS preserves arbitrary imported animation shorthands. Preset keyframes are emitted by the shared canvas/publisher class-CSS generator only when used. Reduced-motion preferences keep content visible without entrance movement, including during delays.
- **Local links:** the URL control accepts `/page#section`, `#section`, `./page`, `../page`, query references, and `tel:` as well as HTTP(S)/mailto links. Protocol-relative URLs and unsafe schemes remain rejected.

- **Entry:** `src/admin/main.tsx` mounts `<Router><AdminRoutes /></Router><AdminContextMenuGuard />` with React 19 root-level error callbacks. `flushSync` forces the initial render synchronous to cut LCP.
- **Router:** `src/admin/lib/routing/` — in-house router replacing `react-router-dom`. Ten workspace/page routes are wrapped in a per-route `<ErrorBoundary>` and `<Suspense>`, with root redirects plus a final `path="/admin/*"` catch-all redirecting unknown admin URLs to `/admin/dashboard` (login form when unauthenticated) instead of rendering an empty tree. Public-site 404s are NOT claimed — the publish pipeline's NotFound handling owns those.
- **Cold path:** entry chunk is tiny. `AuthenticatedAdmin` is `React.lazy` and only loads post-login. Each workspace page is wrapped in `prewarmedLazy(...)`: the active page fires its import at module evaluation; the remaining pages pre-warm via `requestIdleCallback` after first paint so subsequent nav is synchronous (no Suspense flicker).
Expand Down Expand Up @@ -337,6 +340,7 @@ The store is composed of **12 slices**, each created by a factory in `store/slic
| `selectionSlice` | `selectedNodeId`, `hoveredNodeId` |
| `canvasSlice` | Zoom, pan, `activeBreakpointId`, `activeConditionId`, `canvasMode` ('select'|'pan'|'insert'), `canvasView` ('design'|'live'), `runScripts` |
| `uiSlice` | Site editor panel visibility, unsaved-changes flag, insert picker, `componentizeEditorRequest` |
| `previewSelectionSlice` | Session-only template and component preview source selections |
| `classSlice` | Style-rule CRUD, node ↔ class assignment, ambient selector creation |
| `filesSlice` | `SiteFile` CRUD |
| `visualComponentsSlice`| Visual Component CRUD |
Expand Down Expand Up @@ -390,6 +394,14 @@ Selectors are pure reads. Mutations go through actions (`useEditorStore.getState

### 1. Design mode and live mode

Native `details`/`summary` accordions can be opened and closed by clicking their
summary in either view, or with Enter/Space while the summary is focused.
Selection still works normally. Selecting a layer inside a collapsed body
reveals its ancestor accordions automatically. This state belongs only to the
rendered editor frame: it does not change the page tree, collaboration document,
undo history, or the published initial `open` attribute. Reloading the editor
restores the authored state. Read-only composed regions are not toggled.

`CanvasRoot` switches between two rendering surfaces based on `canvasView`:

- **Design mode** (`canvasView === 'design'`): `CanvasRoot` → `CanvasTransformLayer` → `BreakpointFrame` → `IframeFrameSurface` → `NodeRenderer`. Each breakpoint gets its own iframe rendered side-by-side inside the pan/zoom transform layer. The author sees all breakpoints at once and can zoom in/out. The canvas opens at 50% (`INITIAL_ZOOM`) so several frames fit in view; reset (Cmd/Ctrl+0, the toolbar % button) goes to 100% (`RESET_ZOOM`).
Expand Down Expand Up @@ -788,3 +800,4 @@ See [docs/features/plugin-system.md](features/plugin-system.md) for the plugin S
- `src/__tests__/architecture/canvas-aware-selectors.test.ts`
- `src/__tests__/architecture/spotlight-no-direct-store-mutation.test.ts`
- `src/__tests__/architecture/keybindings-registry-single-source.test.ts`

4 changes: 4 additions & 0 deletions docs/features/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

The AI Agent is a model-powered assistant integrated into the Site editor and Content workspace. The shared Agent Panel owns conversation state, provider selection, streaming, history, and the browser bridge; each workspace supplies its own snapshot builder and tool executor.

Relation and repeater catalogs are exposed through the existing tools. `site_list_loop_sources` preserves relation `targetTableSlug` and `allowMultiple`, media metadata, and repeater item fields with their binding tokens. An `entry.field` loop uses `<instatic-loop data-source-id="entry.field" data-field-id="teammembers" data-direction="asc">...</instatic-loop>` inside an entry context. Relation children bind the target table's fields; repeater children bind item fields. The HTML importer preserves the selected field and defaults contextual loops to authored order.

`content_get_collection_schema` includes relation target table IDs/cardinality and repeater item schemas (including select option IDs). Content writes use bare row ID strings or `null` for single relations and string arrays for multi-relations; media uses the same shape with asset IDs. Repeaters use ordered `{ id, cells }` items. The Content bridge edits post-type entries; schema management and a Data-workspace toolset are not provided.

In the Site editor, the agent reads the current page snapshot, plans a sequence of edits, and executes them by calling tools. Structure is written as semantic HTML (`site_insert_html` / `site_replace_node_html`); styling is written as CSS — a `<style>` block and/or `class=` attributes inside the insert, or the dedicated `site_apply_css` tool for authoring/editing any CSS on its own. There is one CSS path and it accepts every selector; `site_assign_class` / `site_remove_class` attach existing classes to nodes.

In the Content workspace, the agent works against content collections and entries. It reads collection schemas and document state server-side, then mutates the live content editor through a browser bridge so the open draft, Tiptap body editor, and sidebar selection stay authoritative.
Expand Down
18 changes: 18 additions & 0 deletions docs/features/content-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ The Content workspace renders a three-pane shell — explorer sidebar, document
- **Body editor:** `TiptapBodyEditor` — one ProseMirror document, not a block list. Body persists as markdown text in the `body` cell.
- **Inline marks:** bubble menu (B / I / code / strike / link). Block inserts: slash menu (`/`) + notch quick-actions.
- **Canvas modes:** `write` (bare editor surface) and `live` (entry rendered inside its template with real site styles).
- **Body images:** toolbar sizes Original (intrinsic width), L (content width), M (560px), S (320px), capped to the available width. Zoom uses the active button variant. Markdown titles persist size and lightbox metadata; stored `size=sm` maps to M.
- **Link insertion:** the block-options menu opens `InsertLinkDialog` for link text and a safe target URL. Both link editors offer the media library (all file types) and Open/Download. Download links store the reserved Markdown title `instatic:download` and publish with the HTML `download` attribute; browsers support forced downloads for same-origin media.
- **Text alignment:** paragraph and heading alignment persists through the Markdown bridge as restricted `<p align="center">…</p>` / `<h2 align="right">…</h2>` wrappers. Their contents remain Markdown; the publisher emits semantic elements with `instatic-content-align--*` classes backed by `publisher/reset.ts`, surviving rich-text sanitization. Alignment-only edits therefore mark the entry dirty and can be published.
- **Lightbox runtime:** `src/core/imageLightboxRuntime.ts` opens a native modal dialog in a shadow root to isolate it from site styles. Fade/scale animation respects reduced motion; closing by Escape, backdrop or the high-contrast button restores scroll and focus without fragment navigation. The publisher includes the same-origin runtime for lightbox content and deferred fragments. Without JavaScript the link opens the image file.
- **Settings panel:** `ContentSettingsPanel` — entry-specific; hidden when no entry is selected. Reopened via the top-right notch when collapsed.
- **Hooks:** `useContentWorkspace` (CRUD + selection), `useContentEntryDraft` (field state + save/publish), `useContentMediaPicker` (media modal + featured media).
- **AI assistant:** `ContentAgentMount` docks the shared Agent Panel in the content rail. `useContentToolBridge`, mounted by `ContentPage`, exposes the live workspace to both the built-in agent and scoped MCP relay even when the panel is closed, so writes mutate the open draft/editor state rather than stale database rows.
Expand Down Expand Up @@ -195,3 +199,17 @@ Body content is exchanged with the model as markdown. The browser bridge convert
- `src/admin/pages/content/hooks/useContentEntryDraft.ts` — field draft state
- `src/core/markdown/markdownDocument.ts` — markdown ↔ ProseMirror round-trip
- `src/admin/layouts/AdminWorkspaceCanvasLayout/AdminWorkspaceCanvasLayout.tsx` — workspace shell + notch

---

# Post publication dates

The seeded **Posts** collection includes an editable `date` field labelled
**Publication date**. Existing installations add it through migration 031 and
backfill existing posts and published versions from their stored publication
timestamp. Publishing a post with an empty date fills the current calendar day;
an author-entered date is preserved. Site loops can order posts by this value
with `orderBy: "cell:date"`, which keeps imported or intentionally backdated
posts in editorial order instead of the order in which they were migrated.
Migration 032 also recognises the opening `**Veröffentlicht am DD.MM.YYYY.**`
line used by archive imports and restores that original date automatically.
6 changes: 6 additions & 0 deletions docs/features/data-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,12 @@ the ordinary row draft. Nested relation and media fields reuse their shared
pickers. Multi-media fields place `MediaPickerModal` in true multi-selection
mode: plain clicks toggle assets and the footer commits the entire selection.

Relation pickers show checkboxes for multiple selection, a selection count, and
the target entry's display title and publication status. Confirm commits the
selection; Cancel leaves the stored value unchanged. Relation editor fields
show the selected names in relation order, with the full list in a tooltip when
space is limited. Titles use the target table's configured primary field.

When a repeater contains exactly one single-value media field,
`MediaRepeaterGallery.tsx` replaces the generic structured-item cards with the
Media workspace presentation. It renders the shared `AssetTile` / `AssetRow`
Expand Down
4 changes: 2 additions & 2 deletions docs/features/html-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ Callers splice the fragment into the page tree via `insertImportedNodes(parentId
| Selector | Module | Props set | Recurse |
|---|---|---|---|
| `instatic-outlet` | `base.outlet` | none (the CMS content outlet) | **No** |
| `instatic-loop` | `base.loop` | `sourceId`, `filters.tableId`, `orderBy`, `direction`, `limit`, `offset`, `pagination`, `pageSize`, optional `tag` / `customTag` from `data-*` attrs | Yes |
| `instatic-loop` | `base.loop` | `sourceId`, `filters.tableId`, `filters.fieldId`, `orderBy`, `direction`, `limit`, `offset`, `pagination`, `pageSize`, optional `tag` / `customTag` from `data-*` attrs | Yes |
| `h1`–`h6`, `p`, `span`, `small`, `strong`, `em` | `base.text` | `text` = `el.textContent`, `tag` = tag name | No |
| `a` with class `btn` | `base.button`, or `base.link` when it wraps element children | `label` (`text` on `base.link`) = `el.textContent`, `href`, `target` | No for text-only; yes when it wraps elements |
| `a` (no `btn` class) | `base.link` | `text` = `el.textContent`, `href`, `target` | No for text-only; yes when it wraps elements |
Expand All @@ -117,7 +117,7 @@ Callers splice the fragment into the page tree via `insertImportedNodes(parentId
**Key details:**

- **`<instatic-outlet>` → `base.outlet`.** The custom element marks where matched content flows in a CMS template. It maps to a childless `base.outlet` node (any inner markup is ignored — the composer fills it). This rule lets the AI agent and hand-authored template HTML place the single content outlet inline via the normal import path. See [templates.md](templates.md) and [agent.md](agent.md).
- **`<instatic-loop>` → `base.loop`.** The custom element lets the AI agent and hand-authored snippets create a real Loop through the same HTML import path. Children recurse normally and become loop variants. Loop configuration is read from attributes: `data-source-id`, `data-table-id` (stored as `filters.tableId`), `data-order-by`, `data-direction`, `data-limit`, `data-offset`, `data-pagination`, `data-page-size`, and optional `data-tag` / `data-custom-tag`. See [loops.md](loops.md) and [agent.md](agent.md).
- **`<instatic-loop>` → `base.loop`.** The custom element lets the AI agent and hand-authored snippets create a real Loop through the same HTML import path. Children recurse normally and become loop variants. Loop configuration is read from attributes: `data-source-id`, `data-table-id` (stored as `filters.tableId`), `data-field-id` (stored as `filters.fieldId` for contextual relation/repeater loops), `data-order-by`, `data-direction`, `data-limit`, `data-offset`, `data-pagination`, `data-page-size`, and optional `data-tag` / `data-custom-tag`. Without an explicit direction, `entry.field` loops preserve authored order (`asc`); other sources default to `desc`. See [loops.md](loops.md) and [agent.md](agent.md).
- `base.text` uses `tag` (not a separate `level` or heading prop) — the tag name is passed through directly. Imported bare DOM text uses `tag: 'none'`, which publishes text without an element wrapper.
- **Direct text inside a recursing container is preserved.** The walker iterates `childNodes` (not just `children`): element children route through the rules, and each significant text node becomes a synthesized `base.text` child with `tag: 'none'` in document order. That no-wrapper text mode publishes back to bare text, so `<div class="num">98%</div>` and `<li>Buy milk</li>` import as containers holding their original text without adding selector-visible wrapper elements. Whitespace-only text (indentation between tags) is skipped; internal whitespace runs collapse to single spaces, and boundary spaces are kept when the text run sits between element siblings.
- **`<body>` metadata is preserved separately.** Classes, safe HTML attributes (`id`, ARIA, `data-*`, etc.), and harvested inline styles on `<body>` are returned as `fragment.body` rather than inserted into `rootIds`. Full-site import applies them to `base.body`; paste-style HTML import can ignore them without changing the fragment structure.
Expand Down
Loading