chromium/ui/webui/resources/tools/codemods/lit_migration_templates.mjs

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// A helper script that performs some Polymer->Lit migration steps. See details
// below.

import fs from 'node:fs';
import path from 'node:path';
import {parseArgs} from 'node:util';

// Regular expression to extract CSS Content from within <style>...</style> or
// <style include="...">...</style> tags. The 'd' flag is needed to obtain the
// start/end indices of the match.
const CSS_REGEX = /<style(?<deps>[^>]*)?>(?<content>[^]+)<\/style>/d;

// Header to place on top of the newly created CSS file.
const CSS_FILE_HEADER = `/* Copyright 2024 The Chromium Authors
 * Use of this source code is governed by a BSD-style license that can be
 * found in the LICENSE file. */

/* #css_wrapper_metadata_start
 * #type=style-lit
 * #scheme=relative
 * #css_wrapper_metadata_end */
`;

const LISTENER_BINDING_REGEX =
    /on-(?<eventName>[a-zA-Z-]+)="(?<listenerName>[a-zA-Z0-9_]+)"/g;

// Regular expression to parse 2-way bindings like value="{{myValue_}}",
// and extract 'value' and 'myValue_' into captured groups for further
// processing.
const LISTENER_BIDNING_TWO_WAY_REGEX =
    /(?<childProp>[a-z-]+)="\{\{(?<parentProp>[a-zA-Z0-9_]+)\}\}"/g;

// Regular expression to extract a references to TS methods or member variables
// in HTML templates. For example 'foo' will be extracted from ${this.foo} or
// ${this.foo_(abc)}.
const TS_REFERENCE_REGEX = /\$\{this\.(?<reference>[^(}]+)(\([^)]*\)){0,1}}/g;

// Replaces part of a string with a the provided replacement string.
function replaceRange(string, start, end, replacement) {
  return string.substring(0, start) + replacement + string.substring(end);
}

function processFile(file) {
  const basename = path.basename(file, '.ts');
  const tsFile = path.join(path.dirname(file), basename + '.ts');
  const htmlFile = path.join(path.dirname(file), basename + '.html');
  const cssFile = path.join(path.dirname(file), basename + '.css');

  // Step 1: Extract a standalone CSS file and write to disk.
  let htmlContent = fs.readFileSync(htmlFile, 'utf8');
  const match = htmlContent.match(CSS_REGEX);

  if (match !== null) {
    fs.writeFileSync(
        cssFile, CSS_FILE_HEADER + match.groups['content'], 'utf8');

    // Step 2: Remove <style>...</style> CSS content from HTML template file.
    htmlContent = htmlContent.substring(match.indices[0][1]);
  }

  // Step 3: Update event listeners syntax in HTML template
  htmlContent = htmlContent.replaceAll(
      LISTENER_BINDING_REGEX, function(_a, _b, _c, _d, _e, groups) {
        return `@${groups.eventName}="\${this.${groups.listenerName}}"`;
      });

  // Step 4: Update property access syntax in HTML template (1-way bindings)
  htmlContent = htmlContent.replaceAll(/\[\[!item/g, () => '${!item');
  htmlContent = htmlContent.replaceAll(/\[\[item/g, () => '${item');
  htmlContent = htmlContent.replaceAll(/\[\[!/g, () => '${!this.');
  htmlContent = htmlContent.replaceAll(/\[\[/g, () => '${this.');
  htmlContent = htmlContent.replaceAll(/\]\]/g, () => '}');

  // Step 5: Update property access syntax in HTML template (2-way bindings)
  const matches = Array.from(htmlContent.matchAll(LISTENER_BIDNING_TWO_WAY_REGEX));
  // Reverse the order so that the character indices don't get messed up after
  // modifying the original string, effectively processing the matches from the
  // end of the string to the start.
  matches.reverse();

  // For each match, change
  // value="{{myValue_}}"
  // to
  // value="${this.myValue_}" @value-changed="${this.onMyValueChanged_}"
  for (let i = 0; i < matches.length; i++) {
    const g = matches[i].groups;
    let listenerPart =
        g['parentProp'].charAt(0).toUpperCase() + g['parentProp'].slice(1);
    listenerPart = listenerPart.replace('_', '');
    const listener =
        `@${g['childProp']}-changed="\${this.on${listenerPart}Changed_}"`
    const binding = matches[i][0].replace("{{", "${this.").replace("}}", "}");
    const start = matches[i].index;
    const end = matches[i].index + matches[i][0].length;
    htmlContent =
        replaceRange(htmlContent, start, end, `${binding} ${listener}`);
  }

  // Step 6: Write updated HTML content to disk
  fs.writeFileSync(htmlFile, htmlContent, 'utf8');

  // Step 7: Extract all methods/variables being referenced from the template
  //         and if they are 'private' change them to 'protected'.
  const references = Array.from(
      htmlContent.matchAll(TS_REFERENCE_REGEX)).map(m => m[1]);
  if (references.length > 0) {
    let tsContent = fs.readFileSync(tsFile, 'utf8');
    for (const ref of references) {
      tsContent = tsContent.replace(`private ${ref}`, `protected ${ref}`);
    }
    // Step 7b: Write updated TS content to disk.
    fs.writeFileSync(tsFile, tsContent, 'utf8');
  }
}

function main() {
  const args = parseArgs({
                 options: {
                   file: {
                     type: 'string',
                   },
                 },
               }).values;

  processFile(args.file);
  console.log('DONE');
}
main();