feat: Reinitialize frontend with SvelteKit and TypeScript

- Delete old Vite+Svelte frontend
- Initialize new SvelteKit project with TypeScript
- Configure Tailwind CSS v4 + DaisyUI
- Implement JWT authentication with auto-refresh
- Create login page with form validation (Zod)
- Add protected route guards
- Update Docker configuration for single-stage build
- Add E2E tests with Playwright (6/11 passing)
- Fix Svelte 5 reactivity with $state() runes

Known issues:
- 5 E2E tests failing (timing/async issues)
- Token refresh implementation needs debugging
- Validation error display timing
This commit is contained in:
2026-02-17 16:19:59 -05:00
parent 54df6018f5
commit de2d83092e
28274 changed files with 3816354 additions and 90 deletions

View File

@@ -0,0 +1,3 @@
export * from './style-attribute.js';
export * from './resource.js';
export * from './utils.js';

View File

@@ -0,0 +1,3 @@
export * from './style-attribute.js';
export * from './resource.js';
export * from './utils.js';

View File

@@ -0,0 +1 @@
export declare const SHORTHAND_PROPERTIES: Map<string, string[]>;

View File

@@ -0,0 +1,153 @@
export const SHORTHAND_PROPERTIES = new Map([
['margin', ['margin-top', 'margin-bottom', 'margin-left', 'margin-right']],
['padding', ['padding-top', 'padding-bottom', 'padding-left', 'padding-right']],
[
'background',
[
'background-image',
'background-size',
'background-position',
'background-repeat',
'background-origin',
'background-clip',
'background-attachment',
'background-color'
]
],
[
'font',
[
'font-style',
'font-variant',
'font-weight',
'font-stretch',
'font-size',
'font-family',
'line-height'
]
],
[
'border',
[
'border-top-width',
'border-bottom-width',
'border-left-width',
'border-right-width',
'border-top-style',
'border-bottom-style',
'border-left-style',
'border-right-style',
'border-top-color',
'border-bottom-color',
'border-left-color',
'border-right-color'
]
],
['border-top', ['border-top-width', 'border-top-style', 'border-top-color']],
['border-bottom', ['border-bottom-width', 'border-bottom-style', 'border-bottom-color']],
['border-left', ['border-left-width', 'border-left-style', 'border-left-color']],
['border-right', ['border-right-width', 'border-right-style', 'border-right-color']],
[
'border-width',
['border-top-width', 'border-bottom-width', 'border-left-width', 'border-right-width']
],
[
'border-style',
['border-top-style', 'border-bottom-style', 'border-left-style', 'border-right-style']
],
[
'border-color',
['border-top-color', 'border-bottom-color', 'border-left-color', 'border-right-color']
],
['list-style', ['list-style-type', 'list-style-position', 'list-style-image']],
[
'border-radius',
[
'border-top-right-radius',
'border-top-left-radius',
'border-bottom-right-radius',
'border-bottom-left-radius'
]
],
[
'transition',
['transition-delay', 'transition-duration', 'transition-property', 'transition-timing-function']
],
[
'animation',
[
'animation-name',
'animation-duration',
'animation-timing-function',
'animation-delay',
'animation-iteration-count',
'animation-direction',
'animation-fill-mode',
'animation-play-state'
]
],
[
'border-block-end',
['border-block-end-width', 'border-block-end-style', 'border-block-end-color']
],
[
'border-block-start',
['border-block-start-width', 'border-block-start-style', 'border-block-start-color']
],
[
'border-image',
[
'border-image-source',
'border-image-slice',
'border-image-width',
'border-image-outset',
'border-image-repeat'
]
],
[
'border-inline-end',
['border-inline-end-width', 'border-inline-end-style', 'border-inline-end-color']
],
[
'border-inline-start',
['border-inline-start-width', 'border-inline-start-style', 'border-inline-start-color']
],
['column-rule', ['column-rule-width', 'column-rule-style', 'column-rule-color']],
['columns', ['column-width', 'column-count']],
['flex', ['flex-grow', 'flex-shrink', 'flex-basis']],
['flex-flow', ['flex-direction', 'flex-wrap']],
[
'grid',
[
'grid-template-rows',
'grid-template-columns',
'grid-template-areas',
'grid-auto-rows',
'grid-auto-columns',
'grid-auto-flow',
'grid-column-gap',
'grid-row-gap'
]
],
['grid-area', ['grid-row-start', 'grid-column-start', 'grid-row-end', 'grid-column-end']],
['grid-column', ['grid-column-start', 'grid-column-end']],
['grid-gap', ['grid-row-gap', 'grid-column-gap']],
['grid-row', ['grid-row-start', 'grid-row-end']],
['grid-template', ['grid-template-columns', 'grid-template-rows', 'grid-template-areas']],
['outline', ['outline-color', 'outline-style', 'outline-width']],
['text-decoration', ['text-decoration-color', 'text-decoration-style', 'text-decoration-line']],
['text-emphasis', ['text-emphasis-style', 'text-emphasis-color']],
[
'mask',
[
'mask-image',
'mask-mode',
'mask-position',
'mask-size',
'mask-repeat',
'mask-origin',
'mask-clip',
'mask-composite'
]
]
]);

