- started implementing frontend to backend

This commit is contained in:
Aeolin Ferjünnoz 2026-04-12 16:38:59 +02:00
parent 3c9f7323ba
commit 6abc36bc9b
24 changed files with 906 additions and 10 deletions

View File

@ -31,7 +31,7 @@ namespace OnlyPrompt.Backend.Controllers
[AllowAnonymous] [AllowAnonymous]
[HttpPost("login")] [HttpPost("login")]
public async Task<Results<Ok, BadRequest<string>, NotFound<string>>> LoginAsync([FromBody] ApiLoginRequest request) public async Task<Results<Ok, RedirectHttpResult, BadRequest<string>, NotFound<string>>> LoginAsync([FromBody] ApiLoginRequest request, [FromQuery]string redirect = null)
{ {
var user = await FindUserAsync(request.UserNameOrEmail); var user = await FindUserAsync(request.UserNameOrEmail);
if (user is null) if (user is null)
@ -46,6 +46,9 @@ namespace OnlyPrompt.Backend.Controllers
var token = _jwtService.BuildToken(user, out var validUntil); var token = _jwtService.BuildToken(user, out var validUntil);
this.Response.Cookies.Append("jwt", token, AuthCookieOptions.Copy(c => c.Expires = validUntil)); this.Response.Cookies.Append("jwt", token, AuthCookieOptions.Copy(c => c.Expires = validUntil));
if (string.IsNullOrEmpty(redirect) == false)
return TypedResults.Redirect(redirect, false);
return TypedResults.Ok(); return TypedResults.Ok();
} }

View File

@ -13,6 +13,7 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
COPY ["OnlyPrompt.Backend/OnlyPrompt.Backend.csproj", "OnlyPrompt.Backend/"] COPY ["OnlyPrompt.Backend/OnlyPrompt.Backend.csproj", "OnlyPrompt.Backend/"]
ADD ["OnlyPrompt.Frontend", "OnlyPrompt.Backend/wwwroot"]
RUN dotnet restore "./OnlyPrompt.Backend/OnlyPrompt.Backend.csproj" RUN dotnet restore "./OnlyPrompt.Backend/OnlyPrompt.Backend.csproj"
COPY . . COPY . .
WORKDIR "/src/OnlyPrompt.Backend" WORKDIR "/src/OnlyPrompt.Backend"

View File

@ -30,6 +30,7 @@
<ItemGroup> <ItemGroup>
<Folder Include="Migrations\" /> <Folder Include="Migrations\" />
<Folder Include="wwwroot\" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -1,7 +1,10 @@
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Rewrite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Identity.Web; using Microsoft.Identity.Web;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
@ -11,6 +14,7 @@ using OnlyPrompt.Backend.Services.Jwt;
using OnlyPrompt.Backend.Utils; using OnlyPrompt.Backend.Utils;
using Scalar.AspNetCore; using Scalar.AspNetCore;
using System.Text; using System.Text;
using System.Text.Json;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration; var config = builder.Configuration;
@ -25,10 +29,14 @@ builder.Services.AddDbContext<OnlyPromptContext>(opts =>
builder.Services.AddSingleton<IPasswordHasher<UserModel>, PasswordHasher<UserModel>>(); builder.Services.AddSingleton<IPasswordHasher<UserModel>, PasswordHasher<UserModel>>();
builder.Services.AddSingleton<ITokenService, JwtTokenService>(); builder.Services.AddSingleton<ITokenService, JwtTokenService>();
builder.Services.AddAutoMapper(AutoMapperSetup.Setup); builder.Services.AddAutoMapper(AutoMapperSetup.Setup);
builder.Services.AddValidation(opts =>
{
opts.MaxDepth = 10;
});
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, opts => { .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, opts =>
{
opts.TokenValidationParameters = new TokenValidationParameters opts.TokenValidationParameters = new TokenValidationParameters
{ {
ValidateIssuer = true, ValidateIssuer = true,
@ -51,7 +59,10 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
}; };
}); });
builder.Services.AddControllers(); builder.Services.AddControllers().AddJsonOptions(jsonOpts =>
{
jsonOpts.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
});
builder.Services.AddOpenApi(opts => opts.AddScalarTransformers()); builder.Services.AddOpenApi(opts => opts.AddScalarTransformers());
var app = builder.Build(); var app = builder.Build();
@ -65,10 +76,28 @@ if (app.Environment.IsDevelopment())
app.UseHttpsRedirection(); app.UseHttpsRedirection();
var rewrite = new RewriteOptions()
.AddRewrite(@"^(?!scalar\/?|api\/?)([^.]+)$", "$1.html", skipRemainingRules: true);
app.UseRewriter(rewrite);
app.UseAuthorization(); app.UseAuthorization();
if (app.Environment.IsDevelopment())
{
var dir = Path.GetFullPath("./../OnlyPrompt.Frontend");
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(dir),
RedirectToAppendTrailingSlash = true,
HttpsCompression = HttpsCompressionMode.Compress,
});
}
else
{
app.UseStaticFiles();
}
app.MapControllers(); app.MapControllers();
app.MapFallbackToFile("/login.html");
using var scope = app.Services.CreateScope(); using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<OnlyPromptContext>(); var db = scope.ServiceProvider.GetRequiredService<OnlyPromptContext>();
await db.Database.MigrateAsync(); await db.Database.MigrateAsync();

