Imported from HappydanceDev/TestClient3_v17 (
.claude/skills/widgetise-block/SKILL.md). Install upstream withnpx skills add HappydanceDev/TestClient3_v17 --skill widgetise-block. Copyright stays with the author.
Convert a Block into a reusable Widget
In this codebase a block is an element type used once, inline in a page's Block List. A widget is the reusable equivalent: a real document type stored as a node under the Widgets folder, selected into a page via a small widget-picker block.
Turning a block into a widget means creating five new artefacts and making two wiring edits. This skill does that mechanically and consistently, because the fiddly parts — fresh GUIDs, the IsElement/Variations flip, the composition swap, and the tab re-homing — are exactly where hand-porting goes wrong.
Read these before generating anything
These files on disk are the source of truth. Read the matching set for the closest existing widget and copy its shape rather than inventing one:
apps/umbraco/Ph.Umbraco.Web/uSync/v17/ContentTypes/widgetfaq.config— simplest widget doc type (payload + intro only).apps/umbraco/Ph.Umbraco.Web/uSync/v17/ContentTypes/widgeticons.config— richer widget with an extracontent/layoutgroup and non-variant layout props.apps/umbraco/Ph.Umbraco.Web/uSync/v17/ContentTypes/blockfaqwidget.config— the picker block element type.apps/umbraco/Ph.Umbraco.Web/uSync/v17/ContentTypes/widgetfolder.config— the allowed-children<Structure>list.apps/umbraco/Ph.Umbraco.Web/uSync/v17/DataTypes/IconsWidgetPicker.config— the picker data type.apps/umbraco/Ph.Modules/Blocks/Widgets/FaqWidget.cs+WidgetBase.cs— the widget model and what the base already provides.apps/umbraco/Ph.Modules/Blocks/WidgetBlocks/FaqWidgetBlock.cs+WIdgetBlockBase.cs— the picker block model.
Templates in templates/ alongside this skill carry the exact file shapes; the reference configs above win if they ever disagree.
Input
The source block alias, e.g. blockFAQ, blockTestimonials. Accept it from /widgetise-block {alias} or the user's phrasing. If missing, ask which block.
Resolve the argument before doing anything else
Do not assume the argument is a well-formed block{Name} alias. Users type partial, reordered, or lowercase names (featureBlock, faq, testimonials), and this codebase contains near-miss aliases that differ only in prefix — blockFeatures (a page-level block) versus itemFeatureBlock (a card inside it). Picking the wrong one produces a widget that cannot be placed on a page, and you will not find out until after the uSync import.
Resolve it first:
cd apps/umbraco/Ph.Umbraco.Web/uSync/v17/ContentTypes
grep -ho 'Alias="[^"]*"' *.config | sed -E 's/Alias="//; s/"//' | grep -i "{stem}"
Search on the distinctive stem, not the raw argument. Strip the generic words block, item, and widget from what the user typed and grep for what remains — featureBlock → feature, faqWidget → faq. Grepping the literal argument finds nothing when the user has reordered the parts, which is exactly the case that needs disambiguating. If the stem returns nothing, try a shorter prefix before concluding the type does not exist.
Then classify every candidate by reading its config:
| Signal | Page-level block (widgetisable) | Child element (not widgetisable) |
|---|---|---|
<Folder> |
Blocks, Blocks/* |
Elements, Elements/* |
| Alias prefix | block… |
item… |
| Referenced by | a BlockList* data type used by a page |
another block's Block List data type (e.g. FeatureItems.config) |
| Compositions | often blockHeadingIntro |
often blockHeading or none |
The <Folder> value is the most reliable single check.
Then:
- Exactly one candidate, in
Blocks→ proceed. - Exactly one candidate, in
Elements→ stop. Explain it is a child element, name the container block that holds it (find via the data type that lists its Key), and offer to widgetise the container instead. - More than one candidate → ask the user which they meant before generating anything. Present each with its alias,
<Name>,<Folder>, and one line on what it is. Recommend theBlocksone. Do not guess from a branch name or from which alias looks closest —featureBlockis a closer string match toitemFeatureBlockthan to the correct answer,blockFeatures.
Only once a single content type is confirmed, derive the names from its real alias (not the user's argument) by stripping the leading block:
| Thing | Pattern | Example (blockFAQ) |
|---|---|---|
| Widget doc type alias | widget{Name} |
widgetFAQ |
| Widget doc type name | Widget {Name} |
Widget FAQ |
| Picker data type alias/name | {Name} Widget Picker |
FAQ Widget Picker |
| Picker block alias | block{Name}Widget |
blockFAQWidget |
| Picker block name | {Name} Widget |
FAQ Widget |
| Widget model | Widgets/{Pascal}Widget.cs |
FaqWidget.cs |
| Picker block model | WidgetBlocks/{Pascal}WidgetBlock.cs |
FaqWidgetBlock.cs |
{Name} keeps the source block's casing (FAQ stays FAQ). {Pascal} is the C# class-name form (FAQ → Faq), matching the existing FaqWidget / FaqBlock convention.
Preconditions — verify, then stop and report if unmet
-
The argument has been resolved to exactly one content type (see Resolve the argument above). Never skip this because the argument "looks like" an alias.
-
The source content type
apps/umbraco/Ph.Umbraco.Web/uSync/v17/ContentTypes/{alias lowercased}.configexists and has<IsElement>true</IsElement>. -
Its
<Folder>isBlocks(or aBlocks/*subfolder) — notElements. AnElementstype is a child element; see the resolution table. -
No
widget{Name}content type already exists. If it does, this block has already been widgetised — report and stop. -
No C# class named
{Pascal}Widgetor{Pascal}WidgetBlockalready exists underPh.Modules/Blocks/. Names collide more often than aliases do, because{Pascal}collapses casing (blockFAQ→Faq). Check with:grep -rn "class {Pascal}Widget\b\|class {Pascal}WidgetBlock\b" apps/umbraco/Ph.Modules/Blocks/Note the source block's own model may already be named
{Pascal}Block(e.g.FeaturesBlock) — that is fine and expected, it is only{Pascal}Widget/{Pascal}WidgetBlockthat must be free. -
Sanity-check that a widget makes sense. Blocks whose content is derived from the page they sit on should not become widgets, because a shared node has no page context. Stop and ask if the source block:
- pulls related content from the current page (
blockRelatedTeams,blockRelatedLocations), - is driven by the current node's position or category (
blockDynamicJobs,blockCategoryJobs,blockPagesList), - or is a layout/structural primitive rather than content (
blockColumnLayout,blockBackgroundColor,blockInvertText).
Say plainly why, and let the user override — if they confirm, proceed.
- pulls related content from the current page (
Step 1 — Widget document type
Create apps/umbraco/Ph.Umbraco.Web/uSync/v17/ContentTypes/widget{name lowercased}.config from the source block config, applying every transform below. These are the whole point of the skill; missing one produces a widget that looks right and misbehaves at runtime.
- New
Keyon the<ContentType>— a fresh GUID. Never reuse the block's. <Alias>→widget{Name};<Name>→Widget {Name}.<IsElement>→false. A widget is a real node, not an element.<Variations>→Culture. Widgets are translated nodes.<Folder>→Widgets.<Icon>→ keep the source icon glyph but swap the colour suffix tocolor-pink, the established widget colour (e.g.icon-chat-active color-deep-purple→icon-chat-active color-pink).<Description>→ reword to reflect reuse, e.g.A reusable FAQ widget.- Compositions — this is the critical swap:
- Remove
blockHeadingIntro(f754070e-b497-4b87-9ead-4af29c35a833) —widgetContentsupplies heading/intro instead. Removing it is what forces step 1b. - Add
widgetContent(59b68825-efb7-49a1-a9f7-8ec0ed30fc4d) anddisableDeletion(fc68738b-308e-44f8-9d1d-8fdd9979f028). - Keep presentational compositions the block already had —
blockContentAlignment(d669a527-b8fa-4a73-b9e6-eac187bb178b),blockBackgroundColor(92d7afd2-ff36-414a-899f-b21505f8a9db),blockColumnLayout(e5746a8e-f587-45d5-929e-83f40a5cd21d),contentAdviser(0195179f-d97e-4917-b0f1-035a97b6c8a8). - Keep
<Compositions>children sorted by alias — uSync compares element order, and unsorted entries cause a permanent report diff.
- Remove
- Every
<GenericProperty>: fresh<Key>GUID, and re-home<Tab Alias="content">Content</Tab>→<Tab Alias="content/widgetContent">Widget Content</Tab>. - Property
<Variations>→Culturefor content-bearing properties (text, RTE, Block Lists, media). LeaveNothingfor presentational toggles that shouldn't vary per culture — followwidgeticons.config, whereicons/introTextareCulturebutlayout/iconSize/invertCardsareNothing. - Mandatory payload properties: consider relaxing
<Mandatory>tofalse.widgetFAQdoes this (the block'squestionsis mandatory, the widget's is not) so an editor can create the node before filling it. Preferfalseunless the user asks otherwise; if you keeptrue, keep the<MandatoryMessage>too. - Tabs: emit a
Contenttab (Type=Tab) plus aWidget Contentgroup (Alias=content/widgetContent,Type=Group), each with a freshKey. Preserve any additional groups the source had (e.g.content/layout), re-keyed.
Step 1b — carry over what blockHeadingIntro provided
widgetContent provides anchorID, darkMode, eyebrowText, heading, hideHeading — but not introText. If the source block had blockHeadingIntro and its model exposes intro text, add an explicit introText property to the widget (as both widgetFAQ and widgetIcons do):
<GenericProperty>
<Key>{fresh-guid}</Key>
<Name>Intro Text</Name>
<Alias>introText</Alias>
<Definition>9a311f43-2565-4787-b8ac-ac9fed7b7bc0</Definition>
<Type>Umbraco.RichText</Type>
<Mandatory>false</Mandatory>
<Description><![CDATA[Optional intro]]></Description>
<SortOrder>1</SortOrder>
<Tab Alias="content/widgetContent">Widget Content</Tab>
<Variations>Culture</Variations>
<LabelOnTop>false</LabelOnTop>
</GenericProperty>
Reuse whichever RTE <Definition> the source block used if it had its own intro; the GUID above is widgetFAQ's. Omit this property entirely if the block genuinely has no intro.
Step 2 — Picker data type
Create apps/umbraco/Ph.Umbraco.Web/uSync/v17/DataTypes/{Name}WidgetPicker.config — fresh Key, filter set to the step 1 widget's Key, and startNode.id the Widgets folder node d5fb18a6-8017-41ad-a12b-755c7df8abc2. Folder must be Content+Pickers (the + is the encoded space; see the uSync note below). Copy templates/WidgetPicker.config.template.
Do not copy the umbMigrationV14 key — it is a migration artefact of the older data types, not part of the shape.
Step 3 — Picker block element type
Create apps/umbraco/Ph.Umbraco.Web/uSync/v17/ContentTypes/block{name lowercased}widget.config — fresh Key, IsElement=true, Variations=Nothing, no compositions, Folder = Blocks, icon {source glyph} color-pink. One Umbraco.MultiNodeTreePicker property aliased widget, whose <Definition> is the step 2 data type Key, on a Widget Content group. Copy templates/WidgetPickerBlock.config.template.
Match blockfaqwidget.config exactly, including its <Tab Alias="widgetContent1"> group alias.
Step 4 — Widget C# model
Create apps/umbraco/Ph.Modules/Blocks/Widgets/{Pascal}Widget.cs from templates/Widget.cs.template:
-
[PublishedModel("widget{Name}")],sealed, extendsWidgetBase. -
Add only the payload properties.
WidgetBasealready gives youAnchorId,HideHeading,DarkMode,EyebrowText,Heading,IntroText,ContentAlignment— re-declaring them is a bug, not duplication-for-clarity. -
Mirror the source block model's property bodies. Note the accessor difference: block models read through
Content.Value<T>(...)/this.ToElementsOf<T>("alias")(they wrap aBlockListItem), whereas widget models readthis.Value<T>(...)directly (they arePublishedContentModel). For a Block List payload the widget form isthis.Value<BlockListModel>("alias")?.ToElementsOf<T>() ?? [].An
IPublishedElementoverload ofToElementsOf<T>(alias)also exists and would compile, but every existing widget uses the explicitthis.Value<BlockListModel>(...)form — match the siblings. -
Carry over properties the block base provided but
WidgetBasedoes not. Diff the two hierarchies rather than assuming they match. Block models often extendIntroTextBlockBase/HeadingBlockBase, and blocks frequently add their ownColumnLayoutandBackgroundColor:public int ColumnLayout => this.Value<int>(PhConstants.Content.ColumnLayout, defaultValue: 2, fallback: Fallback.ToDefaultValue); public string? BackgroundColor => this.Value<string>(PhConstants.Content.BackgroundColor);Preserve the source's
defaultValue/fallbackexactly — dropping them silently changes layout. Anything carried over this way needs its backing composition (blockColumnLayout,blockBackgroundColor) kept in step 1. -
Override
ContentAlignmentonly if the source block did something non-default. Watch the nullability difference:IntroTextBlockBase.ContentAlignmentisstring?,WidgetBase.ContentAlignmentis non-nullablestring.
Step 5 — Picker block C# model
Create apps/umbraco/Ph.Modules/Blocks/WidgetBlocks/{Pascal}WidgetBlock.cs from templates/WidgetBlock.cs.template. This is pure boilerplate — [PublishedModel("block{Name}Widget")], sealed, extends WidgetBlockBase<{Pascal}Widget>, implements IAnchorId, empty constructor body. WidgetBlockBase<T> already exposes Widget and suppresses the duplicate DarkMode.
Step 6 — Allow the widget under the Widget Folder
In widgetfolder.config, add to <Structure>:
<ContentType Key="{step-1-key}" SortOrder="{next}">widget{Name}</ContentType>
Without this the editor cannot create the node, and everything else silently appears to work.
Step 7 — Add the picker block to the Block List data types
Add the step 3 block's Key to the blocks array of each relevant Block List data type in uSync/v17/DataTypes/.
Derive the set; do not hardcode it. Find every Block List that offers the source block, then keep those that already carry widget pickers:
cd apps/umbraco/Ph.Umbraco.Web/uSync/v17/DataTypes
grep -l "{source-block-key}" *.config
grep -c "umbContentName: widget" BlockList*.config
There are more than three — BlockListLocationListing and BlockListTeamListing also carry widget pickers and are easy to miss.
Each entry follows the widget-block convention (note label, which differs from ordinary blocks):
{
"contentElementTypeKey": "{step-3-key}",
"label": "{umbContentName: widget}",
"editorSize": "medium",
"forceHideContentEditorInOverlay": false
}
Place it next to the other widget-picker entries rather than at the end of the array.
Validate every file you touched. The blocks array lives inside a <Config><![CDATA[…]]></Config> block, so a text edit can produce broken JSON that no XML check catches and that only surfaces as a backoffice error after import:
python - <<'PY'
import io, json, re, glob
for p in glob.glob("apps/umbraco/Ph.Umbraco.Web/uSync/v17/DataTypes/BlockList*.config"):
s = io.open(p, encoding='utf-8-sig', newline='').read()
m = re.search(r'<Config><!\[CDATA\[(.*?)\]\]></Config>', s, re.S)
try:
d = json.loads(m.group(1))
print(f"OK {p} blocks={len(d.get('blocks', []))}")
except Exception as e:
print(f"FAIL {p} {e}")
PY
Also confirm each new Key appears exactly once per file, and that no fresh GUID collides anywhere in the uSync tree.
After generating — tell the user to do this, don't do it yourself
State clearly that the configs are written to disk but not imported. Then give them, in order:
- Import in the backoffice (Settings → uSync → Import) to create the types.
- Export, then Report. This is not optional ceremony: uSync's exporter normalises element ordering and folder-name encoding, and skipping it leaves a permanent phantom diff where Report keeps showing the same changes forever. Import → Export → Report is the known fix.
- Create the first widget node under Widgets and check the picker block offers it.
- Verify the Content API output for a page using the new picker block.
Gotchas
- Never reuse a GUID. Every new
Key— content types, properties, tabs, data types — is fresh. Duplicated keys cause uSync to overwrite the original type. - Folder names encode spaces as
+(Content+Pickers, notContent Pickers). Getting this wrong is a known cause of the endless import/report loop. - CRLF line endings and the UTF-8 BOM. The existing
.configfiles and C# files both use them;.editorconfigis authoritative. Match the surrounding files. - Don't touch the source block. Widgetising adds a parallel path; the original block stays usable inline. Removing it is a separate, breaking decision.
- Don't run a build. Visual Studio owns the running site and building while it is open causes IIS file-lock failures and can trigger the VS 2026 launch regression. Let the user build.
IsElementcannot be changed after the fact on a type with content. If you get it wrong, the fix is to delete and recreate — so check before importing.