Dot Ui Vtl Migration logo

Dot Ui Vtl Migration

Organization
dotCMS
dot-ui-vtl-migration

Migrates VTL (Velocity Template Language) custom field templates from the legacy DotCMS Dojo/Dijit API to the modern DotCustomFieldApi. By default it returns a single inline file ready to paste into the Custom Field: the migrated code inside #if( $structures.isNewEditModeEnabled() ) and the original legacy code, untouched, inside #else, with no #parse and no extra files. On request it produces three files instead (_old.vtl, _new.vtl and a #parse router), and it converts existing migrations between the two shapes. Use this skill whenever a user asks to migrate, update, or convert a VTL file, custom field template, or dotCMS field that uses any of: DotCustomFieldApi.get(), DotCustomFieldApi.set(), DotCustomFieldApi.onChangeField(), dojo.ready(), dojo.byId(), dijit.byId(), dijit.form.*, dojoType attributes, or any Dojo/Dijit pattern. Also trigger when the user pastes a VTL snippet and asks "what needs to change" or "can you update this", asks for an inline, single-file or one-file migration ("un solo archivo"), wants to paste the result into the content type editor, or wants to collapse a _new/_old/router migration into one file or split an inline one. Also trigger when the user asks to migrate ALL custom fields, every legacy field, or "the whole instance" — this skill bundles a `scripts/migrate_custom_fields.py` tool that finds, downloads, and safely republishes every legacy custom field across a live dotCMS instance in one guided session (see "Migrate a whole instance" below). If in doubt, use this skill.

Overview

PublisherdotCMS
Repositorycore
Skill namedot-ui-vtl-migration
Stars
971
Forks
487
Bundled files
27
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • 27 bundled files

    Scripts, templates, and references the model can read while it works. Files are read-only and never executed.

  • Open source

    Published by dotCMS on GitHub. Read the source before you install it.

Installation

Install the Dot Ui Vtl Migration AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/dotCMS/core/tree/main/.claude/skills/dot-ui-vtl-migration
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/dotCMS/core.git /tmp/core
mkdir -p .claude/skills
cp -r /tmp/core/.claude/skills/dot-ui-vtl-migration .claude/skills/dot-ui-vtl-migration
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Dot Ui Vtl Migration in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Dot Ui Vtl Migration on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Dot Ui Vtl Migration is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

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:
    bash
    uv 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.

  1. Configure and discover. Set BASE_URL, DOTCMS_TOKEN (env vars — the script never prompts), then run:

    bash
    BASE_URL=https://my-env.dotcms.dev DOTCMS_TOKEN=... \
      uv run scripts/migrate_custom_fields.py pull

    This 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-running pull after a push doesn't re-download what was already published.

  2. Show what's pending and let the customer pick a batch. Build the list from the entries in pull's final stdout JSON line (or manifest.json), and keep it short — one numbered table, nothing migrated yet:

    #VTLKindUsed by
    1text-count.vtl/dA/ file5 fields: Destination.pageTitleCount, …
    2Banner.widgetinline fieldBanner.widget

    Entries with kind: "renderMode" and empty requires need 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 list renderMode entries whose requires is 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), or none. Wait for the answer.

  3. 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.

  4. Show the customer a summary of what changed per selected file and get their explicit confirmation before publishing anything.

  5. Preview, then publish only that batch, passing the selected rows' keys (the dA id for a /dA/ file, Type.field for an inline field or a render-mode switch):

    bash
    uv 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. push independently verifies, per item, that nothing changed on the server since pull and 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.

  6. 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 pending in manifest.json) and ask whether to continue. Unselected rows stay pending — nothing is lost by stopping. In a later session, start again from step 1: pull lists 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 fieldDotCustomFieldApi.getField('id').show() / .hide()
Manual DOM enable/disable of a fieldDotCustomFieldApi.getField('id').enable() / .disable()
Manual checks against dijit validation stateDotCustomFieldApi.getField('id').getValidationState()
No legacy equivalentDotCustomFieldApi.getField('id').onValidationChange(cb)
dojo.ready(fn)DotCustomFieldApi.ready(fn)
dojo.byId('el')document.getElementById('el')
dijit.byId('id')DotCustomFieldApi.getField('id')
dijit.form.* widgetsNative HTML + DaisyUI classes (input, btn, select, etc.)
dojoType="..." attributeRemove; use semantic HTML + DaisyUI components
class="dijit*" classesRemove; use DaisyUI component classes instead
Inline styles / ad-hoc CSSDaisyUI component classes + Tailwind utilities (see guide)
onclick="fn()" inline handlersaddEventListener('click', fn)

