experimental pwa implementation
This commit is contained in:
parent
4eaea29513
commit
a2b898b54f
@ -1,140 +1,150 @@
|
||||
// LINQ-like Enumerable class wrapping lazy generator chains
|
||||
|
||||
class Enumerable {
|
||||
constructor(iteratorFn) {
|
||||
this._iteratorFn = iteratorFn;
|
||||
}
|
||||
constructor(iteratorFn) {
|
||||
this._iteratorFn = iteratorFn;
|
||||
}
|
||||
|
||||
[Symbol.iterator]() {
|
||||
return this._iteratorFn();
|
||||
}
|
||||
[Symbol.iterator]() {
|
||||
return this._iteratorFn();
|
||||
}
|
||||
|
||||
_chain(generatorFn) {
|
||||
const source = this;
|
||||
return new Enumerable(function* () {
|
||||
yield* generatorFn(source);
|
||||
});
|
||||
}
|
||||
_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;
|
||||
});
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
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++;
|
||||
}
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
forEach(action) {
|
||||
for (const item of this) {
|
||||
if (Array.isArray(item)) {
|
||||
action(...item);
|
||||
} else {
|
||||
action(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toArray() {
|
||||
return Array.from(this);
|
||||
}
|
||||
toArray() {
|
||||
return Array.from(this);
|
||||
}
|
||||
|
||||
firstOrDefault(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
for (const item of source) return item;
|
||||
return undefined;
|
||||
}
|
||||
orderByDescending(keySelector) {
|
||||
return this._chain(function* (source) {
|
||||
const items = Array.from(source);
|
||||
items.sort((a, b) => keySelector(b) - keySelector(a));
|
||||
yield* items;
|
||||
});
|
||||
}
|
||||
|
||||
first(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
for (const item of source) return item;
|
||||
throw new Error("No elements in sequence.");
|
||||
}
|
||||
orderBy(keySelector) {
|
||||
return this._chain(function* (source) {
|
||||
const items = Array.from(source);
|
||||
items.sort((a, b) => keySelector(a) - keySelector(b));
|
||||
yield* items;
|
||||
});
|
||||
}
|
||||
|
||||
lastOrDefault(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
let lastValue = undefined;
|
||||
for (const item of source) lastValue = item;
|
||||
return lastValue;
|
||||
}
|
||||
firstOrDefault(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
for (const item of source) return item;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
first(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
for (const item of source) return item;
|
||||
throw new Error("No elements in sequence.");
|
||||
}
|
||||
|
||||
any(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
for (const _ of source) return true;
|
||||
return false;
|
||||
}
|
||||
lastOrDefault(predicate) {
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
let lastValue = undefined;
|
||||
for (const item of source) lastValue = item;
|
||||
return lastValue;
|
||||
}
|
||||
|
||||
all(predicate) {
|
||||
for (const item of this)
|
||||
if (!predicate(item))
|
||||
return false;
|
||||
return true;
|
||||
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;
|
||||
}
|
||||
|
||||
count(predicate) {
|
||||
let count = 0;
|
||||
const source = predicate ? this.where(predicate) : this;
|
||||
for (const _ of source) count++;
|
||||
return count;
|
||||
}
|
||||
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;
|
||||
});
|
||||
}
|
||||
const arr = this;
|
||||
return new Enumerable(function* () {
|
||||
for (const item of arr) yield item;
|
||||
});
|
||||
};
|
||||
|
||||
410
OnlyPrompt.Frontend/js/pwa.js
Normal file
410
OnlyPrompt.Frontend/js/pwa.js
Normal file
@ -0,0 +1,410 @@
|
||||
import "./linq.js";
|
||||
import { Template } from "./template.js";
|
||||
|
||||
class RouteMatch {
|
||||
constructor(route, path, params) {
|
||||
this.route = route;
|
||||
this.path = path;
|
||||
this.params = params;
|
||||
this.segmentCount = route.fragments.length;
|
||||
}
|
||||
}
|
||||
|
||||
export class Route {
|
||||
constructor(pattern, componentClass) {
|
||||
this.id = crypto.randomUUID();
|
||||
this.componentClass = componentClass;
|
||||
this.ComponentDefinition = componentClass.definition;
|
||||
this.fragments = pattern.split("/");
|
||||
}
|
||||
|
||||
match(path) {
|
||||
const parsedUrl = new URL(path, window.location.origin);
|
||||
const params = {};
|
||||
parsedUrl.searchParams.forEach((value, key) => {
|
||||
params[key] = value;
|
||||
});
|
||||
|
||||
const pathFragments = parsedUrl.pathname
|
||||
.split("/")
|
||||
.filter((f) => f.length > 0);
|
||||
|
||||
for (let i = 0; i < this.fragments.length; i++) {
|
||||
const fragment = this.fragments[i];
|
||||
const pathFragment = pathFragments[i];
|
||||
if (fragment.startsWith(":")) {
|
||||
params[fragment.substring(1)] = pathFragment;
|
||||
continue;
|
||||
} else if (fragment === "*") {
|
||||
continue;
|
||||
} else if (fragment === "**") {
|
||||
return new RouteMatch(this, path, params);
|
||||
} else if (fragment !== pathFragment) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return new RouteMatch(this, path, params);
|
||||
}
|
||||
}
|
||||
|
||||
export class ComponentInput {
|
||||
constructor(
|
||||
defaultValue,
|
||||
options = {
|
||||
transform: (value) => value,
|
||||
validate: (value) => true,
|
||||
alias: null,
|
||||
},
|
||||
) {
|
||||
this._value = defaultValue;
|
||||
this.transform = options.transform;
|
||||
this.validate = options.validate;
|
||||
this.alias = options.alias;
|
||||
this._isComponentInput = true;
|
||||
const self = this;
|
||||
const fn = function () {
|
||||
return self._value;
|
||||
};
|
||||
return new Proxy(fn, {
|
||||
get(target, prop) {
|
||||
if (prop === Symbol.toPrimitive) return (hint) => self._value;
|
||||
if (prop === "valueOf") return () => self._value;
|
||||
if (prop in self) return self[prop];
|
||||
const inner = self._value;
|
||||
const value = inner[prop];
|
||||
return typeof value === "function" ? value.bind(inner) : value;
|
||||
},
|
||||
set(target, prop, value) {
|
||||
if (Object.prototype.hasOwnProperty.call(self, prop)) {
|
||||
self[prop] = value;
|
||||
return true;
|
||||
}
|
||||
self._value[prop] = value;
|
||||
return true;
|
||||
},
|
||||
apply(target, thisArg, args) {
|
||||
return self._value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
set(newValue) {
|
||||
const transformedValue = this.transform(newValue);
|
||||
if (!this.validate(transformedValue)) {
|
||||
throw new Error("Invalid value");
|
||||
}
|
||||
this._value = transformedValue;
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance](instance) {
|
||||
return instance?._isComponentInput === true;
|
||||
}
|
||||
}
|
||||
|
||||
export class ViewChild {
|
||||
constructor(options = { selector: null, id: null, multiple: false }) {
|
||||
this._selector = options.selector;
|
||||
this._id = options.id;
|
||||
this._multiple = options.multiple;
|
||||
this._element = null;
|
||||
this._isViewChild = true;
|
||||
return new Proxy(this, {
|
||||
get(target, prop) {
|
||||
if (prop in target) return target[prop];
|
||||
if (target._element) {
|
||||
const value = target._element[prop];
|
||||
return typeof value === "function"
|
||||
? value.bind(target._element)
|
||||
: value;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_setValue(element) {
|
||||
this._element = element;
|
||||
}
|
||||
|
||||
static [Symbol.hasInstance](instance) {
|
||||
return instance?._isViewChild === true;
|
||||
}
|
||||
}
|
||||
|
||||
export class ComponentDefinition {
|
||||
constructor(
|
||||
options = {
|
||||
templatesPath: null,
|
||||
template: null,
|
||||
stylesPath: null,
|
||||
scriptsPath: null,
|
||||
},
|
||||
) {
|
||||
this.templatesPath = options.templatesPath;
|
||||
this.template = options.template;
|
||||
this.stylesPath = options.stylesPath;
|
||||
this.scriptsPath = options.scriptsPath;
|
||||
}
|
||||
}
|
||||
|
||||
class EventListener {
|
||||
constructor(event, element, handler) {
|
||||
this.event = event;
|
||||
this.element = element;
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
attach() {
|
||||
this.element.addEventListener(this.event, this.handler);
|
||||
}
|
||||
|
||||
detach() {
|
||||
this.element.removeEventListener(this.event, this.handler);
|
||||
}
|
||||
}
|
||||
|
||||
export class Component extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this._eventListeners = [];
|
||||
return new Proxy(this, {
|
||||
get(target, prop, receiver) {
|
||||
const value = Reflect.get(target, prop, target);
|
||||
return typeof value === "function" && typeof value.bind === "function"
|
||||
? value.bind(receiver)
|
||||
: value;
|
||||
},
|
||||
set(target, prop, value) {
|
||||
if (target[prop] instanceof ComponentInput) {
|
||||
target[prop].set(value);
|
||||
} else if (target[prop] instanceof ViewChild) {
|
||||
target[prop]._setValue(value);
|
||||
} else {
|
||||
target[prop] = value;
|
||||
}
|
||||
|
||||
target.requestUpdate();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
requestUpdate() {
|
||||
this.dispatchEvent(new Event("requestUpdate", { component: this }));
|
||||
}
|
||||
|
||||
static get definition() {
|
||||
throw new Error("Component definition is not defined");
|
||||
}
|
||||
|
||||
onInit() {}
|
||||
onBeforeRender() {}
|
||||
onAfterRender() {}
|
||||
onDestroy() {
|
||||
this._eventListeners.forEach((listener) => listener.detach());
|
||||
}
|
||||
}
|
||||
|
||||
export function initRouter(outletId, includeId, routes) {
|
||||
const outletRef = document.getElementById(outletId);
|
||||
const includeRef = document.getElementById(includeId);
|
||||
if (!outletRef) {
|
||||
console.error(`Outlet element with id '${outletId}' not found`);
|
||||
return null;
|
||||
}
|
||||
if (!includeRef) {
|
||||
console.error(`Include element with id '${includeId}' not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Router(outletRef, includeRef, routes);
|
||||
}
|
||||
|
||||
export class Router {
|
||||
constructor(outletRef, includeRef, routes) {
|
||||
this.outletRef = outletRef;
|
||||
this.includeRef = includeRef;
|
||||
this.routes = routes;
|
||||
this.templateCache = new Map();
|
||||
outletRef.innerHTML = "Loading...";
|
||||
includeRef.innerHTML = "";
|
||||
navigation.addEventListener("navigate", this.handleNavigate.bind(this));
|
||||
}
|
||||
|
||||
findMatchingRoute(path) {
|
||||
const routeMatch = this.routes
|
||||
.asEnumerable()
|
||||
.select((route) => route.match(path))
|
||||
.where((match) => match !== null)
|
||||
.orderByDescending((match) => match.segmentCount)
|
||||
.firstOrDefault();
|
||||
|
||||
return routeMatch;
|
||||
}
|
||||
|
||||
async handleNavigate(event) {
|
||||
const routeMatch = this.findMatchingRoute(event.destination.url);
|
||||
if (routeMatch) {
|
||||
event.preventDefault();
|
||||
const template = await this.getCachedTemplate(routeMatch.route);
|
||||
const controller = this.createComponentInstance(
|
||||
routeMatch.route.componentClass,
|
||||
routeMatch.params,
|
||||
);
|
||||
this.destroyCurrent();
|
||||
this.activeController = controller;
|
||||
this.activeTemplate = template;
|
||||
this.insertIncludes(routeMatch.route);
|
||||
this.activeController.addEventListener(
|
||||
"requestUpdate",
|
||||
this.handleUpdateRequest.bind(this),
|
||||
);
|
||||
this.renderActiveComponent();
|
||||
}
|
||||
}
|
||||
|
||||
destroyCurrent() {
|
||||
if (this.activeController) {
|
||||
this.activeController.removeEventListener(
|
||||
"requestUpdate",
|
||||
this.handleUpdateRequest,
|
||||
);
|
||||
this.activeController.onDestroy();
|
||||
this.activeController = null;
|
||||
}
|
||||
}
|
||||
|
||||
handleUpdateRequest(args) {
|
||||
if (this._rendering) {
|
||||
this._pendingUpdate = true;
|
||||
return;
|
||||
} else {
|
||||
this.renderActiveComponent();
|
||||
}
|
||||
}
|
||||
|
||||
insertIncludes(route) {
|
||||
if (Array.isArray(route.ComponentDefinition.stylesPath)) {
|
||||
route.ComponentDefinition.stylesPath.forEach((path) => {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = path;
|
||||
this.includeRef.appendChild(link);
|
||||
});
|
||||
} else if (route.ComponentDefinition.stylesPath) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = route.ComponentDefinition.stylesPath;
|
||||
this.includeRef.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
renderActiveComponent() {
|
||||
this._rendering = true;
|
||||
this._pendingUpdate = false;
|
||||
this.activeController.onBeforeRender();
|
||||
const renderedTemplate = this.activeTemplate.render(this.activeController);
|
||||
this.outletRef.innerHTML = renderedTemplate;
|
||||
this.wireEvents(this.activeController);
|
||||
this.wireViewChildren(this.activeController);
|
||||
this.activeController.onAfterRender();
|
||||
this._rendering = false;
|
||||
if (this._pendingUpdate) {
|
||||
this.renderActiveComponent();
|
||||
}
|
||||
}
|
||||
|
||||
wireViewChildren(controller) {
|
||||
const viewChildProps = Object.entries(controller)
|
||||
.filter(([_, value]) => value instanceof ViewChild)
|
||||
.forEach(([key, viewChild]) => {
|
||||
if (viewChild._id) {
|
||||
const element = this.outletRef.querySelector(`#${viewChild._id}`);
|
||||
if (element) viewChild._setValue(element);
|
||||
} else if (viewChild._selector) {
|
||||
const element = this.outletRef.querySelector(viewChild._selector);
|
||||
if (element) viewChild._setValue(element);
|
||||
} else {
|
||||
console.warn(`ViewChild ${key} has no selector or id defined`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
wireEvents(controller) {
|
||||
const allElements = this.outletRef.querySelectorAll("*");
|
||||
allElements.forEach((element) => {
|
||||
element.getAttributeNames().forEach((attr) => {
|
||||
const match = attr.match(/^\((\w+)\)$/);
|
||||
if (!match) return;
|
||||
|
||||
const eventName = match[1];
|
||||
const attrValue = element.getAttribute(attr).trim();
|
||||
const methodName = attrValue.endsWith("()")
|
||||
? attrValue.slice(0, -2)
|
||||
: attrValue;
|
||||
|
||||
let listener;
|
||||
if (typeof controller[methodName] === "function") {
|
||||
// Named method — wire directly
|
||||
const handler = controller[methodName].bind(controller);
|
||||
listener = new EventListener(eventName, element, handler);
|
||||
} else {
|
||||
const fn = new Function("event", attrValue).bind(controller);
|
||||
listener = new EventListener(eventName, element, fn);
|
||||
}
|
||||
listener.attach();
|
||||
|
||||
controller._eventListeners.push(listener);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createComponentInstance(component, params) {
|
||||
const instance = new component();
|
||||
instance.onInit();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (instance[key] instanceof ComponentInput) {
|
||||
instance[key].set(value);
|
||||
}
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
async getCachedTemplate(route) {
|
||||
if (this.templateCache.has(route.id)) {
|
||||
return this.templateCache.get(route.id);
|
||||
}
|
||||
|
||||
let templateContent = route.componentClass.definition.template;
|
||||
if (!templateContent && route.componentClass.definition.templatesPath) {
|
||||
const response = await fetch(
|
||||
route.componentClass.definition.templatesPath,
|
||||
);
|
||||
|
||||
if (response.ok === false) {
|
||||
return new Template(`
|
||||
<div class="error">
|
||||
<h1>Failed to load template</h1>
|
||||
<p>${response.status} ${response.statusText}</p>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
templateContent = await response.text();
|
||||
}
|
||||
|
||||
try {
|
||||
const template = new Template(templateContent);
|
||||
this.templateCache.set(route.id, template);
|
||||
return template;
|
||||
} catch (error) {
|
||||
return new Template(`
|
||||
<div class="error">
|
||||
<h1>Failed to compile template</h1>
|
||||
<p>${error.message}</p>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
}
|
||||
}
|
||||
31
OnlyPrompt.Frontend/js/test.js
Normal file
31
OnlyPrompt.Frontend/js/test.js
Normal file
@ -0,0 +1,31 @@
|
||||
import {
|
||||
initRouter,
|
||||
Route,
|
||||
ViewChild,
|
||||
Component,
|
||||
ComponentInput,
|
||||
ComponentDefinition,
|
||||
} from "./pwa.js";
|
||||
|
||||
class TestComponent extends Component {
|
||||
static get definition() {
|
||||
return new ComponentDefinition({
|
||||
name: "test-component",
|
||||
template: `<div>
|
||||
<h1>Test Component</h1>
|
||||
<p>This is a test component.</p>
|
||||
<button id="test-button" (click)="incrementCount()">Count: {{count}}</button>
|
||||
</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
count = new ComponentInput(0);
|
||||
|
||||
incrementCount() {
|
||||
this.count++;
|
||||
}
|
||||
}
|
||||
|
||||
const route1 = new Route("counter", TestComponent);
|
||||
const route2 = new Route("counter/:count", TestComponent);
|
||||
initRouter("routerOutlet", "styleOutlet", [route1, route2]);
|
||||
28
OnlyPrompt.Frontend/test.html
Normal file
28
OnlyPrompt.Frontend/test.html
Normal file
@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OnlyPrompt - Test</title>
|
||||
<link rel="stylesheet" href="../css/variables.css">
|
||||
<link rel="stylesheet" href="../css/base.css">
|
||||
<div id="styleOutlet">
|
||||
|
||||
</div>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div>
|
||||
<h1>Test Site</h1>
|
||||
<a href="counter/1">Go to count 1</a>
|
||||
<a href="counter/2">Go to count 2</a>
|
||||
<a href="counter/3">Go to count 3</a>
|
||||
<div id="routerOutlet">
|
||||
Hello There
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="./js/test.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user