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
13 changes: 13 additions & 0 deletions content.js
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,19 @@
return true;
}

if (msg.type === 'RESOLVE_TEMPLATE_EDIT_URL') {
// Template-backed views (blog index, archives) on block themes resolve
// to a site-editor deep link. Needs an authenticated REST round-trip
// (/themes + /templates are private), so the popup passes the nonce it
// already resolves for the site-info/user endpoints.
const nonce = msg.nonce || null;
globalThis.WPRest
.resolveTemplateEditUrlAsync({ ctx: detection.context, origin: location.origin, nonce })
.then((res) => sendResponse(res || { url: null, isBlockTheme: null }))
.catch(() => sendResponse({ url: null, isBlockTheme: null }));
return true;
}

if (msg.type === 'GET_SITE_INFO') {
// Runs from the page context, so cookies flow automatically. The popup
// pre-reads window.wpApiSettings.nonce via chrome.scripting (MAIN world,
Expand Down
2 changes: 1 addition & 1 deletion dist/popup.js

Large diffs are not rendered by default.

122 changes: 122 additions & 0 deletions lib/rest.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,122 @@
return null;
}

// --- Template-backed views (block themes) -------------------------------

/**
* Page types that are rendered from a block-theme template/template-part
* rather than a single editable post: the blog index and archives. These
* have no post.php / term.php destination — they resolve to a site-editor
* deep link instead. Category/tag (`pageType === 'term'`) and author
* archives are intentionally excluded: they have their own editable
* record (the term / user) and resolve via the sync/REST paths above.
*/
function isTemplateBackedPage(ctx) {
return ctx.pageType === 'home' || ctx.pageType === 'archive';
}

/**
* Ordered template-slug candidates for a template-backed view, following
* WordPress's template hierarchy from most to least specific. The caller
* picks the first candidate that the active theme actually registers.
*
* home → home, index (blog posts index)
* archive → archive-{postType}?, archive, index
*
* A static front page or posts page is a real Page (pageType 'single')
* and never reaches here — it resolves to post.php upstream.
*/
function templateCandidates(ctx) {
if (ctx.pageType === 'home') {
return ['home', 'index'];
}
if (ctx.pageType === 'archive') {
const candidates = [];
if (ctx.postType) candidates.push(`archive-${ctx.postType}`);
candidates.push('archive', 'index');
return candidates;
}
return [];
}

/**
* Given the registered-template list from /wp/v2/templates, returns the
* most specific template matching the current view, or null. Each template
* object carries an `id` of the form `{stylesheet}//{slug}`, which is
* exactly what the site editor's `postId` expects.
*/
function pickTemplate(ctx, templates) {
if (!Array.isArray(templates)) return null;
const bySlug = new Map();
for (const t of templates) {
if (t && typeof t.slug === 'string' && t.id) bySlug.set(t.slug, t);
}
for (const slug of templateCandidates(ctx)) {
if (bySlug.has(slug)) return bySlug.get(slug);
}
return null;
}

/**
* Builds the site-editor deep link for a resolved template. `canvas=edit`
* opens straight into edit mode rather than the template's preview screen.
* The template `id` is already `{stylesheet}//{slug}`; encode it so the
* `//` survives as the postId value.
*/
function buildSiteEditorUrl(origin, template) {
if (!template || !template.id) return null;
const postId = encodeURIComponent(template.id);
return `${origin}/wp-admin/site-editor.php?postType=wp_template&postId=${postId}&canvas=edit`;
}

/**
* Lists the active theme's registered templates. Private endpoint —
* requires edit_theme_options (admins) and a valid X-WP-Nonce. Returns
* an array (possibly empty) or null on failure / insufficient caps.
*/
async function fetchTemplates({ restApiRoot, origin, nonce, fetchImpl = fetch }) {
const root = normalizeRoot(restApiRoot, origin);
try {
const res = await fetchImpl(`${root}wp/v2/templates`, {
credentials: 'include',
headers: nonce ? { 'X-WP-Nonce': nonce } : undefined,
});
if (!res.ok) return null;
const data = await res.json();
return Array.isArray(data) ? data : null;
} catch (_) {
return null;
}
}

