Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
50 changes: 50 additions & 0 deletions docs/site-editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,56 @@ entity work:
These are shareable — bookmark or send to a colleague to jump straight
into editing that entity.

### Deep links by slug

Path routes address entities by database id. Callers outside the SPA
usually do not have one — the post editor's composed view, for instance,
knows only the slug of the template it composed against. For those, the
site editor accepts a query-string deep link on the mount URL:

```text
/visual-editor/site?entity=template&slug=single
```

On mount the SPA parses the query string, resolves the slug through the
templates list endpoint, and navigates to that template's editor view.
The `pushState` that follows rewrites the address bar to the canonical
path route (`/visual-editor/site/templates/12`), so the query-string form
is an entry point rather than a URL the user is left holding.
Comment on lines +158 to +171

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use one identifier model for path routes.

Lines 158-171 define path routes as database-ID routes and show /visual-editor/site/templates/12. Lines 146-150 show slug-like values such as single. Update the examples or the contract so both describe the same route identifier.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/site-editor.md` around lines 158 - 171, Align the site editor
documentation’s route identifier model by making the deep-link example and
canonical path use the same identifier type. Update the
`/visual-editor/site?entity=template&slug=single` example and its resolution
description, or the `/visual-editor/site/templates/12` route, so both
consistently describe either database IDs or slugs.


| Parameter | Required | Notes |
|-----------|----------|-------|
| `entity` | yes | Only `template` is supported today. Unknown values are ignored. |
| `slug` | yes | Entity slug. Blank or whitespace-only values are ignored. |
| `entity_id` | no | Pre-resolved id. When supplied the slug lookup is skipped. Reserved for entity kinds that have no slug. |

Behaviour worth relying on:

- **Additive.** Mounting with no query string behaves exactly as it did
before — no lookup is issued and the SPA lands on its default section.
- **Unknown entity is a no-op**, not an error. A link written against a
future package version lands on the default section rather than
throwing.
- **Unresolvable slug lands on the Templates index** with a toast reading
`The template "{slug}" was not found.` A failed lookup does the same.
- **The access gate is unchanged.** `SiteEditorAccessGate` runs
server-side, before the SPA mounts, so a denied request still renders
the deny-by-default page and never reaches this parsing.

Build links with `buildTemplateDeepLink()` from
`site-editor/deep-link.ts` rather than assembling the query string by
hand, so both sides of the contract move together. The first consumer is
the post editor's composed-view ribbon, whose **Edit template ↗** button
opens one of these links in a new tab.

`buildTemplateDeepLink()` defaults to the package's own mount path
(`/visual-editor/site`) and takes the route base as an optional second
argument. The composed-view ribbon currently relies on that default: the
post editor has no way to discover a `data-route-base` the host may have
overridden on the site-editor mount element. A host that mounts the site
editor at a non-default path will get a ribbon CTA pointing at the
default path until that value is threaded through to the post editor.
Comment on lines +198 to +204

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline resources/js/visual-editor/editor/editor-app.tsx --view expanded
ast-grep outline resources/js/visual-editor/editor/editor-canvas.tsx --view expanded
ast-grep outline resources/js/visual-editor/editor/composed-view/composed-view-ribbon.tsx --view expanded

rg -n -C 5 --glob '*.{ts,tsx}' \
  'siteEditorRouteBase|routeBase|buildTemplateDeepLink|ComposedViewRibbon' \
  resources/js/visual-editor

Repository: ArtisanPack-UI/visual-editor

Length of output: 50384


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== editor-app outline =="
ast-grep outline resources/js/visual-editor/editor/editor-app.tsx --view expanded

echo "== editor-canvas relevant lines =="
sed -n '1,220p' resources/js/visual-editor/editor/editor-canvas.tsx

echo "== composed-view-ribbon relevant lines =="
sed -n '1,130p' resources/js/visual-editor/editor/composed-view/composed-view-ribbon.tsx

echo "== focused call sites for ComposedViewRibbon and route/data propagation =="
rg -n -C 4 --glob '*.{ts,tsx}' 'EditorApp|<EditorApp|EditorAppShell|<EditorAppShell|<ComposedViewRibbon|siteEditorRouteBase|data-route-base|routeBase' resources/js/visual-editor/editor resources/js/visual-editor/site-editor || true

echo "== visual-editor mount / post-editor related files =="
fd -a '.*post.*|.*visual.*|editor-app' resources/js/visual-editor | sed 's#^\./##'

Repository: ArtisanPack-UI/visual-editor

Length of output: 50384


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== editor main config/readers =="
sed -n '80,180p' resources/js/visual-editor/editor/main.tsx
sed -n '300,460p' resources/js/visual-editor/editor/main.tsx

echo "== EditorCanvas / EditorApp rendering =="
sed -n '365,460p' resources/js/visual-editor/editor/editor-app.tsx

echo "== CanvasChrome / EditorCanvas call sites =="
rg -n -C 3 --glob '*.{ts,tsx}' 'type CanvasChrome|interface CanvasChrome|<EditorCanvas|EditorCanvas\(' resources/js/visual-editor/editor resources/js/visual-editor/site-editor

echo "== routeBase usages in editor/main/editor-app with file names =="
for f in $(rg -l --glob '*.{ts,tsx}' 'routeBase|data-route' resources/js/visual-editor/editor resources/js/visual-editor/site-editor); do
  echo "--- $f"
  rg -n -C 2 'routeBase|data-route' "$f"
done

echo "== docs limitation text =="
sed -n '190,210p' docs/site-editor.md

Repository: ArtisanPack-UI/visual-editor

Length of output: 34656


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

paths = [
    Path('resources/js/visual-editor/editor/editor-app.tsx'),
    Path('resources/js/visual-editor/editor/editor-canvas.tsx'),
    Path('resources/js/visual-editor/editor/composed-view/composed-view- ribbon.tsx'),
    Path('resources/js/visual-editor/editor/main.tsx'),
]
for p in paths:
    print('path exists', p, p.exists())

files = {
    'editor-app.tsx': Path('resources/js/visual-editor/editor/editor-app.tsx').read_text(),
    'editor-canvas.tsx': Path('resources/js/visual-editor/editor/editor-canvas.tsx').read_text(),
    'composed-view-ribbon.tsx': Path('resources/js/visual-editor/editor/composed-view/composed-view-ribbon.tsx').read_text(),
    'main.tsx': Path('resources/js/visual-editor/editor/main.tsx').read_text(),
}

props = re.findall(r'^export interface EditorAppProps \{(?P<body>.*?)^\}', files['editor-app.tsx'], re.M|re.S)
prop_names = re.findall(r'^\s*(?:export )?([A-Za-z_$][\w$]*)\?', props[0], re.M) if props else []
print('EditorAppProps has apiBase and routeBase?', 'apiBase' in prop_names, 'routeBase' in prop_names)
print('EditorAppProps routeBase mentions data-route-base/config?', any('routeBase' in prop_names and ('data-route' in props[0] or 'route-base' in props[0])) for _ in [1])
print('EditorCanvasProps has routeBase?', 'routeBase' in files['editor-canvas.tsx'])
print('EditorAppShell routeBase parsed/used?', bool(re.search(r'routeBase', files['editor-app.tsx'], re.I)))
print('EditorAppShell EditorCanvas chrome props routeBase?', bool(re.search(r'<EditorCanvas[\s\S]{0,2500}routeBase', files['editor-app.tsx'])))
print('MountConfig has routeBase?', 'routeBase:' in re.search(r'export interface MountConfig \{(?P<body>.*?)\n\}', files['main.tsx'], re.S).group(0))
PY

Repository: ArtisanPack-UI/visual-editor

Length of output: 496


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import re
from pathlib import Path

files = {
    'editor-app': Path('resources/js/visual-editor/editor/editor-app.tsx').read_text(),
    'editor-canvas': Path('resources/js/visual-editor/editor/editor-canvas.tsx').read_text(),
    'composed-view-ribbon': Path('resources/js/visual-editor/editor/composed-view/composed-view-ribbon.tsx').read_text(),
    'main': Path('resources/js/visual-editor/editor/main.tsx').read_text(),
}

app_props = re.search(r'export interface EditorAppProps \{(?P<body>.*?)\n\}', files['editor-app'], re.S).group('body')
app_props_names = re.findall(r'^\s*([A-Za-z_$][\w$]*)\?', app_props, re.M)

print('EditorAppProps contains routeBase:', 'routeBase' in app_props_names)
print('EditorAppProps contains apiBase:', 'apiBase' in app_props_names)
print('EditorCanvasProps contains routeBase:', bool(re.search(r'\brouteBase\b', files['editor-canvas'])))
print('EditorAppShell EditorCanvas props contain routeBase:', bool(re.search(r'<EditorCanvas(?:[\\s\\S]*?)routeBase', files['editor-app'])))

editor_app_route_mentions = re.findall(r'\brouteBase\b', files['editor-app'], re.I)
print('EditorApp routeBase mentions:', len(editor_app_route_mentions))

mount_config_lines = re.search(r'export interface MountConfig \{(?P<body>.*?)\n\}', files['main'], re.S).group('body')
print('Editor mount MountConfig routeBase:', 'routeBase' in mount_config_lines)
print('Editor mount MountConfig data-route/base text:', 'data-route' in mount_config_lines or 'route-base' in mount_config_lines)
PY

echo "== EditorApp Shell / EditorCanvas call from editor-app =="
sed -n '1420,1475p' resources/js/visual-editor/editor/editor-app.tsx

Repository: ArtisanPack-UI/visual-editor

Length of output: 2795


Thread the configured route base through the post editor.

ComposerViewRibbon accepts siteEditorRouteBase, but EditorCanvas never passes it, and EditorApp/EditorAppShell do not expose routeBase at all. Hosts that mount the site editor at /data-ap-site-editor/directive/mount?data-route-base=%2Fcustom%2Fsite-editor still get the ribbon Edit template ↗ CTA built to the package default unless the configured value is added to the mount config and forwarded through to ComposedViewRibbon.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/site-editor.md` around lines 198 - 204, Thread the configured route base
through the editor mount configuration: expose routeBase in EditorApp and
EditorAppShell, pass it from EditorCanvas to ComposerViewRibbon as
siteEditorRouteBase, and ensure ComposedViewRibbon/buildTemplateDeepLink uses
that value instead of the package default when provided. Preserve the default
route base when no override is configured.


