diff --git a/OnlyPrompt.Backend/Controllers/AuthController.cs b/OnlyPrompt.Backend/Controllers/AuthController.cs index 23dbe08..292354f 100644 --- a/OnlyPrompt.Backend/Controllers/AuthController.cs +++ b/OnlyPrompt.Backend/Controllers/AuthController.cs @@ -31,7 +31,7 @@ namespace OnlyPrompt.Backend.Controllers [AllowAnonymous] [HttpPost("login")] - public async Task, NotFound>> LoginAsync([FromBody] ApiLoginRequest request) + public async Task, NotFound>> LoginAsync([FromBody] ApiLoginRequest request, [FromQuery]string redirect = null) { var user = await FindUserAsync(request.UserNameOrEmail); if (user is null) @@ -46,6 +46,9 @@ namespace OnlyPrompt.Backend.Controllers var token = _jwtService.BuildToken(user, out var 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(); } diff --git a/OnlyPrompt.Backend/Dockerfile b/OnlyPrompt.Backend/Dockerfile index 377780c..a730896 100644 --- a/OnlyPrompt.Backend/Dockerfile +++ b/OnlyPrompt.Backend/Dockerfile @@ -13,6 +13,7 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["OnlyPrompt.Backend/OnlyPrompt.Backend.csproj", "OnlyPrompt.Backend/"] +ADD ["OnlyPrompt.Frontend", "OnlyPrompt.Backend/wwwroot"] RUN dotnet restore "./OnlyPrompt.Backend/OnlyPrompt.Backend.csproj" COPY . . WORKDIR "/src/OnlyPrompt.Backend" diff --git a/OnlyPrompt.Backend/OnlyPrompt.Backend.csproj b/OnlyPrompt.Backend/OnlyPrompt.Backend.csproj index b236d9e..c0ff109 100644 --- a/OnlyPrompt.Backend/OnlyPrompt.Backend.csproj +++ b/OnlyPrompt.Backend/OnlyPrompt.Backend.csproj @@ -30,6 +30,7 @@ + diff --git a/OnlyPrompt.Backend/Program.cs b/OnlyPrompt.Backend/Program.cs index 937bdfa..b2d4ef9 100644 --- a/OnlyPrompt.Backend/Program.cs +++ b/OnlyPrompt.Backend/Program.cs @@ -1,7 +1,10 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Rewrite; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Options; using Microsoft.Identity.Web; using Microsoft.IdentityModel.Tokens; @@ -11,6 +14,7 @@ using OnlyPrompt.Backend.Services.Jwt; using OnlyPrompt.Backend.Utils; using Scalar.AspNetCore; using System.Text; +using System.Text.Json; var builder = WebApplication.CreateBuilder(args); var config = builder.Configuration; @@ -25,10 +29,14 @@ builder.Services.AddDbContext(opts => builder.Services.AddSingleton, PasswordHasher>(); builder.Services.AddSingleton(); builder.Services.AddAutoMapper(AutoMapperSetup.Setup); - +builder.Services.AddValidation(opts => +{ + opts.MaxDepth = 10; +}); builder.Services.AddAuthorization(); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, opts => { + .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, opts => + { opts.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, @@ -45,13 +53,16 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) { if (context.Request.Cookies.ContainsKey("jwt")) context.Token = context.Request.Cookies["jwt"]; - + return Task.CompletedTask; } }; }); -builder.Services.AddControllers(); +builder.Services.AddControllers().AddJsonOptions(jsonOpts => +{ + jsonOpts.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; +}); builder.Services.AddOpenApi(opts => opts.AddScalarTransformers()); var app = builder.Build(); @@ -65,10 +76,28 @@ if (app.Environment.IsDevelopment()) app.UseHttpsRedirection(); +var rewrite = new RewriteOptions() + .AddRewrite(@"^(?!scalar\/?|api\/?)([^.]+)$", "$1.html", skipRemainingRules: true); + +app.UseRewriter(rewrite); 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.MapFallbackToFile("/login.html"); using var scope = app.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await db.Database.MigrateAsync(); diff --git a/OnlyPrompt.Frontend/.vscode/launch.json b/OnlyPrompt.Frontend/.vscode/launch.json new file mode 100644 index 0000000..5798a26 --- /dev/null +++ b/OnlyPrompt.Frontend/.vscode/launch.json @@ -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}" + } + ] +} \ No newline at end of file diff --git a/OnlyPrompt.Frontend/.vscode/settings.json b/OnlyPrompt.Frontend/.vscode/settings.json new file mode 100644 index 0000000..d3e01d5 --- /dev/null +++ b/OnlyPrompt.Frontend/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "editor.tabSize": 2, + "editor.indentSize": 2 +} diff --git a/OnlyPrompt.Frontend/html/chats.html b/OnlyPrompt.Frontend/chats.html similarity index 100% rename from OnlyPrompt.Frontend/html/chats.html rename to OnlyPrompt.Frontend/chats.html diff --git a/OnlyPrompt.Frontend/html/community.html b/OnlyPrompt.Frontend/community.html similarity index 100% rename from OnlyPrompt.Frontend/html/community.html rename to OnlyPrompt.Frontend/community.html diff --git a/OnlyPrompt.Frontend/html/create.html b/OnlyPrompt.Frontend/create.html similarity index 100% rename from OnlyPrompt.Frontend/html/create.html rename to OnlyPrompt.Frontend/create.html diff --git a/OnlyPrompt.Frontend/css/base.css b/OnlyPrompt.Frontend/css/base.css index 333cd79..493f50e 100644 --- a/OnlyPrompt.Frontend/css/base.css +++ b/OnlyPrompt.Frontend/css/base.css @@ -16,4 +16,26 @@ body { background: var(--bg); 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; } \ No newline at end of file diff --git a/OnlyPrompt.Frontend/html/dashboard.html b/OnlyPrompt.Frontend/dashboard.html similarity index 100% rename from OnlyPrompt.Frontend/html/dashboard.html rename to OnlyPrompt.Frontend/dashboard.html diff --git a/OnlyPrompt.Frontend/js/linq.js b/OnlyPrompt.Frontend/js/linq.js new file mode 100644 index 0000000..9308259 --- /dev/null +++ b/OnlyPrompt.Frontend/js/linq.js @@ -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; + }); +} \ No newline at end of file diff --git a/OnlyPrompt.Frontend/js/login.js b/OnlyPrompt.Frontend/js/login.js new file mode 100644 index 0000000..b4792f9 --- /dev/null +++ b/OnlyPrompt.Frontend/js/login.js @@ -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(); +}); \ No newline at end of file diff --git a/OnlyPrompt.Frontend/js/shared.js b/OnlyPrompt.Frontend/js/shared.js new file mode 100644 index 0000000..7f3e57e --- /dev/null +++ b/OnlyPrompt.Frontend/js/shared.js @@ -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(` +
+ An error occurred while submitting the form. Please try again later. + {{ $this }} +
+`); + +function handleGenericFormError(response, responseText, form) { + if (!response.ok) { + const html = genericFormErrorTemplate.render(responseText); + form.insertAdjacentHTML('beforeend', html); + } +} + +const validationErrorTemplate = new Template(` +
+
    + @for(error of $this) { +
  • {{error}}
  • + } +