View File

@@ -0,0 +1,46 @@
import type { AST } from 'svelte-eslint-parser';
import type { RuleContext } from '../../types.js';
import type { TSESTree } from '@typescript-eslint/types';
/**
* Parse style attribute value
*/
export declare function parseStyleAttributeValue(node: AST.SvelteAttribute, context: RuleContext): SvelteStyleRoot<AST.SvelteMustacheTagText> | null;
export type SvelteStyleInterpolation = AST.SvelteMustacheTagText | TSESTree.Expression;
export interface SvelteStyleNode<E extends SvelteStyleInterpolation> {
nodes?: SvelteStyleChildNode<E>[];
range: AST.Range;
loc: AST.SourceLocation;
}
export interface SvelteStyleRoot<E extends SvelteStyleInterpolation = SvelteStyleInterpolation> {
type: 'root';
nodes: (SvelteStyleChildNode<E> | SvelteStyleInline<E>)[];
}
export interface SvelteStyleInline<E extends SvelteStyleInterpolation = SvelteStyleInterpolation> extends SvelteStyleNode<E> {
type: 'inline';
node: E;
getInlineStyle(node: TSESTree.Expression): SvelteStyleRoot<TSESTree.Expression> | null;
getAllInlineStyles(): Map<TSESTree.Expression, SvelteStyleRoot<TSESTree.Expression>>;
}
export interface SvelteStyleDeclaration<E extends SvelteStyleInterpolation = SvelteStyleInterpolation> extends SvelteStyleNode<E> {
type: 'decl';
prop: {
name: string;
range: AST.Range;
loc: AST.SourceLocation;
interpolations: E[];
};
value: {
value: string;
range: AST.Range;
loc: AST.SourceLocation;
interpolations: E[];
};
important: boolean;
addInterpolation: (tagOrExpr: E) => void;
unknownInterpolations: E[];
}
export interface SvelteStyleComment extends SvelteStyleNode<never> {
type: 'comment';
addInterpolation: (tagOrExpr: SvelteStyleInterpolation) => void;
}
export type SvelteStyleChildNode<E extends SvelteStyleInterpolation = SvelteStyleInterpolation> = SvelteStyleDeclaration<E> | SvelteStyleComment;

View File

