Ce blog est la deuxième partie de mon blog Bluebird. Voir partie 1
Pour mon clone Twitter sans dépendances, j'ai décidé de concevoir mes gestionnaires de routes de manière à ce qu'ils renvoient des variables et un nom de modèle. Cela permet des tests faciles, car mes tests peuvent simplement faire des assertions sur le nom du modèle et les variables plutôt que d'inspecter un document HTML.
// request { method: "GET", path: "/profile/1234", cookies: { "user-id": 54 }, } // response { status: 200, template: "public-profile-show", variables: { user: { id: 54, name: "John Doe", }, posts: [ { id: 55412, message: "Have you seen the new iThing?", createdAt: 1699788972 } ] } }
Dans ce blog, je vais implémenter ce langage de modèle.
Le langage de modèle dont j'ai besoin doit générer des documents HTML avec seulement un ensemble de variables en entrée. Je souhaite qu'un modèle soit compilé dans une fonction JS. Par exemple, je veux Bonjour <%= nom %> pour compiler quelque chose comme ceci :
({ name }) => `Hello ${escapeHtml(name)}`;
Je vais opter pour un classique <%= %> syntaxe, car elle est incroyablement courante et bien connue. La plupart des développeurs qui voient cette syntaxe sauront intuitivement que vous pouvez simplement y écrire du code normal et que la sortie du code sera ajoutée à la sortie.
Il doit prendre en charge les variables et les entités HTML à échappement automatique. Les boucles, les instructions if/else et l'inclusion d'autres modèles doivent également être prises en charge. Ce serait bien si nous pouvions invoquer des fonctions arbitraires et faire quelques calculs de base ;
Donc, fondamentalement, je veux qu'il puisse exécuter du javascript arbitraire.
Je suppose que je viens juste de commencer à écrire du code et de voir où je me retrouve. Tout d'abord, un test.
it("simple template", () => { const fn = Template.parse("Hello, <%= name %>"); assert.equal(fn({ name: "world" }), "Hello, world"); });
L'implémentation la plus simple à laquelle je puisse penser est d'utiliser une expression régulière. Tout le contenu en dehors de <%= %> sera simplement ajouté à la sortie et le contenu entre les deux sera exécuté en JS.
L'expression régulière utilisée est /(.*?)<%(.*?)%>/sg. Cette expression régulière capture n'importe quel texte jusqu'au premier <% trouvé en utilisant (.*?)<%. Ensuite, il capture n'importe quel texte jusqu'à %> en utilisant (.*?)%>. Le modificateur s permet au . (point) pour correspondre aux nouvelles lignes. Le modificateur g permet plusieurs correspondances.
La fonction de remplacement de Javascript sur la chaîne permet d'exécuter du code pour chaque correspondance tout en renvoyant également une valeur de remplacement, "" dans mon code. Étant donné que chaque correspondance est remplacée par une chaîne vide, seul le texte après le dernier %> est renvoyé par la fonction de remplacement, que j'appelle la queue.
J'utilise JSON.stringify pour créer une chaîne littérale.
const Template = { parse(template) { let body = [ "eval(`var { ${Object.keys(vars).join(', ')} } = vars;`);", `out = [];` ]; const tail = template.replace(/(.*?)<%(.*?)%>/sg, (_, content, code) => { body.push(`out.push(${JSON.stringify(content)});`); if (code.startsWith("=")) body.push(`out.push(${code.substr(1)});`); return ""; }); body.push(`out.push(${JSON.stringify(tail)});`); body.push(`return out.join("");`); return new Function('vars', body.join("\n")) } };
Pour le modèle du test, cette fonction renvoie une fonction comme celle-ci :
function(vars) { eval(`var { ${Object.keys(vars).join(', ')} } = vars;`); out = []; out.push("Hello, "); out.push(name); out.push(""); return out.join(""); }
Une autre partie notable de ce code est l'instruction eval. Pour permettre au modèle de faire référence à n'importe quelle variable dans vars (nom dans cet exemple), je dois rendre les propriétés dans vars disponibles en tant que variables locales dans la fonction.
Il n'y a pas de moyen facile de déterminer les variables possibles lors de la compilation, donc je les génère au moment de l'exécution. La seule façon dont je sais attribuer des variables locales arbitraires au moment de l'exécution, est d'utiliser l'évère.
// request { method: "GET", path: "/profile/1234", cookies: { "user-id": 54 }, } // response { status: 200, template: "public-profile-show", variables: { user: { id: 54, name: "John Doe", }, posts: [ { id: 55412, message: "Have you seen the new iThing?", createdAt: 1699788972 } ] } }
Une autre méthode consiste à utiliser le statement, qui est découragé. Essayons-le quand même.
({ name }) => `Hello ${escapeHtml(name)}`;
La fonction générée fonctionne parfaitement. Dommage que la fonctionnalité soit découragée, héritée ou obsolète, selon qui vous demandez. Jusqu'à présent, mes options sont Evil Eval ou obsolètes avec. Idéalement, je souhaite déterminer les variables utilisées au temps de compilation, mais cela nécessite de compiler le code JavaScript pour déterminer les variables utilisées.
Il n'y a pas de moyen facile d'obtenir un arbre de syntaxe abstrait d'un morceau de JavaScript en utilisant des nodejs simples.
Maintenant pour échapper aux entités HTML, prendre en charge les instructions IF / else et ajouter quelques correctifs mineurs.
it("simple template", () => { const fn = Template.parse("Hello, <%= name %>"); assert.equal(fn({ name: "world" }), "Hello, world"); });
J'ai également ajouté quelques tests supplémentaires.
const Template = { parse(template) { let body = [ "eval(`var { ${Object.keys(vars).join(', ')} } = vars;`);", `out = [];` ]; const tail = template.replace(/(.*?)<%(.*?)%>/sg, (_, content, code) => { body.push(`out.push(${JSON.stringify(content)});`); if (code.startsWith("=")) body.push(`out.push(${code.substr(1)});`); return ""; }); body.push(`out.push(${JSON.stringify(tail)});`); body.push(`return out.join("");`); return new Function('vars', body.join("\n")) } };
Pour autoriser les incluses, je vais ajouter une fonction pour analyser tous les fichiers de modèle dans un répertoire. Cette fonction gardera un dictionnaire avec des noms de modèle comme des touches et leur modèle de modèle analysé comme des valeurs.
function(vars) { eval(`var { ${Object.keys(vars).join(', ')} } = vars;`); out = []; out.push("Hello, "); out.push(name); out.push(""); return out.join(""); }
var { foo } = { foo: 1 }; // foo = 1 eval('var { bar } = { bar: 2 }'); // bar = 2
function(vars) { with (vars) { out = []; out.push("Hello, "); out.push(name); out.push(""); return out.join(""); } }
maintenant pour intégrer ce moteur de modèle dans le fichier main.mjs pour rendre le modèle à l'aide des modèles .ejs.
// Template.parse let body = [ "eval(`var { ${Object.keys(vars).join(', ')} } = vars;`);", `out = [];` ]; const tail = template.replace(/(.*?)<%(.*?)%>/sg, (_, content, code) => { if (content) body.push(`out.push(${JSON.stringify(content)});`); if (code.startsWith("=")) body.push(`out.push(escapeHtml(${code.substr(1)}));`); else if (code.startsWith("-")) body.push(`out.push(${code.substr(1)});`); else body.push(code); return ""; }); if (tail.length > 0) body.push(`out.push(${JSON.stringify(tail)});`); body.push(`return out.join("");`); body = body.join("\n"); const fn = new Function('vars', body); return (vars) => fn({ ...vars, ...Template.locals }); // Template.locals locals: { escapeHtml: (str) => `${str}`.replace(/[<>&"']/g, s => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[s]) }
describe("Template.parse", () => { it("simple template", () => { const fn = Template.parse("Hello, <%= name %>"); assert.equal(fn({ name: "world" }), "Hello, world"); }); it("math template", () => { const fn = Template.parse("1 + 1 = <%= 1 + 1 %>"); assert.equal(fn({}), "1 + 1 = 2"); }); it("function template", () => { const fn = Template.parse("Hello <%= foo() %>"); assert.equal(fn({ foo: () => "world" }), "Hello world"); }); it("if-else template", () => { const fn = Template.parse(`Answer: <% if (answer) { %>Yes<% } else { %>No<% } %>`); assert.deepEqual( [fn({ answer: true }), fn({ answer: false })], ["Answer: Yes", "Answer: No"]); }); it("multiline template", () => { const fn = Template.parse(` Answer: <% if (answer) { %> Yes <% } else { %> No <% } %> `); assert.deepEqual( [delws(fn({ answer: true })), delws(fn({ answer: false }))], ["Answer: Yes", "Answer: No"]); }); it("escape html", () => { const fn = Template.parse("Hello, <%= name %>"); assert.equal(fn({ name: "<script> & \" '" }), "Hello, <script> & " '"); }); }); function delws(str) { return str.replace(/^\s+|\s+$/g, "").replace(/\s+/g, " "); }
export const Template = { /** * * @param {string} path Directory containing one or more template files * @returns {Promise<(template: string, vars: Record<string, any>) => string>} */ async parseDirectory(path) { /** @type {Map<string, function>} */ const templates = new Map(); const include = (templateName, vars) => { const template = templates.get(templateName); if (!template) throw new Error(`Template ${path}/${templateName} does not exist, ` + `templates found: ${Array.from(templates.keys()).join(", ")}`); return template({ ...vars, include }); }; const readDir = async (prefix) => { const innerPath = join(path, prefix); const fileNames = await promises.readdir(join(innerPath)); for (const fileName of fileNames) { const templateName = join(prefix, fileName); const filePath = join(innerPath, fileName); if (fileName.endsWith(".ejs")) { const body = await promises.readFile(filePath, { encoding: "utf-8" }); templates.set(templateName, Template.parse(body, { filePath })); } else if ((await promises.stat(filePath)).isDirectory) { await readDir(join(prefix, fileName)); } } }; await readDir(""); return include; } }
Maintenant, nous sommes prêts à commencer à écrire notre application, qui se poursuivra dans le prochain blog
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!