+
+`); + +const unknownInputErrorTemplate = new Template(` +
+

An error occurred with the following fields:

+ @for(field, errors of Object.entries($this)) { +
    + @for(error of errors) { +
  • {{field}}: {{error}}
  • + } +
+ } +
+`); + +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; +} \ No newline at end of file diff --git a/OnlyPrompt.Frontend/js/template.js b/OnlyPrompt.Frontend/js/template.js new file mode 100644 index 0000000..3bc8e50 --- /dev/null +++ b/OnlyPrompt.Frontend/js/template.js @@ -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; + } +} diff --git a/OnlyPrompt.Frontend/js/test-template.mjs b/OnlyPrompt.Frontend/js/test-template.mjs new file mode 100644 index 0000000..7a0fe85 --- /dev/null +++ b/OnlyPrompt.Frontend/js/test-template.mjs @@ -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 } })); \ No newline at end of file diff --git a/OnlyPrompt.Frontend/html/login.html b/OnlyPrompt.Frontend/login.html similarity index 91% rename from OnlyPrompt.Frontend/html/login.html rename to OnlyPrompt.Frontend/login.html index 9990740..bc64173 100644 --- a/OnlyPrompt.Frontend/html/login.html +++ b/OnlyPrompt.Frontend/login.html @@ -33,14 +33,14 @@ -