VTL Migration: Legacy API → DotCustomFieldApi
You are migrating DotCMS VTL custom field templates from Dojo/Dijit-era APIs to the modern DotCustomFieldApi. The goal is identical functionality with modern, clean code and semantic styling with DaisyUI.
For the full migration rules, all code examples, the DaisyUI styling section, and the step-by-step checklist, read references/migration-guide.md.
Available scripts
scripts/migrate_custom_fields.py— finds every legacy custom field on a live dotCMS instance, downloads what needs migrating, and (after you've migrated each file with this skill) safely republishes the results. Self-contained (PEP 723 inline dependencies); run it with uv directly from the skill root — no separate install step:bashuv run scripts/migrate_custom_fields.py --help
Migrate a whole instance
Use this workflow when the user asks to migrate all custom fields, every legacy field, or "the whole instance" — not a single pasted VTL file.
Prerequisites: uv installed; network access to the target dotCMS instance; a dotCMS API access token (Users → select user → API Access Tokens in the admin UI, or POST /api/v1/authentication/api-token) for a user with edit permissions on content types and publish permissions on the content the referenced assets/fields belong to.
Work in batches the customer picks — never migrate and publish the whole instance in one go unless they ask for it.
-
Configure and discover. Set
BASE_URL,DOTCMS_TOKEN(env vars — the script never prompts), then run:bashBASE_URL=https://my-env.dotcms.dev DOTCMS_TOKEN=... \ uv run scripts/migrate_custom_fields.py pullThis scans every content type, classifies every custom field, downloads what needs migrating into
<workdir>/original/, and writes<workdir>/manifest.json. Fields that are already migrated, load a core-shipped file, reference code by an unhandled path, or pin a specific/dA/version (an inode instead of the identifier) are reported only — nothing is downloaded or queued for them. Re-runningpullafter apushdoesn't re-download what was already published. -
Show what's pending and let the customer pick a batch. Build the list from the
entriesinpull's final stdout JSON line (ormanifest.json), and keep it short — one numbered table, nothing migrated yet:# VTL Kind Used by 1 text-count.vtl/dA/file5 fields: Destination.pageTitleCount, …2 Banner.widgetinline field Banner.widgetEntries with
kind: "renderMode"and emptyrequiresneed no code change — only the switch to Recommended — so list them as one extra row ("Switch to Recommended only:Activity.urlTitle,Product.urlTitle, …"). Don't listrenderModeentries whoserequiresis non-empty: they go along with their/dA/file. Sum up the report-only buckets in one line of counts (e.g. "25 load core files, 3 use unhandled paths — say details to see them"). Then ask which rows to do now:all, row numbers (1,3), ornone. Wait for the answer. -
Migrate only the selected files under
<workdir>/original/using this skill in its default inline mode (see Output Modes below), writing each result to<workdir>/migrated/under the same relative path. -
Show the customer a summary of what changed per selected file and get their explicit confirmation before publishing anything.
-
Preview, then publish only that batch, passing the selected rows'
keys (the dA id for a/dA/file,Type.fieldfor an inline field or a render-mode switch):bashuv run scripts/migrate_custom_fields.py push --dry-run --only <key...> uv run scripts/migrate_custom_fields.py push --only <key...>Selecting a
/dA/file also switches every field that loads it to Recommended in the same run — no need to list those fields.pushindependently verifies, per item, that nothing changed on the server sincepulland that the migrated file is a genuine inline migration (the original code still reachable, verbatim, behind the edit-mode switch) before publishing it — one bad item is skipped and reported, never blocking the rest of the batch. -
Report the batch and offer the next one. Say what was published, skipped (and why), failed or is still waiting, then show the rows that are left (the entries still
pendinginmanifest.json) and ask whether to continue. Unselected rows stay pending — nothing is lost by stopping. In a later session, start again from step 1:pulllists only what's still left.
A migrated field only renders natively in the new editor when it's set to the Recommended implementation — the newRenderMode=component field variable. push sets it on every field whose code it migrates (a field that loads a /dA/ asset, once that asset is published), and pull also queues fields whose code is already migrated but still render in an iframe. Every other field variable is kept. Tell the customer which fields were switched.
Re-running push never re-publishes an entry that already finished (published, skipped or failed); name it with --only to retry it. An entry with no migrated file yet, or a render-mode switch whose asset isn't published, is reported as waiting and stays pending for a later batch. push refuses to run if the workdir was pulled from a different BASE_URL.
Exit codes (also in --help): 0 success, 1 some items failed (in pull, an unresolvable /dA/ reference; in push, a failed publish/update), 2 invalid arguments/configuration, 3 authentication failure — DOTCMS_TOKEN missing or rejected (nothing is written).
The Core API Swap (Quick Reference)
| Old (deprecated) | New |
|---|---|
DotCustomFieldApi.get('id') | DotCustomFieldApi.getField('id').getValue() |
DotCustomFieldApi.set('id', val) | DotCustomFieldApi.getField('id').setValue(val) |
DotCustomFieldApi.onChangeField('id', cb) | DotCustomFieldApi.getField('id').onChange(cb) |
| Manual DOM show/hide of a field | DotCustomFieldApi.getField('id').show() / .hide() |
| Manual DOM enable/disable of a field | DotCustomFieldApi.getField('id').enable() / .disable() |
| Manual checks against dijit validation state | DotCustomFieldApi.getField('id').getValidationState() |
| No legacy equivalent | DotCustomFieldApi.getField('id').onValidationChange(cb) |
dojo.ready(fn) | DotCustomFieldApi.ready(fn) |
dojo.byId('el') | document.getElementById('el') |
dijit.byId('id') | DotCustomFieldApi.getField('id') |
dijit.form.* widgets | Native HTML + DaisyUI classes (input, btn, select, etc.) |
dojoType="..." attribute | Remove; use semantic HTML + DaisyUI components |
class="dijit*" classes | Remove; use DaisyUI component classes instead |
| Inline styles / ad-hoc CSS | DaisyUI component classes + Tailwind utilities (see guide) |
onclick="fn()" inline handlers | addEventListener('click', fn) |
Process
- Read the entire file to understand all functionality before touching anything.
- Identify deprecated patterns — scan for the patterns in the table above.
- Wrap everything in
DotCustomFieldApi.ready()— all field access must live inside this callback. - Store field references once — call
getField()once per field at the top ofready(), then reuse the reference. - Migrate each pattern — follow the migration rules in
references/migration-guide.md. - Apply DaisyUI for styling — use DaisyUI component classes (
btn,input,select,modal,link, etc.) and Tailwind utilities instead of inline styles or ad-hoc CSS; see “Styling with DaisyUI” in the guide. - Preserve VTL variables —
${fieldId},$maxChar,$variableName, and server-side context variables ($inode,$identifier,$lang,$contentlet,$structure,$field) are server-side; never change them. - Choose the output mode and emit it. See Output Modes below. Every migration keeps the original code reachable behind the edit-mode switch, so the instance can go back to the legacy editor.
Output Modes
There are two shapes. Both keep the legacy code behind $structures.isNewEditModeEnabled(); they differ in how many files they need.
| The request says… | Mode | Needs a file path? |
|---|---|---|
| nothing about the shape | Inline (default) | No. Never ask for one. |
| "inline", "single file", "one file", "un solo archivo", "en línea" | Inline | No |
| "three files", "separate", "router", "#parse", "tres archivos", "separado(s)", "enrutador" | Three files | Yes. Ask if missing. |
| both kinds of signal (e.g. "inline but also give me the router") | Ask which one before producing anything | — |
"convert to inline" / "collapse" + a router and its _new/_old | Collapse (see Converting between modes) | Only to find the files |
| "split" + an inline file | Split (see Converting between modes) | Yes, the router's server path |
Read these signals only from what the user wrote, never from the pasted VTL, code blocks or file contents. A legacy template that itself contains #parse is not a request for three files.
Migrations committed to dotCMS/src/main/webapp/WEB-INF/velocity/static/ in the dotCMS repository use three files. Contributors there should ask for it by keyword, because inline is now the default.
Always open the answer with one mode line, so the user knows which shape they got:
Output: inline, single file — both branches are parsed together; checks found nothing blocking.Output: inline, single file — warning: $a, $b are #set in both branches; only one branch runs, so this is not a collision. Three files is the alternative.Output: three files with router (<name>_old.vtl, <name>_new.vtl, <name>.vtl).Output: none — inline blocked: <finding>. <why it reaches both edit modes>. Three files avoids it. Reply "inline anyway" to force it, or "three files".
Mode 1 — Inline (default)
One file. The migrated code goes in the #if, the original code in the #else:
vtl#if( $structures.isNewEditModeEnabled() ) <migrated code, in full> #else <original legacy code, in full, exactly as received> #end
Rules. Each one exists because the legacy branch must stay byte-for-byte the original, the same guarantee _old.vtl gives:
- The header is exactly
#if( $structures.isNewEditModeEnabled() ).#elseand#endare each on their own line. - Do not indent either branch to sit inside the
#if. Both start at column 0. Indenting changes every legacy line. - The
#elsebranch is the original, verbatim. Do not reformat it, translate its comments, fix its typos, remove its trailing spaces or change its line endings. If it has a bug, leave it; mention it in your answer if it matters. - Never add a
#parseand never write_new/_oldfiles in this mode. - Never ask for a filename or server path. The user may have only the field's content, pasted from the content type editor; inline needs no path. If a path was given, the single file keeps the original file's name and replaces it.
- Run the checks in "Before emitting inline" first. They can stop the output.
When the original is a file on disk (you were given a path), do not retype it. Write the migrated version to a temporary file, then assemble with the shell so the legacy bytes are copied, not regenerated. This is the only reliable way to keep CRLF line endings and a missing final newline:
sh{ printf '#if( $structures.isNewEditModeEnabled() )\n'; cat migrated.vtl; printf '\n#else\n'; cat original.vtl; printf '\n#end'; } > result.vtl
When the content was pasted, copy the pasted legacy code into the #else exactly as the user sent it.
Before emitting inline
Velocity parses the whole file before it renders either branch. With the router, the branch not taken is never even parsed; inline, both are. So some problems that only affected the legacy editor in the three-file shape would break the new editor too. Before emitting inline, read both branches and check:
| Finding | Severity | What to do |
|---|---|---|
A #macro with the same name in both branches | Blocking | Macros are registered at parse time, and the first definition wins. That is the migrated one, so the legacy editor would silently run the new macro. Emit nothing; name the macro; offer three files. |
The legacy branch would not parse on its own: a #if/#foreach/#macro/#define without its #end, a stray #end, an #else outside an #if | Blocking | Inline would carry that parse error into the new edit mode. Emit nothing; give the line; offer three files. |
| The migrated branch would not parse on its own | Blocking | If you migrated it, that is a bug in your migration: fix it before emitting anything. If it came from the user (collapse or split), report the line like the legacy case and write nothing. |
The same variable #set in both branches | Warning | #set runs at render time and only one branch renders, so this is not a collision. Emit, but list the names in the mode line. |
Count directives the way Velocity does. Text inside ##, #* *# and #[[ ]]# is not code, and \#end is escaped. A directive name also ends at the first character that is not a letter, digit, _ or @, so $('#end-date') in a script is an #end, while #endDate is not. The full rules and examples are in references/migration-guide.md → "Output Modes".
After a blocking finding, emit inline only if the user explicitly insists ("inline anyway"), and restate the finding in the mode line when you do.
Mode 2 — Three files with a router
Use this mode when the request asks for it (see the table above). Given an original file at /static/personas/keytag_custom_field.vtl, the three outputs are:
File 1 — keytag_custom_field_old.vtl
The original file content, completely unchanged. Copy it verbatim — every deprecated API call, every dijit class, every dojo.ready. This is the fallback for the legacy editor.
File 2 — keytag_custom_field_new.vtl
The fully migrated file with all deprecated patterns replaced per the migration rules.
File 3 — keytag_custom_field.vtl (replaces the original)
The conditional router — this file takes the name of the original and delegates to _new or _old based on which edit mode is active:
vtl#if( $structures.isNewEditModeEnabled() ) #parse('/static/personas/keytag_custom_field_new.vtl') #else #parse('/static/personas/keytag_custom_field_old.vtl') #end
The #parse paths must use the full server path of the file, not just the filename. Use the same directory as the original file. Indent the #parse lines with a tab and end the file right after #end, with no final newline, as the routers already in the repository do. On disk, write it with printf so no newline is added.
This pattern lets both legacy and new edit modes coexist safely — old editor users continue using the deprecated code, new editor users get the modernized version.
When the user gives you a file path, derive all three filenames automatically. In three-file mode only, if you receive file contents without a path, ask for the filename and its server path before outputting, because #parse cannot work without it.
Converting between modes
Conversion only moves content. Never apply a migration rule to either branch while converting. It is a re-shaping, not a re-migration.
Collapse (router + _new + _old → one inline file). Read the router's two #parse paths and find the _new and _old files. The _new content goes in the #if and the _old content in the #else, using the Mode 1 layout. Run "Before emitting inline" first; the same findings apply. If the router's paths do not match the files you were given (different names or directories), say so and stop. Do not guess.
Split (one inline file → router + _new + _old). You need the router's full server path; ask for it if you do not have it.
- Find where the branches end. Line 1 is the header. The legacy branch starts after the top-level
#else: the first#elseline reached while no block opened after line 1 is still open. Count block openers (#if,#foreach,#macro,#define,#literal,#@name) and#ends from line 2 on. Never just take the first#else, because branches often contain their own#if … #else … #end. The file ends with the matching#end. _new.vtlis everything between the header and the top-level#else._old.vtlis everything between that#elseand the final#end. In both cases, drop the one newline the inline layout added before the#elseand before the#end.- Write the router in Mode 2's shape: tab-indented
#parselines with full server paths.
If the file does not start with #if( $structures.isNewEditModeEnabled() ), or has no top-level #else, it is not an inline migration. Say so; do not guess.
On disk, use the shell for both directions so bytes are copied, never retyped. Collapse is the Mode 1 command, with _new in place of migrated.vtl and _old in place of original.vtl. Split takes line ranges, where E is the line number of the top-level #else and L is the line number of the final #end:
shsed -n "2,$((E-1))p" inline.vtl | perl -pe 'chomp if eof' > name_new.vtl sed -n "$((E+1)),$((L-1))p" inline.vtl | perl -pe 'chomp if eof' > name_old.vtl
Non-Negotiables
- All
getField()calls must be insideDotCustomFieldApi.ready() - Never use
DotCustomFieldApi.get()orDotCustomFieldApi.set()(the old short forms) - VTL variables stay exactly as-is
- Business logic stays exactly as-is — only the API calls and styling approach change
- Dijit CSS classes (any
class="dijit*") must be removed - Styling: Prefer DaisyUI component classes + Tailwind utilities; keep custom CSS only when the guide says so
- Translate non-English comments to English in the migrated code only
- The legacy code (
#elsebranch or_old.vtl) is never edited, not even its comments or whitespace - Server-side VTL variables (
$inode,$identifier,$lang,$contentlet,$structure,$field) are resolved at render time — do not confuse them withDotCustomFieldApiJavaScript APIs
Key Patterns to Know
Field reference lifecycle:
jsDotCustomFieldApi.ready(() => { // Get once, reuse everywhere const titleField = DotCustomFieldApi.getField('title'); const urlField = DotCustomFieldApi.getField('url'); // Read const current = titleField.getValue() || ''; // Write urlField.setValue(slugify(current)); // Watch titleField.onChange((value) => { urlField.setValue(slugify(value)); }); });
Field visibility and state control:
jsDotCustomFieldApi.ready(() => { const mediaField = DotCustomFieldApi.getField('media'); const mediaFileField = DotCustomFieldApi.getField('mediafile'); // Show/hide based on current value if (mediaField.getValue() === 'upload') { mediaFileField.show(); } else { mediaFileField.hide(); } // React to changes mediaField.onChange((value) => { if (value === 'upload') { mediaFileField.show(); } else { mediaFileField.hide(); } }); // Enable/disable a field mediaFileField.disable(); // blocks editing, applies disabled styling mediaFileField.enable(); // restores interactivity });
Reacting to validation state (required, errors, touched):
html<style> /* Self-contained: legacy iframe pages do NOT load DaisyUI, so we ship the rule with the template. */ #slugInput.is-invalid { border-color: #ef4444; outline-color: #ef4444; } </style> <script type="module"> DotCustomFieldApi.ready(() => { const field = DotCustomFieldApi.getField('urlTitle'); const input = document.getElementById('slugInput'); const applyValidation = (state) => { // Only show the error after the user (or Save) has marked the control as touched — // mirrors how Angular's built-in fields paint the red border. const showError = state.invalid && state.touched; input.classList.toggle('is-invalid', showError); }; // onValidationChange emits the initial state synchronously, so a separate // getValidationState() call up front is redundant. The bridge auto-cleans // on form destroy, so the unsubscribe return value can be ignored here. field.onValidationChange(applyValidation); }); </script>
Use a self-contained
is-invalidclass with inline<style>instead of DaisyUI'sinput-error. The legacy iframe page (legacy-custom-field.jsp) does NOT load DaisyUI or Tailwind, so aninput-errortoggle would silently produce no visual feedback there. Inside the iframe the migrated branch does not run at all:$structures.isNewEditModeEnabled()isfalsethere, because the iframe request carries nocontentTypeId, so the#else(legacy) code renders. The callback only fires in component mode (newRenderMode=component); in the legacy editor the Dojo bridge'sonValidationChangeis a no-op, and that editor has its own validation surface. See Rule 13 inreferences/migration-guide.mdfor the full gotchas list.
state shape: { valid, invalid, touched, dirty, errors } (mirrors Angular's AbstractControl). errors is null when valid, otherwise a record like { required: true }.
Multiple onChange for the same field → combine into one handler:
js// Old: two separate onChangeField calls for 'title' // New: one onChange that does both titleField.onChange((value) => { updateURL(value); updateFriendlyName(value); });
Native dialog with DaisyUI modal (replaces dojoType="dijit.Dialog"):
html<button type="button" id="openModalButton" class="btn btn-primary">Open modal</button> <dialog id="myDialog" class="modal"> <div class="modal-box"> <h3 class="font-bold text-lg">Hello!</h3> <p class="py-4">Press ESC key or click the button below to close</p> <div class="modal-action"> <form method="dialog"> <button type="submit" class="btn">Close</button> </form> </div> </div> </dialog> <script> const myDialog = document.getElementById('myDialog'); const openModalButton = document.getElementById('openModalButton'); openModalButton?.addEventListener('click', () => { myDialog?.showModal(); }); </script>
Styling (DaisyUI): Buttons → btn, btn-primary, btn-ghost, btn-sm. Inputs → input input-bordered. Selects → select select-bordered. Links → link link-primary. Use Tailwind for layout (flex, gap, w-full). Full reference in references/migration-guide.md → “Styling with DaisyUI”.
Available Velocity Context Variables
Custom field templates can use server-side VTL variables injected by dotCMS when the field is rendered. These are resolved on the server before HTML reaches the browser — they are not available in JavaScript and must not be confused with DotCustomFieldApi.
| Variable | Type | Description |
|---|---|---|
$inode | String | The contentlet's inode (version ID) |
$identifier | String | The contentlet's persistent identifier |
$lang | long | The contentlet's language ID |
$contentlet | Contentlet | The full Contentlet object |
$structure | ContentType | The content type (structure) |
$field | Field | The current field being rendered |
Availability:
$structureand$fieldare always available when the custom field is rendered.$inode,$identifier,$lang, and$contentletare populated only when editing an existing contentlet (when an inode is known). On new content, those four variables are empty/unset.- Both the new editor (REST API component mode and iframe mode) and the legacy editor expose the same variables.
Example — display context variables in the template:
html<p> <strong>inode:</strong> $inode </p> <p> <strong>identifier:</strong> $identifier </p> <p> <strong>lang:</strong> $lang </p> <p> <strong>contentlet:</strong> $contentlet </p> <p> <strong>structure:</strong> $structure </p> <p> <strong>field:</strong> $field </p>
Example — guard for new vs existing content:
html#if($utilMethods.isSet($inode)) <input type="hidden" id="contentInode" value="$inode" /> #else <p class="text-sm text-base-content/70">Save the content first to access inode-specific features.</p> #end
For full details, availability rules, and practical examples → read references/migration-guide.md → “Server-Side Velocity Context Variables”.
Before Outputting
Verify the migration passes this checklist (details in references/migration-guide.md):
Answer:
- Opens with the mode line (inline, three files, or none, with the reason)
- No filename or server path was asked for in inline mode
Inline output (default):
- One file; header exactly
#if( $structures.isNewEditModeEnabled() );#elseand#endon their own lines -
#elsebranch is the original verbatim: not re-indented, not reformatted, comments untouched - No
#parseadded; no_new/_oldfiles written - "Before emitting inline" checks run; no blocking finding, unless the user insisted, in which case the finding is restated
- Shared
#setvariables, if any, listed in the mode line
Three-file output (on request):
-
_old.vtl— original file content, completely unchanged (deprecated code preserved intentionally) -
_new.vtl— fully migrated content - Router file (original filename) — contains only the
#if( $structures.isNewEditModeEnabled() )block with correct full-path#parsedirectives pointing to_newand_old
Conversion (collapse / split):
- Neither branch changed; no migration rule applied
- Split used the top-level
#else, not the first one
Migrated code (the #if branch or _new.vtl):
- No
DotCustomFieldApi.get()or.set()or.onChangeField()remaining - No
dojo.*ordijit.*references remaining - No
dojoTypeattributes remaining - No
dijit*CSS classes remaining - Styling uses DaisyUI components where applicable (buttons, inputs, selects, modals, links) and Tailwind for layout; no inline styles unless necessary
- All field access inside
DotCustomFieldApi.ready() - All
getField()calls stored in variables and reused - Field visibility uses
field.show()/field.hide()instead of manual DOM manipulation - Field state uses
field.enable()/field.disable()instead of manual DOM attribute changes - VTL variables unchanged
- Business logic unchanged
For complete rules, DaisyUI styling section, all migration examples (character counter, title field, slug generator, dialogs, file browser), and edge cases → read references/migration-guide.md.