@@ -0,0 +1,266 @@
import Parser from './template-safe-parser.js';
import { Input } from 'postcss';
/** Parse for CSS */
function safeParseCss(css) {
try {
const input = new Input(css);
const parser = new Parser(input);
parser.parse();
return parser.root;
}
catch {
return null;
}
}
const cache = new WeakMap();
/**
* Parse style attribute value
*/
export function parseStyleAttributeValue(node, context) {
if (cache.has(node)) {
return cache.get(node) || null;
}
cache.set(node, null);
if (!node.value?.length) {
return null;
}
const startOffset = node.value[0].range[0];
const sourceCode = context.sourceCode;
const cssCode = node.value.map((value) => sourceCode.getText(value)).join('');
const root = safeParseCss(cssCode);
if (!root) {
return root;
}
const ctx = {
startOffset,
value: node.value,
context
};
const mustacheTags = node.value.filter((v) => v.type === 'SvelteMustacheTag');
const converted = convertRoot(root, mustacheTags, (e) => e.range, ctx);
cache.set(node, converted);
return converted;
}
class IgnoreError extends Error {
}
/** Checks wether the given node is string literal or not */
function isStringLiteral(node) {
return node.type === 'Literal' && typeof node.value === 'string';
}
/** convert root node */
function convertRoot(root, interpolations, getRange, ctx) {
const nodes = [];
for (const child of root.nodes) {
const converted = convertChild(child, ctx);
if (!converted) {
return null;
}
while (interpolations[0]) {
const tagOrExpr = interpolations[0];
if (tagOrExpr.range[1] <= converted.range[0]) {
nodes.push(buildSvelteStyleInline(tagOrExpr));
interpolations.shift();
continue;
}
if (tagOrExpr.range[0] < converted.range[1]) {
try {
converted.addInterpolation(tagOrExpr);
}
catch (e) {
if (e instanceof IgnoreError)
return null;
throw e;
}
interpolations.shift();
continue;
}
break;
}
nodes.push(converted);
}
nodes.push(...interpolations.map(buildSvelteStyleInline));
return {
type: 'root',
nodes
};
/** Build SvelteStyleInline */
function buildSvelteStyleInline(tagOrExpr) {
const inlineStyles = new Map();
let range = null;
/** Get range */
function getRangeForInline() {
if (range) {
return range;
}
return range ?? (range = getRange(tagOrExpr));
}
return {
type: 'inline',
node: tagOrExpr,
get range() {
return getRangeForInline();
},
get loc() {
return toLoc(getRangeForInline(), ctx);
},
getInlineStyle(node) {
return getInlineStyle(node);
},
getAllInlineStyles() {
const allInlineStyles = new Map();
for (const node of extractExpressions(tagOrExpr)) {
const style = getInlineStyle(node);
if (style) {
allInlineStyles.set(node, style);
}
}
return allInlineStyles;
}
};
/** Get inline style node */
function getInlineStyle(node) {
if (node.type === 'SvelteMustacheTag') {
return getInlineStyle(node.expression);
}
if (inlineStyles.has(node)) {
return inlineStyles.get(node) || null;
}
const sourceCode = ctx.context.sourceCode;
inlineStyles.set(node, null);
let converted;
if (isStringLiteral(node)) {
const root = safeParseCss(sourceCode.getText(node).slice(1, -1));
if (!root) {
return null;
}
converted = convertRoot(root, [], () => [0, 0], {
...ctx,
startOffset: node.range[0] + 1
});
}
else if (node.type === 'TemplateLiteral') {
const root = safeParseCss(sourceCode.getText(node).slice(1, -1));
if (!root) {
return null;
}
converted = convertRoot(root, [...node.expressions], (e) => {
const index = node.expressions.indexOf(e);
return [node.quasis[index].range[1] - 2, node.quasis[index + 1].range[0] + 1];
}, {
...ctx,
startOffset: node.range[0] + 1
});
}
else {
return null;
}
inlineStyles.set(node, converted);
return converted;
}
/** Extract all expressions */
function* extractExpressions(node) {
if (node.type === 'SvelteMustacheTag') {
yield* extractExpressions(node.expression);
}
else if (isStringLiteral(node)) {
yield node;
}
else if (node.type === 'TemplateLiteral') {
yield node;
}
else if (node.type === 'ConditionalExpression') {
yield* extractExpressions(node.consequent);
yield* extractExpressions(node.alternate);
}
else if (node.type === 'LogicalExpression') {
yield* extractExpressions(node.left);
yield* extractExpressions(node.right);
}
}
}
}
/** convert child node */
function convertChild(node, ctx) {
const range = convertRange(node, ctx);
if (node.type === 'decl') {
const propRange = [range[0], range[0] + node.prop.length];
const declValueStartIndex = propRange[1] + (node.raws.between || '').length;
const valueRange = [
declValueStartIndex,
declValueStartIndex + (node.raws.value?.value || node.value).length
];
const prop = {
name: node.prop,
range: propRange,
get loc() {
return toLoc(propRange, ctx);
},
interpolations: []
};
const value = {
value: node.value,
range: valueRange,
get loc() {
return toLoc(valueRange, ctx);
},
interpolations: []
};
const unknownInterpolations = [];
return {
type: 'decl',
prop,
value,
important: node.important,
range,
get loc() {
return toLoc(range, ctx);
},
addInterpolation(tagOrExpr) {
const index = tagOrExpr.range[0];
if (prop.range[0] <= index && index < prop.range[1]) {
prop.interpolations.push(tagOrExpr);
return;
}
if (value.range[0] <= index && index < value.range[1]) {
value.interpolations.push(tagOrExpr);
return;
}
unknownInterpolations.push(tagOrExpr);
},
unknownInterpolations
};
}
if (node.type === 'comment') {
return {
type: 'comment',
range,
get loc() {
return toLoc(range, ctx);
},
addInterpolation: () => {
throw new IgnoreError();
}
};
}
if (node.type === 'atrule') {
// unexpected node
return null;
}
if (node.type === 'rule') {
// unexpected node
return null;
}
// unknown node
return null;
}
/** convert range */
function convertRange(node, ctx) {
return [ctx.startOffset + node.source.start.offset, ctx.startOffset + node.source.end.offset];
}
/** convert range */
function toLoc(range, ctx) {
return {
start: ctx.context.sourceCode.getLocFromIndex(range[0]),
end: ctx.context.sourceCode.getLocFromIndex(range[1])
};
}