/**
* Resolves a template-backed view to a site-editor edit URL. Returns
* `{ url, isBlockTheme }` so the popup can label the disabled state
* honestly:
*
* - isBlockTheme false → classic theme; templates are PHP files, no URL.
* - isBlockTheme true, url null → block theme but no matching template.
* - isBlockTheme null → couldn't determine (not an admin, REST off).
*
* Reads the active theme's `is_block_theme` flag first (cheap gate) and
* only lists templates when it's a block theme.
*/
async function resolveTemplateEditUrlAsync({ ctx, origin, nonce, fetchImpl = fetch }) {
if (!isTemplateBackedPage(ctx)) return { url: null, isBlockTheme: null };

const theme = await fetchActiveTheme({
restApiRoot: ctx.restApiRoot, origin, nonce, fetchImpl,
});
const isBlockTheme = theme ? !!theme.is_block_theme : null;
if (isBlockTheme !== true) return { url: null, isBlockTheme };

const templates = await fetchTemplates({
restApiRoot: ctx.restApiRoot, origin, nonce, fetchImpl,
});
const url = buildSiteEditorUrl(origin, pickTemplate(ctx, templates));
return { url: url || null, isBlockTheme: true };
}

/**
* Sync-only resolution — no network. Returns the best admin URL given
* whatever IDs we already have in context, or null.
Expand Down Expand Up @@ -341,6 +457,12 @@
resolveEditUrlSync,
resolveEditUrlAsync,
canResolveViaRest,
isTemplateBackedPage,
templateCandidates,
pickTemplate,
buildSiteEditorUrl,
fetchTemplates,
resolveTemplateEditUrlAsync,
fetchSiteInfo,
fetchActiveTheme,
fetchPluginsDetail,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,19 @@
return true;
}

if (msg.type === 'RESOLVE_TEMPLATE_EDIT_URL') {
// Template-backed views (blog index, archives) on block themes resolve
// to a site-editor deep link. Needs an authenticated REST round-trip
// (/themes + /templates are private), so the popup passes the nonce it
// already resolves for the site-info/user endpoints.
const nonce = msg.nonce || null;
globalThis.WPRest
.resolveTemplateEditUrlAsync({ ctx: detection.context, origin: location.origin, nonce })
.then((res) => sendResponse(res || { url: null, isBlockTheme: null }))
.catch(() => sendResponse({ url: null, isBlockTheme: null }));
return true;
}

if (msg.type === 'GET_SITE_INFO') {
// Runs from the page context, so cookies flow automatically. The popup
// pre-reads window.wpApiSettings.nonce via chrome.scripting (MAIN world,
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,122 @@
return null;
}

// --- Template-backed views (block themes) -------------------------------

/**
* Page types that are rendered from a block-theme template/template-part
* rather than a single editable post: the blog index and archives. These
* have no post.php / term.php destination — they resolve to a site-editor
* deep link instead. Category/tag (`pageType === 'term'`) and author
* archives are intentionally excluded: they have their own editable
* record (the term / user) and resolve via the sync/REST paths above.
*/
function isTemplateBackedPage(ctx) {
return ctx.pageType === 'home' || ctx.pageType === 'archive';
}

/**
* Ordered template-slug candidates for a template-backed view, following
* WordPress's template hierarchy from most to least specific. The caller
* picks the first candidate that the active theme actually registers.
*
* home → home, index (blog posts index)
* archive → archive-{postType}?, archive, index
*
* A static front page or posts page is a real Page (pageType 'single')
* and never reaches here — it resolves to post.php upstream.
*/
function templateCandidates(ctx) {
if (ctx.pageType === 'home') {
return ['home', 'index'];
}
if (ctx.pageType === 'archive') {
const candidates = [];
if (ctx.postType) candidates.push(`archive-${ctx.postType}`);
candidates.push('archive', 'index');
return candidates;
}
return [];
}