16
OnlyPrompt.Frontend/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,16 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "msedge",
"request": "launch",
"name": "Open current page in Edge",
"cwd": "${workspaceRoot}",
"url": "https://localhost:7163/${fileBasename}"
}
]
}

View File

@ -0,0 +1,4 @@
{
"editor.tabSize": 2,
"editor.indentSize": 2
}

View File

@ -17,3 +17,25 @@ body {
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
} }
/* Form errors */
.form-error {
color: red;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.form-error ul {
list-style: none;
padding-left: 0;
list-style: '*';
}
.form-error li {
margin-bottom: 0.25rem;
}
.form-error li .error {
color: red;
font-style: italic;
}

View File

@ -0,0 +1,140 @@
// LINQ-like Enumerable class wrapping lazy generator chains
class Enumerable {
constructor(iteratorFn) {
this._iteratorFn = iteratorFn;
}
[Symbol.iterator]() {
return this._iteratorFn();
}
_chain(generatorFn) {
const source = this;
return new Enumerable(function* () {
yield* generatorFn(source);
});
}
where(predicate) {
return this._chain(function* (source) {
for (const item of source)
if (predicate(item))
yield item;
});
}
select(selector) {
return this._chain(function* (source) {
for (const item of source)
yield selector(item);
});
}
take(count) {
count = Math.max(0, count);
return this._chain(function* (source) {
for (const item of source) {
if (count-- <= 0) break;
yield item;
}
});
}
skip(count) {
count = Math.max(0, count);
return this._chain(function* (source) {
for (const item of source) {
if (count-- > 0) continue;
yield item;
}
});
}
isLast() {
return this._chain(function* (source) {
const iter = source[Symbol.iterator]();
let current = iter.next();
let index = 0;
while (!current.done) {
const next = iter.next();
yield [current.value, next.done, index];
current = next;
index++;
}
});
}
forEach(action) {
for (const item of this) {
if (Array.isArray(item)) {
action(...item);
} else {
action(item);
}
}
}
toArray() {
return Array.from(this);
}
firstOrDefault(predicate) {
const source = predicate ? this.where(predicate) : this;
for (const item of source) return item;
return undefined;
}
first(predicate) {
const source = predicate ? this.where(predicate) : this;
for (const item of source) return item;
throw new Error("No elements in sequence.");
}
lastOrDefault(predicate) {
const source = predicate ? this.where(predicate) : this;
let lastValue = undefined;
for (const item of source) lastValue = item;
return lastValue;
}
last(predicate) {
const source = predicate ? this.where(predicate) : this;
let lastValue = undefined;
let found = false;
for (const item of source) {
lastValue = item;
found = true;
}
if (!found) throw new Error("No elements in sequence.");
return lastValue;
}
any(predicate) {
const source = predicate ? this.where(predicate) : this;
for (const _ of source) return true;
return false;
}
all(predicate) {
for (const item of this)
if (!predicate(item))
return false;
return true;
}
count(predicate) {
let count = 0;
const source = predicate ? this.where(predicate) : this;
for (const _ of source) count++;
return count;
}
}
Array.prototype.asEnumerable = function () {
const arr = this;
return new Enumerable(function* () {
for (const item of arr)
yield item;
});
}

View File

@ -0,0 +1,21 @@
import { sendFormAsync } from "./shared.js";
function togglePassword() {
const passwordInput = document.getElementById('password');
const newInputType = passwordInput.type === 'password' ? 'text' : 'password';
passwordInput.type = newInputType;
}
async function submitLoginForm(){
const form = document.getElementById('loginForm');
await sendFormAsync(form);
}
const togglePasswordButton = document.getElementById('togglePassword');
togglePasswordButton.addEventListener('click', togglePassword);
const loginForm = document.getElementById('loginForm');
loginForm.addEventListener('submit', async (event) => {
event.preventDefault(); // Prevent the default form submission
await submitLoginForm();
});

View File

@ -0,0 +1,148 @@
import './linq.js'
import { Template } from './template.js';
export function formToObject(form) {
const data = new FormData(form);
const object = {};
data.forEach((value, key) => {
setNestedValue(object, key, value);
});
return object;
}
function setNestedValue(obj, path, value) {
path.split('.').asEnumerable()
.isLast()
.forEach((key, isLast) => {
if (isLast) {
obj[key] = value;
}
else {
if (!obj[key]) {
obj[key] = {};
}
obj = obj[key];
}
});
}
export async function sendFormAsync(form, url, method) {
url = url || form.action;
method = method || form.method || 'post';
const data = formToObject(form);
const response = await sendJsonAsync(url, data, method);
const responseText = await response.text();
if (response.ok == false && handleValidationError(response, responseText, form)) {
return null;
}
if (response.ok == false) {
handleGenericFormError(response, responseText, form);
return null;
} else {
if(response.redirected){
window.location.href = response.url;
return null;
}
return responseText.length == 0 ? null : JSON.parse(responseText);
}
}
export async function sendJsonAsync(url, data, method = 'post') {
const response = await fetch(url, {
method: method.toUpperCase(),
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
return response;
}
export async function postAndRenderAsync(url, data, template, targetElement) {
const response = await sendJsonAsync(url, data);
if (response.ok) {
const responseText = await response.text();
targetElement.innerHTML = template.render(responseText.length == 0 ? undefined : JSON.parse(responseText));
}
}
export async function postFormAndRenderAsync(url, form, template, targetElement) {
const object = formToObject(form);
const data = await postFormAsync(url, object, template, targetElement);
if (data) {
targetElement.innerHTML = template.render(data);
}
}
const genericFormErrorTemplate = new Template(`
<div class="form-error">
An error occurred while submitting the form. Please try again later.
{{ $this }}
</div>
`);
function handleGenericFormError(response, responseText, form) {
if (!response.ok) {
const html = genericFormErrorTemplate.render(responseText);
form.insertAdjacentHTML('beforeend', html);
}
}
const validationErrorTemplate = new Template(`
<div class="form-error">
<ul>
@for(error of $this) {
<li class="error">{{error}}</li>
}
</ul>
</div>
`);
const unknownInputErrorTemplate = new Template(`
<div class="form-error">
<p>An error occurred with the following fields:</p>
@for(field, errors of Object.entries($this)) {
<ul>
@for(error of errors) {
<li class="error">{{field}}: {{error}}</li>
}
</ul>
}
</div>
`);
function handleValidationError(response, responseText, form) {
if (response.status !== 400) return false;
const responseObject = JSON.parse(responseText);
const unknownInputErrors = {};
if (responseObject.type === 'https://tools.ietf.org/html/rfc9110#section-15.5.1' && responseObject.errors) {
for (const [field, messages] of Object.entries(responseObject.errors)) {
const input = form.querySelector(`[name="${field}"]`);
if (input) {
const parent = input.parentElement;
const errorHtml = validationErrorTemplate.render(messages);
let errorContainer = parent.querySelector('.form-error'); // Check if an error container already exists
if (errorContainer) {
errorContainer.outerHTML = errorHtml; // Replace existing error container
} else {
parent.insertAdjacentHTML('beforeend', errorHtml);
}
} else {
unknownInputErrors[field] = messages;
}
}
if (Object.keys(unknownInputErrors).length > 0) {
const html = unknownInputErrorTemplate.render(unknownInputErrors);
form.insertAdjacentHTML('beforeend', html);
}
return true;
}
return false;
}

View File

@ -0,0 +1,455 @@
import './linq.js'
function selectPath(data, expression) {
if (!expression) return data;
try {
return new Function('$data', `with($data) { return (${expression}); }`)(data);
} catch {
console.error(`Error evaluating Template expression: ${expression}`);
return undefined;
}
}
class ScopeDict {
constructor(initial = {}) {
this._entries = { ...initial };
}
set(name, value) {
if (name.startsWith('$')) {
throw new Error(`Cannot declare '${name}' with @let. Variables starting with '$' are reserved.`);
}
this._entries[name] = value;
}
setBuiltin(name, value) {
this._entries[name] = value;
}
has(name) {
return name in this._entries;
}
get(name) {
return this._entries[name];
}
clone() {
return new ScopeDict(this._entries);
}
}
function createScopeProxy(data, scope) {
if (data === null || data === undefined || typeof data !== 'object') {
scope.setBuiltin('$this', data);
return new Proxy({}, {
get(_target, prop) {
if (prop === '$this') return data;
if (scope.has(prop)) return scope.get(prop);
return undefined;
},
has(_target, prop) {
return prop === '$this' || scope.has(prop);
}
});
}
scope.setBuiltin('$this', data);
return new Proxy(data, {
get(target, prop) {
if (prop === '$this') return data;
if (scope.has(prop)) return scope.get(prop);
return target[prop];
},
has(target, prop) {
return prop === '$this' || scope.has(prop) || prop in target;
}
});
}
function skipString(str, i) {
const quote = str[i];
i++;
while (i < str.length && str[i] !== quote) {
if (str[i] === '\\') i++;
i++;
}
return i;
}
function findClosingParen(str, openPos) {
let depth = 1;
let i = openPos + 1;
while (i < str.length && depth > 0) {
if (str[i] === "'" || str[i] === '"' || str[i] === '`') {
i = skipString(str, i) + 1;
continue;
}
if (str[i] === '(') depth++;
else if (str[i] === ')') depth--;
if (depth > 0) i++;
}
if (depth !== 0) throw new Error('Unclosed ( in template');
return i;
}
function findClosingBrace(str, openPos) {
let depth = 1;
let i = openPos + 1;
while (i < str.length && depth > 0) {
if (str[i] === "'" || str[i] === '"' || str[i] === '`') {
i = skipString(str, i) + 1;
continue;
}
if (str[i] === '{' && str[i + 1] === '{') {
const end = str.indexOf('}}', i + 2);
if (end === -1) throw new Error('Unclosed {{ }} in template');
i = end + 2;
continue;
}
if (str[i] === '{') depth++;
else if (str[i] === '}') depth--;
if (depth > 0) i++;
}
if (depth !== 0) throw new Error('Unclosed { in template');
return i;
}
function findTopLevelComma(str) {
let depth = 0;
for (let i = 0; i < str.length; i++) {
if (str[i] === "'" || str[i] === '"' || str[i] === '`') {
i = skipString(str, i);
continue;
}
if (str[i] === '(') depth++;
else if (str[i] === ')') depth--;
else if (str[i] === ',' && depth === 0) return i;
}
return -1;
}
function formatNumber(value, spec) {
const hasThousands = spec.includes(',');
const dotIdx = spec.indexOf('.');
let decimals = 0;
if (dotIdx !== -1) {
decimals = spec.length - dotIdx - 1;
}
let result = value.toFixed(decimals);
if (hasThousands) {
const parts = result.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
result = parts.join('.');
}
if (!spec.includes('.') && !hasThousands) {
const padLength = spec.replace(/[^0]/g, '').length;
if (padLength > 0) {
const isNeg = value < 0;
let abs = Math.abs(Math.round(value)).toString();
abs = abs.padStart(padLength, '0');
result = isNeg ? '-' + abs : abs;
}
}
return result;
}
function formatDate(date, spec) {
const pad = (n, len = 2) => String(n).padStart(len, '0');
const tokens = {
'yyyy': date.getFullYear(),
'yy': String(date.getFullYear()).slice(-2),
'MM': pad(date.getMonth() + 1),
'M': date.getMonth() + 1,
'dd': pad(date.getDate()),
'd': date.getDate(),
'HH': pad(date.getHours()),
'H': date.getHours(),
'hh': pad(date.getHours() % 12 || 12),
'h': date.getHours() % 12 || 12,
'mm': pad(date.getMinutes()),
'm': date.getMinutes(),
'ss': pad(date.getSeconds()),
's': date.getSeconds(),
'fff': pad(date.getMilliseconds(), 3),
};
let result = spec;
for (const [token, value] of Object.entries(tokens).sort((a, b) => b[0].length - a[0].length)) {
result = result.replaceAll(token, String(value));
}
return result;
}
function createFormatter(formatSpec) {
return (value) => {
if (value == null) return '';
if (value instanceof Date) return formatDate(value, formatSpec);
if (typeof value === 'number') return formatNumber(value, formatSpec);
return String(value);
};
}
class StaticTemplatePart {
constructor(text) {
this.text = text;
}
render(_data) {
return this.text;
}
}
class VariableTemplatePart {
constructor(contextPath, formatter) {
this.contextPath = contextPath;
this.formatter = formatter || (x => x);
}
render(data) {
return this.formatter(selectPath(data, this.contextPath));
}
}
class LetTemplatePart {
constructor(name, expression) {
this.name = name;
this.expression = expression;
}
declareScoped(scope, data) {
scope.set(this.name, selectPath(data, this.expression));
}
render(_data) {
return '';
}
}
class ListTemplatePart {
constructor(varNames, contextPath, innerTemplate, emptyTemplate, formatter) {
this.varNames = varNames;
this.contextPath = contextPath;
this.formatter = formatter || (x => x);
this.innerTemplate = innerTemplate;
this.emptyTemplate = emptyTemplate;
}
render(data) {
const list = this.formatter(selectPath(data, this.contextPath));
const arr = Array.isArray(list) ? list : Array.from(list);
if (arr.length === 0) {
return this.emptyTemplate ? this.emptyTemplate.render(data) : '';
}
let result = '';
const count = arr.length;
for (let index = 0; index < count; index++) {
const scope = new ScopeDict();
const item = arr[index];
if (this.varNames.length === 1) {
scope.setBuiltin(this.varNames[0], item);
} else {
for (let v = 0; v < this.varNames.length; v++) {
scope.setBuiltin(this.varNames[v], item[v]);
}
}
scope.setBuiltin('$index', index);
scope.setBuiltin('$first', index === 0);
scope.setBuiltin('$last', index === count - 1);
scope.setBuiltin('$even', index % 2 === 0);
scope.setBuiltin('$odd', index % 2 === 1);
scope.setBuiltin('$count', count);
result += this.innerTemplate.render(data, scope);
}
return result;
}
}
class ConditionalTemplatePart {
constructor(contextPath, condition, trueTemplate, falseTemplate, formatter) {
this.contextPath = contextPath;
this.formatter = formatter || (x => x);
this.condition = condition;
this.trueTemplate = trueTemplate;
this.falseTemplate = falseTemplate;
}
render(data) {
const selectedData = this.formatter(selectPath(data, this.contextPath));
if (this.condition(selectedData)) {
return this.trueTemplate ? this.trueTemplate.render(data) : '';
} else {
return this.falseTemplate ? this.falseTemplate.render(data) : '';
}
}
}
export class Template {
constructor(templateString) {
if (templateString != null) {
this.parts = this._parseBlock(templateString);
}
}
_parseBlock(str) {
const parts = [];
let i = 0;
while (i < str.length) {
// {{ expression }} or {{ expression:format }}
if (str[i] === '{' && str[i + 1] === '{') {
const end = str.indexOf('}}', i + 2);
if (end === -1) throw new Error('Unclosed {{ }}');
const content = str.substring(i + 2, end).trim();
let expression, formatter = null;
const colonIdx = content.lastIndexOf(':');
if (colonIdx > 0) {
const possibleFormat = content.substring(colonIdx + 1).trim();
if (/^[0#.,yMdHhmsafzZ\-\/: ]+$/.test(possibleFormat)) {
expression = content.substring(0, colonIdx).trim();
formatter = createFormatter(possibleFormat);
} else {
expression = content;
}
} else {
expression = content;
}
parts.push(new VariableTemplatePart(expression, formatter));
i = end + 2;
continue;
}
// @for(item of expression){...} @empty{...}
if (str.startsWith('@for(', i)) {
const parenOpen = i + 4;
const parenClose = findClosingParen(str, parenOpen);
const forClause = str.substring(parenOpen + 1, parenClose).trim();
const ofIdx = forClause.indexOf(' of ');
if (ofIdx === -1) throw new Error('@for missing "of": @for(item of expression)');
const varNames = forClause.substring(0, ofIdx).split(',').map(v => v.trim());
const expression = forClause.substring(ofIdx + 4).trim();
const braceOpen = str.indexOf('{', parenClose + 1);
if (braceOpen === -1) throw new Error('@for missing block');
const braceClose = findClosingBrace(str, braceOpen);
const blockContent = str.substring(braceOpen + 1, braceClose);
const innerTemplate = new Template(null);
innerTemplate.parts = this._parseBlock(blockContent);
let emptyTemplate = null;
let afterBlock = braceClose + 1;
const remaining = str.substring(afterBlock);
const emptyMatch = remaining.match(/^\s*@empty\s*\{/);
if (emptyMatch) {
const emptyBraceOpen = afterBlock + emptyMatch[0].length - 1;
const emptyBraceClose = findClosingBrace(str, emptyBraceOpen);
const emptyContent = str.substring(emptyBraceOpen + 1, emptyBraceClose);
emptyTemplate = new Template(null);
emptyTemplate.parts = this._parseBlock(emptyContent);
afterBlock = emptyBraceClose + 1;
}
parts.push(new ListTemplatePart(varNames, expression, innerTemplate, emptyTemplate));
i = afterBlock;
continue;
}
// @if(expression){...}@else{...}
if (str.startsWith('@if(', i)) {
const parenOpen = i + 3;
const parenClose = findClosingParen(str, parenOpen);
const expression = str.substring(parenOpen + 1, parenClose).trim();
const trueBraceOpen = str.indexOf('{', parenClose + 1);
if (trueBraceOpen === -1) throw new Error('@if missing block');
const trueBraceClose = findClosingBrace(str, trueBraceOpen);
const trueContent = str.substring(trueBraceOpen + 1, trueBraceClose);
const trueTemplate = new Template(null);
trueTemplate.parts = this._parseBlock(trueContent);
let falseTemplate = null;
let afterBlock = trueBraceClose + 1;
const remaining = str.substring(afterBlock);
const elseMatch = remaining.match(/^\s*@else\s*\{/);
if (elseMatch) {
const falseBraceOpen = afterBlock + elseMatch[0].length - 1;
const falseBraceClose = findClosingBrace(str, falseBraceOpen);
const falseContent = str.substring(falseBraceOpen + 1, falseBraceClose);
falseTemplate = new Template(null);
falseTemplate.parts = this._parseBlock(falseContent);
afterBlock = falseBraceClose + 1;
}
parts.push(new ConditionalTemplatePart(expression, val => !!val, trueTemplate, falseTemplate));
i = afterBlock;
continue;
}
// @let name = expression (ends at newline or end of string)
if (str.startsWith('@let ', i)) {
const lineEnd = str.indexOf('\n', i);
const end = lineEnd === -1 ? str.length : lineEnd;
const declaration = str.substring(i + 5, end).trim();
const eqIdx = declaration.indexOf('=');
if (eqIdx === -1) throw new Error('Invalid @let: missing =');
const name = declaration.substring(0, eqIdx).trim();
const expression = declaration.substring(eqIdx + 1).trim();
parts.push(new LetTemplatePart(name, expression));
i = end + 1;
continue;
}
// Static text — collect until next special token
let end = i;
while (end < str.length) {
if (str[end] === '{' && str[end + 1] === '{') break;
if (str[end] === '@' && (
str.startsWith('@for(', end) ||
str.startsWith('@if(', end) ||
str.startsWith('@let ', end)
)) break;
end++;
}
if (end > i) {
parts.push(new StaticTemplatePart(str.substring(i, end)));
i = end;
}
}
return parts;
}
render(data, parentScope = new ScopeDict()) {
const scope = parentScope.clone();
const proxy = createScopeProxy(data, scope);
let result = '';
for (const part of this.parts) {
part.declareScoped?.(scope, proxy);
result += part.render(proxy);
}
return result;
}
}

View File

@ -0,0 +1,57 @@
import { Template } from './template.js';
// 1. Basic variable
let t = new Template('Hello {{ name }}!');
console.log('1:', t.render({ name: 'World' }));
// 2. Format specifier
t = new Template('Price: {{ price:0.00 }}');
console.log('2:', t.render({ price: 9.5 }));
// 3. @let
t = new Template('@let short = user.name\nHello {{ short }}!');
console.log('3:', t.render({ user: { name: 'Alice' } }));
// 4. @for with separator via @if(!$first)
t = new Template('@for(item of items){@if(!$first){, }{{ item }}}');
console.log('4:', t.render({ items: ['a', 'b', 'c'] }));
// 5/6. @if / @else
t = new Template('@if(admin){Admin}@else{User}');
console.log('5:', t.render({ admin: true }));
console.log('6:', t.render({ admin: false }));
// 7. Expression
t = new Template('{{ name.toUpperCase() }}');
console.log('7:', t.render({ name: 'alice' }));
// 8. $index
t = new Template('@for(item of items){ {{ $index }}: {{ item }} }');
console.log('8:', t.render({ items: ['x', 'y'] }));
// 9. Multiple @let
t = new Template('@let a = first\n@let b = second\n{{ a }} and {{ b }}');
console.log('9:', t.render({ first: 'one', second: 'two' }));
// 10. Nested for
t = new Template('@for(group of groups){ @for(item of group.items){ {{ item }} } }');
console.log('10:', t.render({ groups: [{ items: [1, 2] }, { items: [3, 4] }] }));
// 11. @empty block
t = new Template('@for(item of items){ {{ item }} } @empty{No items}');
console.log('11a:', t.render({ items: [] }));
console.log('11b:', t.render({ items: ['x'] }));
// 12. $first, $last, $even, $odd, $count
t = new Template('@for(item of items){[{{ item }} first={{ $first }} last={{ $last }} even={{ $even }} odd={{ $odd }} count={{ $count }}]}');
console.log('12:', t.render({ items: ['a', 'b', 'c'] }));
// 13. $this to access the contexts root object
t = new Template('@for(item of $this){ {{ item }} } @empty{No items}');
console.log('13a:', t.render([]));
console.log('13b:', t.render(['x']));
// 14. @for with multiple variables (destructuring)
t = new Template('@for(key, value of Object.entries(obj)){@if(!$first){, }{{ key }}={{ value }}}');
console.log('14:', t.render({ obj: { a: 1, b: 2, c: 3 } }));

View File

@ -33,14 +33,14 @@
<p class="login-subtitle">Welcome back! Enter your details below.</p> <p class="login-subtitle">Welcome back! Enter your details below.</p>
<!-- Login form, id is used for JavaScript validation --> <!-- Login form, id is used for JavaScript validation -->
<form id="loginForm" class="login-form"> <form id="loginForm" class="login-form" action="/api/v1/auth/login?redirect=/dashboard" method="post">
<div class="form-group"> <div class="form-group">
<label for="email">Email Address</label> <label for="email">Email Address</label>
<input <input
type="email" type="email"
id="email" id="email"
name="email" name="userNameOrEmail"
placeholder="yourname@email.com" placeholder="yourname@email.com"
required required
> >
@ -72,8 +72,7 @@
</p> </p>
</section> </section>
</main> </main>
<script type="module" src="js/login.js"></script>
<!-- Script for login logic and form validation -->
<script src="../js/login.js"></script>
</body> </body>
</html> </html>