const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-sitecore-contextid": contextId,
// sc_editMode instructs Edge to return draft content and field metadata.
// sc_layoutKind selects between final and shared layout variants.
sc_editMode: editMode,
sc_layoutKind: layoutKind,
},
body: JSON.stringify({
query,
variables: { siteName: site, itemId, language, version },
}),
});
const result = await response.json();
if (result.errors) {
throw new Error(JSON.stringify(result.errors));
}
const item = result?.data?.item;
if (!item) return null; // item not found
// Parse the rendered layout data (same structure as the layout query).
const rendered = item.rendered;
const layoutData =
typeof rendered === "string" ? JSON.parse(rendered) : rendered;
// Collect the first page of dictionary entries.
// TODO: For sites with large dictionaries, implement pagination using
// result.data.site.siteInfo.dictionary.pageInfo.hasNext and endCursor.
const dictionary = {};
for (const { key, value } of result?.data?.site?.siteInfo?.dictionary
?.results ?? ) {
dictionarykey = value;
}
// editingData holds the layout data and site dictionary returned by the
// editing GraphQL query.
type editingData struct {
LayoutData mapstringany
Dictionary mapstringstring
}
// fetchEditingData retrieves editing layout data and the site dictionary for
// the given item using the Sitecore Edge GraphQL editing query.
//
// - itemId - the Sitecore item GUID received as sc_itemid
// - editMode - "true" to fetch draft content (edit canvas),
// "false" for preview
// - layoutKind - "final" or "shared"; defaults to "final"
func fetchEditingData(site, itemId, language, version, layoutKind, editMode string) (*editingData, error) {
endpoint := os.Getenv("SITECORE_EDGE_PLATFORM_URL")
contextID := os.Getenv("SITECORE_EDGE_CONTEXT_ID")
// sc_editMode instructs Edge to return draft content and field metadata.
// sc_layoutKind selects between final and shared layout variants.
req.Header.Set("sc_editMode", editMode) // "true" or "false"
req.Header.Set("sc_layoutKind", layoutKind) // "final" or "shared"
if err := json.NewDecoder(resp.Body).Decode(&gql); err != nil {
return nil, fmt.Errorf("decode editing response: %w", err)
}
if len(gql.Errors) > 0 {
return nil, fmt.Errorf("graphql editing: %s", gql.Errors0.Message)
}
if gql.Data.Item == nil {
return nil, nil // item not found
}
// Parse the rendered layout data (same structure as the layout query).
raw := gql.Data.Item.Rendered
var layoutData mapstringany
if json.Unmarshal(raw, &layoutData) != nil {
var s string
if json.Unmarshal(raw, &s) != nil {
return nil, fmt.Errorf("cannot parse rendered field")
}
if err := json.Unmarshal(byte(s), &layoutData); err != nil {
return nil, fmt.Errorf("cannot parse rendered string: %w", err)
}
}
// Collect the first page of dictionary entries.
// TODO: For sites with large dictionaries, implement pagination using
// gql.Data.Site.SiteInfo.Dictionary.PageInfo.HasNext and EndCursor.
dictionary := make(mapstringstring)
if gql.Data.Site != nil &&
gql.Data.Site.SiteInfo != nil &&
gql.Data.Site.SiteInfo.Dictionary != nil {
for _, entry := range gql.Data.Site.SiteInfo.Dictionary.Results {
dictionaryentry.Key = entry.Value
}
}
// metaField wraps rendered field HTML with field-chrome metadata code blocks.
// The Page builder reads these to identify which fields a content author can
// edit inline.
//
// fieldData must be the full field object (the object that contains both
// "value" and, in edit mode, "metadata"). If no "metadata" key is present,
// the rendered HTML is returned unchanged.
export function metaField(fieldData, rendered) {
const metadata = fieldData?.metadata;
if (!metadata) return rendered;
return (
`${JSON.stringify(metadata)}` +
rendered +
``
);
}
// General Link field, with jsonValue as a fallback for direct item queries.
const linkField = fields?.TargetUrl;
const link = linkField?.value ?? linkField?.jsonValue?.value;
const content = link?.href
? `<a href="${escHtml(link.href)}"${link.target ? ` target="${escHtml(link.target)}"` : ""}>${imgTag}`
: imgTag;
// Plain text caption field.
const caption = fields?.ImageCaption?.value;
const html = caption
? `
function renderPromoEditing(fields) {
for (const name of "PromoText", "PromoText2", "PromoText3") {
const f = fields?.name;
if (f?.value) return metaField(f, f.value);
}
return "";
}
function renderRichTextEditing(fields) {
const f = fields?.Text;
if (!f?.value) return metaField(f, "");
return metaField(f, `
${f.value}
`);
}
function renderTitleEditing(fields, params) {
const f = fields?.heading;
if (!f?.value) return metaField(f, "");
const classAttr = params?.cssClass
? ` class="${escHtml(params.cssClass)}"`
: "";
return metaField(f, `<h1${classAttr}>${escHtml(f.value)}`);
}
// editingPassthroughComponents is the set of structural wrapper components
// that have no fields of their own and only forward nested placeholders.
// Components registered here in componentMap use a passthrough renderer
// that calls Placeholder. In editing mode that would bypass
// renderPlaceholderEditing, stripping all rendering and field chromes from
// every component nested inside the wrapper.
//
// Add any new passthrough/container components here alongside adding them
// to componentMap.
export const editingPassthroughComponents = new Set(
"Container",
"ContainerFullBleed",
"PartialDesignDynamicPlaceholder",
);
// editingComponentMap maps Sitecore component names to editing-aware renderer
// functions. Each renderer wraps its field output with metaField so that the
// Page builder can activate inline field editing on click.
//
// ColumnSplitter is handled separately in renderComponentEditing because it
// renders nested placeholders and needs the component's own uid.
const editingComponentMap = {
Image: renderImageEditing,
Promo: renderPromoEditing,
RichText: renderRichTextEditing,
Title: renderTitleEditing,
};
// renderColumnSplitterEditing mirrors ColumnSplitter.astro but calls
// renderPlaceholderEditing so that nested column placeholders have metadata
// code blocks and accept component drops in the Page builder canvas.
function renderColumnSplitterEditing(fields, params, placeholders, uid) {
const enabled = params?.EnabledPlaceholders
? params.EnabledPlaceholders.split(",").map((s) => s.trim())
: null;
const cols = Object.entries(placeholders)
.map((key, items) => {
const m = key.match(/^column-(\d+)-/);
if (!m) return null;
const index = Number(m1);
if (enabled && !enabled.includes(String(index))) return null;
return { key, items, index };
})
.filter(Boolean)
.sort((a, b) => a.index - b.index);
let html = `<div data-component="ColumnSplitter" class="${escHtml(params?.GridParameters ?? "")}">`;
for (const col of cols) {
const colClass = params?.`ColumnWidth${col.index}` ?? "";
html += `
`;
html += renderPlaceholderEditing(col.key, col.items, uid);
html += "
";
}
html += "";
return html;
}
// renderPlaceholderEditing renders a placeholder wrapped in metadata code
// blocks. parentUID is the uid of the route or component that owns this
// placeholder.
export function renderPlaceholderEditing(
name,
items,
parentUID = DEFAULT_UID,
) {
const phId = `${name}_${parentUID}`;
return (
metaOpen("placeholder", phId) +
`
// renderComponentEditing renders a single component wrapped in metadata code
// blocks. It delegates field rendering to editingComponentMap (which adds
// field-chrome wrapping), with a fallback to renderMissingComponentEditing
// for any components not listed in editingComponentMap.
export function renderComponentEditing(comp) {
const componentName = comp?.componentName ?? "";
const uid = comp?.uid || DEFAULT_UID;
const fields = comp?.fields ?? {};
const params = comp?.params ?? {};
const rawPH = comp?.placeholders ?? {};
const placeholders = Object.fromEntries(
Object.entries(rawPH).map((k, v) => k, Array.isArray(v) ? v : ),
);
let inner;
if (componentName === "ColumnSplitter") {
// ColumnSplitter renders nested placeholders; it needs the component uid
// to build correct placeholder IDs for drag-and-drop in the canvas.
inner = renderColumnSplitterEditing(fields, params, placeholders, uid);
} else if (editingPassthroughComponents.has(componentName)) {
// Passthrough components have no fields of their own - they only forward
// nested placeholders. Delegating to the normal component map would call
// Placeholder instead of renderPlaceholderEditing, stripping all chromes
// from every component nested inside the wrapper.
inner = Object.entries(placeholders)
.map((key, items) => renderPlaceholderEditing(key, items, uid))
.join("");
} else if (editingComponentMapcomponentName) {
inner = editingComponentMapcomponentName(fields, params, placeholders);
} else {
inner = renderMissingComponentEditing(componentName, placeholders, uid);
}