/**
* Given the registered-template list from /wp/v2/templates, returns the
* most specific template matching the current view, or null. Each template
* object carries an `id` of the form `{stylesheet}//{slug}`, which is
* exactly what the site editor's `postId` expects.
*/
function pickTemplate(ctx, templates) {
if (!Array.isArray(templates)) return null;
const bySlug = new Map();
for (const t of templates) {
if (t && typeof t.slug === 'string' && t.id) bySlug.set(t.slug, t);
}
for (const slug of templateCandidates(ctx)) {
if (bySlug.has(slug)) return bySlug.get(slug);
}
return null;
}

/**
* Builds the site-editor deep link for a resolved template. `canvas=edit`
* opens straight into edit mode rather than the template's preview screen.
* The template `id` is already `{stylesheet}//{slug}`; encode it so the
* `//` survives as the postId value.
*/
function buildSiteEditorUrl(origin, template) {
if (!template || !template.id) return null;
const postId = encodeURIComponent(template.id);
return `${origin}/wp-admin/site-editor.php?postType=wp_template&postId=${postId}&canvas=edit`;
}

/**
* Lists the active theme's registered templates. Private endpoint —
* requires edit_theme_options (admins) and a valid X-WP-Nonce. Returns
* an array (possibly empty) or null on failure / insufficient caps.
*/
async function fetchTemplates({ restApiRoot, origin, nonce, fetchImpl = fetch }) {
const root = normalizeRoot(restApiRoot, origin);
try {
const res = await fetchImpl(`${root}wp/v2/templates`, {
credentials: 'include',
headers: nonce ? { 'X-WP-Nonce': nonce } : undefined,
});
if (!res.ok) return null;
const data = await res.json();
return Array.isArray(data) ? data : null;
} catch (_) {
return null;
}
}

/**
* Resolves a template-backed view to a site-editor edit URL. Returns
* `{ url, isBlockTheme }` so the popup can label the disabled state
* honestly:
*
* - isBlockTheme false → classic theme; templates are PHP files, no URL.
* - isBlockTheme true, url null → block theme but no matching template.
* - isBlockTheme null → couldn't determine (not an admin, REST off).
*
* Reads the active theme's `is_block_theme` flag first (cheap gate) and
* only lists templates when it's a block theme.
*/
async function resolveTemplateEditUrlAsync({ ctx, origin, nonce, fetchImpl = fetch }) {
if (!isTemplateBackedPage(ctx)) return { url: null, isBlockTheme: null };

const theme = await fetchActiveTheme({
restApiRoot: ctx.restApiRoot, origin, nonce, fetchImpl,
});
const isBlockTheme = theme ? !!theme.is_block_theme : null;
if (isBlockTheme !== true) return { url: null, isBlockTheme };

const templates = await fetchTemplates({
restApiRoot: ctx.restApiRoot, origin, nonce, fetchImpl,
});
const url = buildSiteEditorUrl(origin, pickTemplate(ctx, templates));
return { url: url || null, isBlockTheme: true };
}

