Skip to content

Commit 0b8d9e0

Browse files
committed
Refactor console
1 parent 57dad78 commit 0b8d9e0

17 files changed

+302
-293
lines changed
+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { Command } from "./Command";
2+
3+
export abstract class AutocompleteCommand extends Command {
4+
abstract autocomplete(arg: string): string[] | null;
5+
}

assets/js/console/Command.ts

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { Console } from "./Console";
2+
3+
export interface Command {
4+
readonly name: string;
5+
execute(console: Console, args: string[]): void;
6+
}

assets/js/console/Console.ts

+171
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { Command } from "./Command";
2+
import { getCommandFromInput, longestCommonPrefix } from "./helpers";
3+
import { ChangeDirectory } from "./commands/ChangeDirectory";
4+
import { Clear } from "./commands/Clear";
5+
import { Help } from "./commands/Help";
6+
import { Kitties } from "./commands/Kitties";
7+
import { List } from "./commands/List";
8+
9+
export class Console {
10+
private inputElement = document.getElementById("prompt-input") as HTMLSpanElement;
11+
private promptBlur = document.getElementById("prompt-blur") as HTMLSpanElement;
12+
private consoleElement = document.getElementById("console") as HTMLDivElement;
13+
private nav = document.getElementsByTagName("nav")[0];
14+
15+
static commands: Command[] = [
16+
new ChangeDirectory(),
17+
new Clear(),
18+
new Help(),
19+
new Kitties(),
20+
new List(),
21+
];
22+
23+
public initialise(): void {
24+
// Focus prompt input on page load for browsers that don't support autofocus on contenteditable elements.
25+
this.inputElement.focus();
26+
27+
this.mirrorInputPromptToBlurredPrompt();
28+
this.moveCaretToEndOnFocus();
29+
this.listenForKeyboardInput();
30+
this.focusPromptOnClick();
31+
}
32+
33+
private mirrorInputPromptToBlurredPrompt(): void {
34+
this.inputElement.addEventListener("input", () => {
35+
this.setInput(this.inputElement.textContent.replace(/\s/g, "\xA0"));
36+
});
37+
}
38+
39+
private moveCaretToEndOnFocus(): void {
40+
this.inputElement.addEventListener("focusin", () => {
41+
this.moveCaretToEnd();
42+
});
43+
}
44+
45+
private listenForKeyboardInput(): void {
46+
/**
47+
* Handle enter key press to clear the prompt input.
48+
*/
49+
document.addEventListener("keydown", (event: KeyboardEvent) => {
50+
const input = this.inputElement.textContent.replace(/\xA0/g, " ").trim();
51+
52+
switch (event.key) {
53+
case "ArrowLeft":
54+
case "ArrowRight":
55+
case "ArrowUp":
56+
case "ArrowDown":
57+
event.preventDefault();
58+
break;
59+
60+
// Clear prompt input
61+
case "Enter":
62+
event.preventDefault();
63+
this.onEnter(input);
64+
break;
65+
66+
case "Tab":
67+
event.preventDefault();
68+
this.onTab(input);
69+
break;
70+
71+
// Remove focus from prompt input
72+
case "Escape":
73+
this.inputElement.blur();
74+
break;
75+
}
76+
});
77+
}
78+
79+
private focusPromptOnClick(): void {
80+
/**
81+
* Focus prompt input when clicking on the header.
82+
*/
83+
this.nav.addEventListener("click", (event: MouseEvent) => {
84+
// Prevent focusing prompt input when clicking on a link.
85+
if (event.target instanceof HTMLAnchorElement) {
86+
return;
87+
}
88+
89+
this.inputElement.focus();
90+
});
91+
}
92+
93+
private onEnter(input: string) {
94+
const { command, args } = getCommandFromInput(input);
95+
this.setInput();
96+
this.clearOutput();
97+
98+
if (command) {
99+
command.execute(this, args);
100+
return;
101+
}
102+
103+
this.print("Command not found. Type `help` for a list of available commands.");
104+
return;
105+
}
106+
107+
private onTab(input: string) {
108+
if (input.length === 0) {
109+
return;
110+
}
111+
112+
const matchingCommands = Console.commands.filter((command: Command) =>
113+
command.name.startsWith(input),
114+
);
115+
116+
switch (matchingCommands.length) {
117+
case 0:
118+
return;
119+
120+
case 1:
121+
const matchingCommand = matchingCommands[0];
122+
if (input.length < matchingCommand.name.length) {
123+
this.inputElement.textContent = matchingCommand.name;
124+
this.promptBlur.textContent = matchingCommand.name;
125+
this.moveCaretToEnd();
126+
}
127+
return;
128+
129+
default:
130+
const matchingPrefix = longestCommonPrefix(
131+
matchingCommands.map((command: Command) => command.name),
132+
);
133+
this.setInput("");
134+
this.inputElement.textContent = matchingPrefix;
135+
this.promptBlur.textContent = matchingPrefix;
136+
this.moveCaretToEnd();
137+
}
138+
return;
139+
}
140+
141+
private moveCaretToEnd() {
142+
const range = document.createRange();
143+
const selection = window.getSelection();
144+
145+
// Move caret to end of prompt input.
146+
range?.setStart(this.inputElement, this.inputElement.childNodes.length);
147+
range?.collapse(false);
148+
selection?.removeAllRanges();
149+
selection?.addRange(range);
150+
}
151+
152+
public print(...lines: string[]) {
153+
lines.forEach((line: string) => {
154+
const outputElement = document.createElement("pre");
155+
outputElement.textContent = line;
156+
this.consoleElement.appendChild(outputElement);
157+
});
158+
}
159+
160+
public setInput(newInput: string = "") {
161+
this.inputElement.textContent = newInput;
162+
this.promptBlur.textContent = newInput;
163+
this.moveCaretToEnd();
164+
}
165+
166+
public clearOutput() {
167+
while (this.consoleElement?.firstChild) {
168+
this.consoleElement.removeChild(this.consoleElement.firstChild);
169+
}
170+
}
171+
}