View File

@@ -0,0 +1,5 @@
import SafeParser from 'postcss-safe-parser/lib/safe-parser.js';
declare class TemplateSafeParser extends SafeParser {
protected createTokenizer(): void;
}
export default TemplateSafeParser;

View File

@@ -0,0 +1,8 @@
import SafeParser from 'postcss-safe-parser/lib/safe-parser.js';
import templateTokenize from './template-tokenize.js';
class TemplateSafeParser extends SafeParser {
createTokenizer() {
this.tokenizer = templateTokenize(this.input, { ignoreErrors: true });
}
}
export default TemplateSafeParser;

View File

@@ -0,0 +1,6 @@
import type { Tokenizer } from 'postcss/lib/tokenize';
import tokenize from 'postcss/lib/tokenize';
type Tokenize = typeof tokenize;
/** Tokenize */
declare function templateTokenize(...args: Parameters<Tokenize>): Tokenizer;
export default templateTokenize;

View File

@@ -0,0 +1,36 @@
import tokenize from 'postcss/lib/tokenize';
/** Tokenize */
function templateTokenize(...args) {
const tokenizer = tokenize(...args);
/** nextToken */
function nextToken(...args) {
const returned = [];
let token, lastPos;
let depth = 0;
while ((token = tokenizer.nextToken(...args))) {
if (token[0] !== 'word') {
if (token[0] === '{') {
++depth;
}
else if (token[0] === '}') {
--depth;
}
}
if (depth || returned.length) {
lastPos = token[3] || token[2] || lastPos;
returned.push(token);
}
if (!depth) {
break;
}
}
if (returned.length) {
token = ['word', returned.map((token) => token[1]).join(''), returned[0][2], lastPos];
}
return token;
}
return Object.assign({}, tokenizer, {
nextToken
});
}
export default templateTokenize;

View File

@@ -0,0 +1,12 @@
/**
* Checks whether given property name has vender prefix
*/
export declare function hasVendorPrefix(prop: string): boolean;
/**
* Get the vender prefix from given property name
*/
export declare function getVendorPrefix(prop: string): string;
/**
* Strip the vender prefix
*/
export declare function stripVendorPrefix(prop: string): string;

View File

@@ -0,0 +1,18 @@
/**
* Checks whether given property name has vender prefix
*/
export function hasVendorPrefix(prop) {
return Boolean(getVendorPrefix(prop));
}
/**
* Get the vender prefix from given property name
*/
export function getVendorPrefix(prop) {
return /^-\w+-/u.exec(prop)?.[0] || '';
}
/**
* Strip the vender prefix
*/
export function stripVendorPrefix(prop) {
return prop.slice(getVendorPrefix(prop).length);
}