/**
* Sync-only resolution — no network. Returns the best admin URL given
* whatever IDs we already have in context, or null.
Expand Down Expand Up @@ -341,6 +457,12 @@
resolveEditUrlSync,
resolveEditUrlAsync,
canResolveViaRest,
isTemplateBackedPage,
templateCandidates,
pickTemplate,
buildSiteEditorUrl,
fetchTemplates,
resolveTemplateEditUrlAsync,
fetchSiteInfo,
fetchActiveTheme,
fetchPluginsDetail,
Expand Down
38 changes: 30 additions & 8 deletions src/popup/components/DetectedView.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { DevTools } from './DevTools';
import { NewContent } from './NewContent';
import { SiteInfoPanel } from './SiteInfoPanel';
import { usePrefs } from '../hooks/usePrefs';
import { runAction, applyAdminBarPref, requestRestEditUrl } from '../lib/actions';
import { runAction, applyAdminBarPref, requestRestEditUrl, requestTemplateEditUrl } from '../lib/actions';
import { editLabel, editDisabledLabel, postTypeLabel } from '../lib/labels';

export function DetectedView({ result, host }) {
Expand Down Expand Up @@ -134,7 +134,7 @@ function WpAdminActions({ ctx, origin, url }) {

function FrontendLoggedInActions({ ctx, origin, url }) {
const [prefs, savePref] = usePrefs(origin);
const { editUrl, resolving } = useEditUrlResolution(ctx, origin);
const { editUrl, resolving, isBlockTheme } = useEditUrlResolution(ctx, origin);

const isMac = typeof navigator !== 'undefined' && navigator.platform?.startsWith('Mac');
const shortcutHint = isMac ? 'Alt⇧E' : 'Alt+Shift+E';
Expand All @@ -150,7 +150,7 @@ function FrontendLoggedInActions({ ctx, origin, url }) {
? editLabel(ctx, true)
: resolving
? editLabel(ctx, true)
: editDisabledLabel(ctx);
: editDisabledLabel(ctx, { isBlockTheme });

return (
<>
Expand Down Expand Up @@ -223,6 +223,12 @@ function AdminBarSection({ ctx, origin, prefs, onToggle }) {
* Two-tier resolution: synchronous first (instant), then REST if the ctx has
* slugs we can look up. While REST is in flight we expose `resolving: true`
* so the UI can show a loading state.
*
* Three async shapes feed this:
* - term/author slug → ID lookup (requestRestEditUrl)
* - template-backed views (blog index, archives) → block-theme site-editor
* deep link (requestTemplateEditUrl), which also reports `isBlockTheme`
* so a disabled row can explain itself honestly.
*/
function useEditUrlResolution(ctx, origin) {
const syncUrl = useMemo(() => {
Expand All @@ -235,26 +241,42 @@ function useEditUrlResolution(ctx, origin) {
return wpRest ? wpRest.canResolveViaRest(ctx) : false;
}, [ctx]);

const isTemplateBacked = useMemo(() => {
const wpRest = typeof window !== 'undefined' ? window.WPRest : null;
return wpRest ? wpRest.isTemplateBackedPage(ctx) : false;
}, [ctx]);

const [asyncUrl, setAsyncUrl] = useState(null);
const [asyncAttempted, setAsyncAttempted] = useState(false);
const needsAsync = !syncUrl && canResolveAsync;
const [isBlockTheme, setIsBlockTheme] = useState(null);

const needsTemplateAsync = !syncUrl && !canResolveAsync && isTemplateBacked;
const needsAsync = !syncUrl && (canResolveAsync || needsTemplateAsync);

useEffect(() => {
if (!needsAsync || asyncAttempted) return;
let cancelled = false;
(async () => {
const resolved = await requestRestEditUrl();
if (cancelled) return;
setAsyncUrl(resolved);
if (needsTemplateAsync) {
const { url, isBlockTheme: themeFlag } = await requestTemplateEditUrl();
if (cancelled) return;
setAsyncUrl(url || null);
setIsBlockTheme(themeFlag ?? null);
} else {
const resolved = await requestRestEditUrl();
if (cancelled) return;
setAsyncUrl(resolved);
}
setAsyncAttempted(true);
})();
return () => {
cancelled = true;
};
}, [needsAsync, asyncAttempted]);
}, [needsAsync, needsTemplateAsync, asyncAttempted]);

return {
editUrl: syncUrl || asyncUrl || null,
resolving: needsAsync && !asyncAttempted,
isBlockTheme,
};
}
Loading