assets/js/shell/commands/ChangeDirectory.ts assets/js/console/commands/ChangeDirectory.ts

+12-19
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,25 @@
11
import { Command } from "../Command";
22

3-
import { getAllPages, getPagesInPath } from "../helpers";
3+
import { getAllPages, getPagesInPath, slugPath } from "../helpers";
44
import { HugoPage } from "../../types/hugo";
5+
import { Console } from "../Console";
56

67
export class ChangeDirectory implements Command {
78
public readonly name: string = "cd";
9+
private readonly allPages: HugoPage[] = getAllPages();
10+
private readonly pagesInPath: HugoPage[] = getPagesInPath(window.location.pathname);
811

9-
public execute(consoleElement: HTMLDivElement, args: string[] = []): void {
12+
public execute(console: Console, args: string[] = []): void {
1013
if (args.length === 0) {
11-
const outputElement = document.createElement("pre");
12-
outputElement.textContent = "cd: missing argument";
13-
consoleElement.appendChild(outputElement);
14+
console.print("cd: missing argument");
1415
return;
1516
}
1617

1718
if (args.length > 1) {
18-
const outputElement = document.createElement("pre");
19-
outputElement.textContent = "cd: too many arguments";
20-
consoleElement.appendChild(outputElement);
19+
console.print("cd: too many arguments");
2120
return;
2221
}
2322

24-
const allPages = getAllPages();
25-
const pagesInPath = getPagesInPath(window.location.pathname);
26-
2723
// Change to root directory
2824
if (!args.length || ["/", "~"].includes(args[0])) {
2925
window.location.pathname = "/";
@@ -55,26 +51,23 @@ export class ChangeDirectory implements Command {
5551

5652
// Change to an absolute path
5753
if (inputPath.startsWith("/") || inputPath.startsWith("~/")) {
58-
const page = allPages.find(
59-
(p: HugoPage) =>
60-
p.Path.toLowerCase() === inputPath ||
61-
"/" + p.Section.toLowerCase() + "/" + p.Slug === inputPath,
54+
const page = this.allPages.find(
55+
(p: HugoPage) => p.Path.toLowerCase() === inputPath || slugPath(p) === inputPath,
6256
);
6357

6458
if (page !== undefined) {
65-
window.location.pathname = "/" + page.Section.toLowerCase() + "/" + page.Slug;
59+
window.location.pathname = slugPath(page).concat("/");
6660
return;
6761
}
6862
return;
6963
}
7064

7165
// Change to a relative path
72-
console.log(inputPath, pagesInPath);
7366
if (
74-
pagesInPath.find(
67+
this.pagesInPath.find(
7568
(p: HugoPage) =>
7669
p.Path.replace(window.location.pathname, "").toLowerCase() === inputPath ||
77-
p.Slug === inputPath,
70+
slugPath(p).replace(window.location.pathname, "").toLowerCase() === inputPath,
7871
)
7972
) {
8073
window.location.pathname = window.location.pathname.concat(inputPath).concat("/");

assets/js/console/commands/Clear.ts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Command } from "../Command";
2+
import { Console } from "../Console";
3+
4+
export class Clear implements Command {
5+
public readonly name: string = "clear";
6+
7+
public execute(console: Console, args: string[]): void {
8+
if (args.length > 0) {
9+
console.print("clear: too many arguments");
10+
return;
11+
}
12+
13+
console.clearOutput();
14+
}
15+
}

assets/js/console/commands/Help.ts

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Command } from "../Command";
2+
import { Console } from "../Console";
3+
4+
export class Help implements Command {
5+
public readonly name: string = "help";
6+
7+
public execute(console: Console, args: string[]): void {
8+
if (args.length > 0) {
9+
console.print("help: too many arguments");
10+
return;
11+
}
12+
13+
const output = Console.commands.map((command: Command) => command.name).join(" ");
14+
15+
console.print(output);
16+
}
17+
}

assets/js/console/commands/Kitties.ts

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Command } from "../Command";
2+
3+
import { commands, Console } from "../Console";
4+
5+
export class Kitties implements Command {
6+
public readonly name: string = "kitties";
7+
8+
public execute(console: Console, args: string[]): void {
9+
if (args.length > 0) {
10+
console.print("kitties: too many arguments");
11+
return;
12+
}
13+
14+
window.location.href = "https://hamana.nl/";
15+
}
16+
}

assets/js/console/commands/List.ts

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Command } from "../Command";
2+
3+
import { HugoPage } from "../../types/hugo";
4+
import { getPagesInPath } from "../helpers";
5+
import { Console } from "../Console";
6+
7+
export class List implements Command {
8+
public readonly name: string = "ls";
9+
10+
public execute(console: Console, args: string[]): void {
11+
if (args.length > 0) {
12+
console.print("ls: too many arguments");
13+
return;
14+
}
15+
16+
const currentPath = window.location.pathname;
17+
let pages = getPagesInPath(currentPath);
18+
19+
console.print(".");
20+
21+
if (currentPath !== "/") {
22+
console.print("..");
23+
}
24+
25+
const paths = pages.map((page: HugoPage): string => {
26+
if (page.Slug) {
27+
return page.Slug.concat("/");
28+
} else {
29+
return page.Path.replace(currentPath, "").concat("/");
30+
}
31+
});
32+
33+
console.print(...paths);
34+
}
35+
}

assets/js/shell/helpers.ts assets/js/console/helpers.ts

+20
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// @ts-ignore
22
import params from "@params";
33
import { HugoPage } from "../types/hugo";
4+
import { Command } from "./Command";
5+
import { Console } from "./Console";
46

57
export function getAllPages(): HugoPage[] {
68
const pages = JSON.parse(params.pages) as HugoPage[];
@@ -36,3 +38,21 @@ export function longestCommonPrefix(strings: string[]): string {
3638

3739
return firstString.substring(0, i);
3840
}
41+
42+
export function slugPath(page: HugoPage): string {
43+
return (page.Section + "/" + page.Slug + "/").toLowerCase();
44+
}
45+
46+
export function getCommandFromInput(input: string): {
47+
command: Command | null;
48+
args: string[];
49+
} {
50+
if (input === "") return { command: null, args: [] };
51+
52+
const command: Command = Console.commands.find((command: Command) =>
53+
input.startsWith(command.name),
54+
);
55+
const args = input.split(" ").slice(1) || [];
56+
57+
return { command, args };
58+
}

assets/js/main.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
initialiseDarkModeToggleListener,
55
} from "./darkMode";
66
import smoothScrollToNode from "./smoothScrollToNode";
7-
import initialisePrompt from "./shell/prompt";
7+
import { Console } from "./console/Console";
88
import { CustomWindow } from "./types/main";
99

1010
declare let window: CustomWindow;
@@ -17,4 +17,7 @@ initialiseDarkModeListener();
1717
/** Called by the bouncing arrow on the home page. */
1818
window.smoothScrollToNode = smoothScrollToNode;
1919

20-
initialisePrompt();
20+
document.addEventListener("DOMContentLoaded", () => {
21+
const prompt = new Console();
22+
prompt.initialise();
23+
});

assets/js/shell/Command.ts

-4
This file was deleted.

0 commit comments

Comments
 (0)