---

## 6. Preview
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('canvasStyles', () => {
expect(framingIndex).toBeGreaterThan(defaultsIndex);
});

it('places COMPOSED_CHROME_STYLES last so it can undo the framing inside chrome regions', () => {
it('places COMPOSED_CHROME_STYLES after the framing so it can undo it inside chrome regions', () => {
const framingIndex = canvasStyles.findIndex(
(entry) => entry.css === POST_EDITOR_FRAMING_STYLES
);
Expand All @@ -56,10 +56,15 @@ describe('canvasStyles', () => {
// The framing entry exists to frame post *content* — 48px padding
// and a 720px column. Composed-view chrome (#655) renders inside
// the same canvas but is a site header/footer, so it has to win
// against that framing; hence it lands after. Anything added later
// would silently outrank the chrome reset.
// against that framing; hence it lands after.
//
// Only the #623 ribbon sheet follows it: the ribbon is editor
// chrome that happens to render in the canvas and shares no
// selectors with the chrome regions, so it cannot outrank the
// reset. Any *other* future entry appended past the chrome reset
// would, so this pins the tail to exactly one trailing sheet.
expect(chromeIndex).toBeGreaterThan(framingIndex);
expect(chromeIndex).toBe(canvasStyles.length - 1);
expect(chromeIndex).toBe(canvasStyles.length - 2);
});

it('keeps the alignment overrides between the canvas defaults and the post-editor framing', () => {
Expand Down
74 changes: 74 additions & 0 deletions resources/js/visual-editor/editor/__tests__/editor-canvas.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,4 +247,78 @@ describe('EditorCanvas', () => {
expect(frame.style.width).toBe('');
});
});

describe('composed-view ribbon (#623)', () => {
it('is absent in bare-content mode', () => {
render(
<EditorCanvas
showTitle
title=""
onTitleChange={() => undefined}
blockContext={null}
chrome={null}
/>
);

expect(
screen.queryByTestId('ap-composed-view-ribbon')
).not.toBeInTheDocument();
});

it('mounts inside the canvas iframe when chrome is supplied', () => {
render(
<EditorCanvas
showTitle
title=""
onTitleChange={() => undefined}
blockContext={null}
chrome={{
header: [],
footer: [],
templateName: 'Single Post',
templateSlug: 'single',
}}
/>
);

const ribbon = screen.getByTestId('ap-composed-view-ribbon');

// Inside the `BlockCanvas` stub, not beside it — the ribbon
// has to scroll with the composed preview rather than sit in
// the editor chrome above the frame.
expect(
screen.getByTestId('ap-stub-block-canvas')
).toContainElement(ribbon);
expect(
screen.getByTestId('ap-composed-view-ribbon-cta')
).toHaveAttribute(
'href',
'/visual-editor/site?entity=template&slug=single'
);
});

it('drops the CTA when composing against the fallback template', () => {
render(
<EditorCanvas
showTitle
title=""
onTitleChange={() => undefined}
blockContext={null}
chrome={{
header: [],
footer: [],
templateName: 'Default template',
templateSlug: null,
}}
/>
);

expect(
screen.getByTestId('ap-composed-view-ribbon')
).toBeInTheDocument();
expect(
screen.queryByTestId('ap-composed-view-ribbon-cta')
).not.toBeInTheDocument();
});
});
});
7 changes: 7 additions & 0 deletions resources/js/visual-editor/editor/canvas-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
} from '../editor-settings';

import canvasThemeTokens from './canvas-theme-tokens.css?inline';
import composedRibbonStyles from './composed-view/composed-view-ribbon.css?inline';
import flexLayoutStyles from '../../../css/flex-layout.css?inline';
import photoGridStyles from '../blocks/_shared/photo-grid/photo-grid.css?inline';

Expand Down Expand Up @@ -221,6 +222,12 @@ const baseCanvasStyles: CanvasStyle[] = [
// it exists to undo that framing inside the read-only header/footer
// regions, which are chrome, not post content.
{ css: COMPOSED_CHROME_STYLES },
// Composed-view ribbon (#623). Lives under `composed-view/`, which
// the `blocks/*/*.css` glob above does not reach, so it is handed
// to the iframe explicitly. Last in the list because it is editor
// chrome rendered inside the canvas — nothing downstream should
// restyle it.
{ css: composedRibbonStyles },
];

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import { ComposedViewRibbon } from '../composed-view-ribbon';

describe('ComposedViewRibbon', () => {
it('names the resolved template', () => {
render(
<ComposedViewRibbon templateName="Single Post" templateSlug="single" />
);

expect(
screen.getByText('Editing content inside Single Post')
).toBeInTheDocument();
});

it('labels itself as a landmark region for assistive tech', () => {
render(
<ComposedViewRibbon templateName="Single Post" templateSlug="single" />
);

expect(
screen.getByRole('region', { name: 'Composed-view controls' })
).toBeInTheDocument();
});

it('deep-links the CTA at the site editor in a new tab', () => {
render(
<ComposedViewRibbon templateName="Single Post" templateSlug="single" />
);

const cta = screen.getByTestId('ap-composed-view-ribbon-cta');

expect(cta).toHaveAttribute(
'href',
'/visual-editor/site?entity=template&slug=single'
);
expect(cta).toHaveAttribute('target', '_blank');
expect(cta).toHaveAttribute('rel', 'noopener noreferrer');
});

it('encodes reserved characters in the slug', () => {
render(
<ComposedViewRibbon
templateName="Odd"
templateSlug="single/post&more"
/>
);

expect(
screen.getByTestId('ap-composed-view-ribbon-cta')
).toHaveAttribute(
'href',
'/visual-editor/site?entity=template&slug=single%2Fpost%26more'
);
});

it('honours a host-supplied site-editor route base', () => {
render(
<ComposedViewRibbon
templateName="Single Post"
templateSlug="single"
siteEditorRouteBase="/admin/site-editor/"
/>
);

expect(
screen.getByTestId('ap-composed-view-ribbon-cta')
).toHaveAttribute(
'href',
'/admin/site-editor?entity=template&slug=single'
);
});

it('hides the CTA on the fallback template, keeping the name', () => {
render(
<ComposedViewRibbon
templateName="Default template"
templateSlug={null}
/>
);

expect(
screen.getByText('Editing content inside Default template')
).toBeInTheDocument();
expect(
screen.queryByTestId('ap-composed-view-ribbon-cta')
).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Composed-view ribbon — #623.
*
* Injected into the canvas iframe via `canvas-styles.ts` (`?inline`),
* NOT imported for side effects by the component: the iframe document
* sees none of the parent document's Vite-injected stylesheets.
*
* Colours resolve from the ArtisanPack UI tokens re-established on the
* iframe root by `canvas-theme-tokens.css`, with hex fallbacks so the
* ribbon still reads correctly when the editor is mounted outside a
* DaisyUI host.
*/

.ap-visual-editor__composed-ribbon {
position: sticky;
top: 0;
z-index: 20;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 1rem;
background-color: var( --color-base-200, #f3f4f6 );
border-bottom: 1px solid var( --color-base-300, #e5e7eb );
color: var( --color-base-content, #1f2937 );
font-size: 0.8125rem;
line-height: 1.4;

/* The ribbon is editor chrome that happens to live in the canvas —
selecting its text on the way to selecting a block is never what
the author meant. */
user-select: none;
}

.ap-visual-editor__composed-ribbon-label {
font-weight: 500;
}

.ap-visual-editor__composed-ribbon-cta {
flex-shrink: 0;
padding: 0.25rem 0.625rem;
border: 1px solid var( --color-base-300, #e5e7eb );
border-radius: 0.25rem;
background-color: var( --color-base-100, #ffffff );
color: var( --color-primary, #2563eb );
font-weight: 600;
text-decoration: none;
white-space: nowrap;
}

.ap-visual-editor__composed-ribbon-cta:hover,
.ap-visual-editor__composed-ribbon-cta:focus-visible {
text-decoration: underline;
}

.ap-visual-editor__composed-ribbon-cta:focus-visible {
outline: var( --wp-admin-border-width-focus, 2px ) solid
var( --color-primary, #2563eb );
outline-offset: 1px;
}
Loading