Process

  1. Read the entire file to understand all functionality before touching anything.
  2. Identify deprecated patterns — scan for the patterns in the table above.
  3. Wrap everything in DotCustomFieldApi.ready() — all field access must live inside this callback.
  4. Store field references once — call getField() once per field at the top of ready(), then reuse the reference.
  5. Migrate each pattern — follow the migration rules in references/migration-guide.md.
  6. 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.
  7. Preserve VTL variables — ${fieldId}, $maxChar, $variableName, and server-side context variables ($inode, $identifier, $lang, $contentlet, $structure, $field) are server-side; never change them.
  8. 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…ModeNeeds a file path?
nothing about the shapeInline (default)No. Never ask for one.
"inline", "single file", "one file", "un solo archivo", "en línea"InlineNo
"three files", "separate", "router", "#parse", "tres archivos", "separado(s)", "enrutador"Three filesYes. 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/_oldCollapse (see Converting between modes)Only to find the files
"split" + an inline fileSplit (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() ). #else and #end are 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 #else branch 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 #parse and never write _new/_old files 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:

FindingSeverityWhat to do
A #macro with the same name in both branchesBlockingMacros 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 #ifBlockingInline 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 ownBlockingIf 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 branchesWarning#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.

  1. Find where the branches end. Line 1 is the header. The legacy branch starts after the top-level #else: the first #else line 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.
  2. _new.vtl is everything between the header and the top-level #else. _old.vtl is everything between that #else and the final #end. In both cases, drop the one newline the inline layout added before the #else and before the #end.
  3. Write the router in Mode 2's shape: tab-indented #parse lines 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:

sh
sed -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 inside DotCustomFieldApi.ready()
  • Never use DotCustomFieldApi.get() or DotCustomFieldApi.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 (#else branch 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 with DotCustomFieldApi JavaScript APIs

Key Patterns to Know

Field reference lifecycle:

js
DotCustomFieldApi.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:

js
DotCustomFieldApi.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-invalid class with inline <style> instead of DaisyUI's input-error. The legacy iframe page (legacy-custom-field.jsp) does NOT load DaisyUI or Tailwind, so an input-error toggle would silently produce no visual feedback there. Inside the iframe the migrated branch does not run at all: $structures.isNewEditModeEnabled() is false there, because the iframe request carries no contentTypeId, so the #else (legacy) code renders. The callback only fires in component mode (newRenderMode=component); in the legacy editor the Dojo bridge's onValidationChange is a no-op, and that editor has its own validation surface. See Rule 13 in references/migration-guide.md for 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.

VariableTypeDescription
$inodeStringThe contentlet's inode (version ID)
$identifierStringThe contentlet's persistent identifier
$langlongThe contentlet's language ID
$contentletContentletThe full Contentlet object
$structureContentTypeThe content type (structure)
$fieldFieldThe current field being rendered

Availability:

  • $structure and $field are always available when the custom field is rendered.
  • $inode, $identifier, $lang, and $contentlet are 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() ); #else and #end on their own lines
  • #else branch is the original verbatim: not re-indented, not reformatted, comments untouched
  • No #parse added; no _new/_old files written
  • "Before emitting inline" checks run; no blocking finding, unless the user insisted, in which case the finding is restated
  • Shared #set variables, 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 #parse directives pointing to _new and _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.* or dijit.* references remaining
  • No dojoType attributes 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.

Bundled files

The model reads these on demand while the skill is loaded. They are exposed as readable files and are never executed.

Frequently asked questions

What does the Dot Ui Vtl Migration AI skill do?

Migrates VTL (Velocity Template Language) custom field templates from the legacy DotCMS Dojo/Dijit API to the modern DotCustomFieldApi. By default it returns a single inline file ready to paste into the Custom Field: the migrated code inside #if( $structures.isNewEditModeEnabled() ) and the original legacy code, untouched, inside #else, with no #parse and no extra files. On request it produces three files instead (_old.vtl, _new.vtl and a #parse router), and it converts existing migrations between the two shapes. Use this skill whenever a user asks to migrate, update, or convert a VTL file,...

Why use Dot Ui Vtl Migration on TypingMind?

Because you install it once and use it with any model. Dot Ui Vtl Migration is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Dot Ui Vtl Migration in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/dotCMS/core/tree/main/.claude/skills/dot-ui-vtl-migration. TypingMind reads its SKILL.md and bundles its files and installs it as a skill you can enable per chat.

Which AI models can use Dot Ui Vtl Migration?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Dot Ui Vtl Migration?

As many as you like. As long as a model supports skills, you can use Dot Ui Vtl Migration with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Dot Ui Vtl Migration AI skill free?

It is published on GitHub by dotCMS. Check the repository for licensing terms. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