Merge branch 'master' into feature/custom-keymap

This commit is contained in:
thewickest 2025-09-29 17:56:29 +02:00
commit 7dc3ad59c3
21 changed files with 422 additions and 125 deletions

View file

@ -21,9 +21,9 @@ body:
- type: checkboxes
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered.
description: Please [search](https://github.com/monkeytypegame/monkeytype/issues?q=is%3Aissue) to see if an issue already exists for the bug you encountered.
options:
- label: I have searched the existing issues
- label: I have [searched](https://github.com/monkeytypegame/monkeytype/issues?q=is%3Aissue) the existing open and closed issues
required: true
- type: markdown

View file

@ -239,14 +239,14 @@ jobs:
with:
filters: |
languages:
- 'frontend/static/languages/*.json'
- 'frontend/static/languages/**'
quotes:
- 'frontend/static/quotes/*.json'
- 'frontend/static/quotes/**'
others:
- 'frontend/static/layouts/*.json'
- 'frontend/static/layouts/**'
- 'frontend/static/themes/**'
- 'frontend/static/webfonts/**'
- 'frontend/static/challenges/*.json'
- 'frontend/static/challenges/**'
- name: Set up Node.js
uses: actions/setup-node@v4

View file

@ -17,9 +17,9 @@ When contributing to Monkeytype, it's good to know our best practices, tips, and
## How to Contribute
We have two separate contribution guides based on what you're looking to contribute. If you're simply looking to help us augment our language or quotes data, please refer to [CONTRIBUTING_BASIC.md](./CONTRIBUTING_BASIC.md). This guide will go over how to do so easily and without the need to set up a local development server.
We have two separate contribution guides based on what you're looking to contribute. If you're simply looking to help us augment our language or quotes data, please refer to [CONTRIBUTING_BASIC.md](/docs/CONTRIBUTING_BASIC.md). This guide will go over how to do so easily and without the need to set up a local development server.
If you're looking to make deeper code changes that affect functionality, or will require screenshots of the changes, please refer to [CONTRIBUTING_ADVANCED.md](./CONTRIBUTING_ADVANCED.md).
If you're looking to make deeper code changes that affect functionality, or will require screenshots of the changes, please refer to [CONTRIBUTING_ADVANCED.md](/docs/CONTRIBUTING_ADVANCED.md).
## Standards and Guidelines
@ -55,7 +55,7 @@ Before submitting a theme make sure...
- your theme has been added to the `_list` file and the `textColor` property is the theme's main color
- your theme is clear and readable with both `flip test colors` and `colorful mode` enabled and disabled
If you want to contribute themes but don't know how, check [THEMES.md](./THEMES.md)
If you want to contribute themes but don't know how, check [THEMES.md](/docs/THEMES.md)
### Language Guidelines
@ -64,7 +64,7 @@ If you want to contribute themes but don't know how, check [THEMES.md](./THEMES.
- Be sure to add your language to the `_list` and `_groups` files
- Make sure the number of words in the file corresponds to the file name (for example: `languageName.json` is 200 words, `languageName_1k.json` is 1000 words, and so on)
If you want to contribute languages but don't know how, check [LANGUAGES.md](./LANGUAGES.md)
If you want to contribute languages but don't know how, check [LANGUAGES.md](/docs/LANGUAGES.md)
### Quote Guidelines
@ -78,11 +78,11 @@ If you want to contribute languages but don't know how, check [LANGUAGES.md](./L
- Remember to name your pull request properly. For example, if you are adding new quotes for the language `French`, your pull request should be named `impr(quotes): add French quotes`.
If you want to contribute quotes but don't know how, check [QUOTES.md](./QUOTES.md)
If you want to contribute quotes but don't know how, check [QUOTES.md](/docs/QUOTES.md)
### Layout Guidelines
If you want to contribute layouts but don't know how, check [LAYOUTS.md](./LAYOUTS.md)
If you want to contribute layouts but don't know how, check [LAYOUTS.md](/docs/LAYOUTS.md)
## Questions

View file

@ -111,7 +111,7 @@ Follow these steps if you want to work on anything involving the database/accoun
| Manual | Docker (recommended) |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <ol><li>Install [MongoDB Community Edition](https://docs.mongodb.com/manual/administration/install-community/)</li><li>Install [Redis](https://redis.io/docs/install/install-redis/)</li><li>Make sure both are running</li></ol> | <ol><li>Install [Docker](http://www.docker.io/gettingstarted/#h_installation) on your machine</li><li>Run `npm run docker-db-only` from the `./backend` directory</li></ol> |
| <ol><li>Install [MongoDB Community Edition](https://docs.mongodb.com/manual/administration/install-community/)</li><li>Install [Redis](https://redis.io/docs/latest/operate/oss_and_stack/install/install-stack/)</li><li>Make sure both are running</li></ol> | <ol><li>Install [Docker](http://www.docker.io/gettingstarted/#h_installation) on your machine</li><li>Run `npm run docker-db-only` from the `./backend` directory</li></ol> |
3. (Optional) Install [MongoDB-compass](https://www.mongodb.com/try/download/compass?tck=docs_compass). This tool can be used to see and manipulate your database visually.
- To connect, type `mongodb://localhost:27017` in the connection string box and press connect. The Monkeytype database will be created and shown after the server is started.

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { getErrorMessage, isObject } from "../../src/ts/utils/misc";
import { getErrorMessage, isObject, escapeHTML } from "../../src/ts/utils/misc";
import {
getLanguageDisplayString,
removeLanguageSize,
@ -123,6 +123,46 @@ describe("misc.ts", () => {
});
});
describe("escapeHTML", () => {
it("should escape HTML characters correctly", () => {
const tests = [
{
input: "hello world",
expected: "hello world",
},
{
input: "<script>alert('xss')</script>",
expected: "&lt;script&gt;alert(&#39;xss&#39;)&lt;&#x2F;script&gt;",
},
{
input: 'Hello "world" & friends',
expected: "Hello &quot;world&quot; &amp; friends",
},
{
input: "Click `here` to continue",
expected: "Click &#x60;here&#x60; to continue",
},
{
input: null,
expected: null,
},
{
input: undefined,
expected: undefined,
},
{
input: "",
expected: "",
},
];
tests.forEach((test) => {
const result = escapeHTML(test.input);
expect(result).toBe(test.expected);
});
});
});
describe("getErrorMesssage", () => {
it("should correctly get the error message", () => {
const tests = [

View file

@ -32,9 +32,6 @@ export type Validation<T> = {
/** custom debounce delay for `isValid` call. defaults to 100 */
debounceDelay?: number;
/** Resets the value to the current config if empty */
resetIfEmpty?: false;
};
// oxlint-disable-next-line no-explicit-any
@ -144,7 +141,9 @@ export type ValidationOptions<T> = (T extends string
};
export type ValidatedHtmlInputElement = HTMLInputElement & {
isValid: () => boolean | undefined;
getValidationResult: () => ValidationResult;
setValue: (val: string | null) => void;
triggerValidation: () => void;
};
/**
* adds an 'InputIndicator` to the given `inputElement` and updates its status depending on the given validation
@ -176,9 +175,11 @@ export function validateWithIndicator<T>(
},
});
let isValid: boolean | undefined = undefined;
let currentStatus: ValidationResult = {
status: "checking",
};
const callback = (result: ValidationResult): void => {
isValid = result.status === "success" || result.status === "warning";
currentStatus = result;
if (result.status === "failed" || result.status === "warning") {
indicator.show(result.status, result.errorMessage);
} else {
@ -196,7 +197,20 @@ export function validateWithIndicator<T>(
inputElement.addEventListener("input", handler);
const result = inputElement as ValidatedHtmlInputElement;
result.isValid = () => isValid;
result.getValidationResult = () => {
return currentStatus;
};
result.setValue = (val: string | null) => {
inputElement.value = val ?? "";
if (val === null) {
indicator.hide();
} else {
inputElement.dispatchEvent(new Event("input"));
}
};
result.triggerValidation = () => {
inputElement.dispatchEvent(new Event("input"));
};
return result;
}
@ -213,6 +227,8 @@ export type ConfigInputOptions<K extends ConfigKey, T = ConfigType[K]> = {
schema: boolean;
/** optional callback is called for each change of the validation result */
validationCallback?: (result: ValidationResult) => void;
/** Resets the value to the current config if empty */
resetIfEmpty?: false;
};
};

View file

@ -10,6 +10,7 @@ import * as CustomTextState from "../states/custom-text-name";
import { getLanguageDisplayString } from "../utils/strings";
import Format from "../utils/format";
import { getActiveFunboxNames } from "../test/funbox/list";
import { escapeHTML } from "../utils/misc";
ConfigEvent.subscribe((eventKey) => {
const configKeys: ConfigEvent.ConfigEventKey[] = [
@ -79,7 +80,9 @@ export async function update(): Promise<void> {
const isLong = CustomTextState.isCustomTextLong();
if (Config.mode === "custom" && customTextName !== "" && isLong) {
$(".pageTest #testModesNotice").append(
`<div class="textButton noInteraction"><i class="fas fa-book"></i>${customTextName} (shift + enter to save progress)</div>`
`<div class="textButton noInteraction"><i class="fas fa-book"></i>${escapeHTML(
customTextName
)} (shift + enter to save progress)</div>`
);
}

View file

@ -6,7 +6,11 @@ import * as Settings from "../pages/settings";
import * as Notifications from "../elements/notifications";
import * as ConnectionState from "../states/connection";
import AnimatedModal from "../utils/animated-modal";
import { PresetType, PresetTypeSchema } from "@monkeytype/schemas/presets";
import {
PresetNameSchema,
PresetType,
PresetTypeSchema,
} from "@monkeytype/schemas/presets";
import { getPreset } from "../controllers/preset-controller";
import {
ConfigGroupName,
@ -17,6 +21,10 @@ import {
} from "@monkeytype/schemas/configs";
import { getDefaultConfig } from "../constants/default-config";
import { SnapshotPreset } from "../constants/default-snapshot";
import {
ValidatedHtmlInputElement,
validateWithIndicator,
} from "../elements/input-validation";
const state = {
presetType: "full" as PresetType,
@ -26,6 +34,8 @@ const state = {
setPresetToCurrent: false,
};
let presetNameEl: ValidatedHtmlInputElement | null = null;
export function show(action: string, id?: string, name?: string): void {
if (!ConnectionState.get()) {
Notifications.add("You are offline", 0, {
@ -39,11 +49,22 @@ export function show(action: string, id?: string, name?: string): void {
beforeAnimation: async () => {
$("#editPresetModal .modal .text").addClass("hidden");
addCheckBoxes();
if (!presetNameEl) {
presetNameEl = validateWithIndicator(
document.querySelector(
"#editPresetModal .modal input"
) as HTMLInputElement,
{
schema: PresetNameSchema,
}
);
}
if (action === "add") {
$("#editPresetModal .modal").attr("data-action", "add");
$("#editPresetModal .modal .popupTitle").html("Add new preset");
$("#editPresetModal .modal .submit").html(`add`);
$("#editPresetModal .modal input").val("");
presetNameEl?.setValue(null);
presetNameEl?.parentElement?.classList.remove("hidden");
$("#editPresetModal .modal input").removeClass("hidden");
$(
"#editPresetModal .modal label.changePresetToCurrentCheckbox"
@ -57,7 +78,9 @@ export function show(action: string, id?: string, name?: string): void {
$("#editPresetModal .modal").attr("data-preset-id", id);
$("#editPresetModal .modal .popupTitle").html("Edit preset");
$("#editPresetModal .modal .submit").html(`save`);
$("#editPresetModal .modal input").val(name);
presetNameEl?.setValue(name);
presetNameEl?.parentElement?.classList.remove("hidden");
$("#editPresetModal .modal input").removeClass("hidden");
$(
"#editPresetModal .modal label.changePresetToCurrentCheckbox"
@ -85,6 +108,7 @@ export function show(action: string, id?: string, name?: string): void {
$("#editPresetModal .modal .inputs").addClass("hidden");
$("#editPresetModal .modal .presetType").addClass("hidden");
$("#editPresetModal .modal .presetNameTitle").addClass("hidden");
presetNameEl?.parentElement?.classList.add("hidden");
}
updateUI();
},
@ -238,6 +262,11 @@ async function apply(): Promise<void> {
return;
}
if (presetNameEl?.getValidationResult().status === "failed") {
Notifications.add("Preset name is not valid", 0);
return;
}
hide();
Loader.show();

View file

@ -1,11 +1,13 @@
import * as CustomText from "../test/custom-text";
import * as Notifications from "../elements/notifications";
import * as CustomTextState from "../states/custom-text-name";
import { InputIndicator } from "../elements/input-indicator";
import { debounce } from "throttle-debounce";
import AnimatedModal, { ShowOptions } from "../utils/animated-modal";
import { validateWithIndicator } from "../elements/input-validation";
import { z } from "zod";
let indicator: InputIndicator | undefined;
type IncomingData = {
text: string[];
};
type State = {
textToSave: string[];
@ -15,6 +17,35 @@ const state: State = {
textToSave: [],
};
const validatedInput = validateWithIndicator(
$("#saveCustomTextModal .textName")[0] as HTMLInputElement,
{
debounceDelay: 500,
schema: z
.string()
.min(1)
.max(32)
.regex(/^[\w\s-]+$/, {
message:
"Name can only contain letters, numbers, spaces, underscores and hyphens",
}),
isValid: async (value) => {
const checkbox = $("#saveCustomTextModal .isLongText").prop(
"checked"
) as boolean;
const names = CustomText.getCustomTextNames(checkbox);
return !names.includes(value) ? true : "Duplicate name";
},
callback: (result) => {
if (result.status === "success") {
$("#saveCustomTextModal button.save").prop("disabled", false);
} else {
$("#saveCustomTextModal button.save").prop("disabled", true);
}
},
}
);
export async function show(options: ShowOptions<IncomingData>): Promise<void> {
state.textToSave = [];
void modal.show({
@ -28,10 +59,6 @@ export async function show(options: ShowOptions<IncomingData>): Promise<void> {
});
}
function hide(): void {
void modal.hide();
}
function save(): boolean {
const name = $("#saveCustomTextModal .textName").val() as string;
const checkbox = $("#saveCustomTextModal .isLongText").prop(
@ -59,69 +86,18 @@ function save(): boolean {
}
}
function updateIndicatorAndButton(): void {
const val = $("#saveCustomTextModal .textName").val() as string;
const checkbox = $("#saveCustomTextModal .isLongText").prop(
"checked"
) as boolean;
if (!val) {
indicator?.hide();
$("#saveCustomTextModal button.save").prop("disabled", true);
} else {
const names = CustomText.getCustomTextNames(checkbox);
if (names.includes(val)) {
indicator?.show("unavailable");
$("#saveCustomTextModal button.save").prop("disabled", true);
} else {
indicator?.show("available");
$("#saveCustomTextModal button.save").prop("disabled", false);
}
}
}
const updateInputAndButtonDebounced = debounce(500, updateIndicatorAndButton);
async function setup(modalEl: HTMLElement): Promise<void> {
indicator = new InputIndicator($("#saveCustomTextModal .textName"), {
available: {
icon: "fa-check",
level: 1,
},
unavailable: {
icon: "fa-times",
level: -1,
},
loading: {
icon: "fa-circle-notch",
spinIcon: true,
level: 0,
},
});
modalEl.addEventListener("submit", (e) => {
e.preventDefault();
if (save()) hide();
});
modalEl.querySelector(".textName")?.addEventListener("input", (e) => {
const val = (e.target as HTMLInputElement).value;
if (val.length > 0) {
indicator?.show("loading");
updateInputAndButtonDebounced();
if (validatedInput.getValidationResult().status === "success" && save()) {
void modal.hide();
}
});
modalEl.querySelector(".isLongText")?.addEventListener("input", (e) => {
const val = (e.target as HTMLInputElement).value;
if (val.length > 0) {
indicator?.show("loading");
updateInputAndButtonDebounced();
}
validatedInput.triggerValidation();
});
}
type IncomingData = {
text: string[];
};
const modal = new AnimatedModal<IncomingData>({
dialogId: "saveCustomTextModal",
setup,

View file

@ -151,7 +151,7 @@ async function fill(): Promise<void> {
const supportersEl = document.querySelector(".pageAbout .supporters");
let supportersHTML = "";
for (const supporter of supporters ?? []) {
supportersHTML += `<div>${supporter}</div>`;
supportersHTML += `<div>${Misc.escapeHTML(supporter)}</div>`;
}
if (supportersEl) {
supportersEl.innerHTML = supportersHTML;
@ -160,7 +160,7 @@ async function fill(): Promise<void> {
const contributorsEl = document.querySelector(".pageAbout .contributors");
let contributorsHTML = "";
for (const contributor of contributors ?? []) {
contributorsHTML += `<div>${contributor}</div>`;
contributorsHTML += `<div>${Misc.escapeHTML(contributor)}</div>`;
}
if (contributorsEl) {
contributorsEl.innerHTML = contributorsHTML;

View file

@ -170,7 +170,8 @@ validateWithIndicator(emailVerifyInputEl, {
debounceDelay: 0,
callback: (result) => {
registerForm.email =
emailInputEl.isValid() && result.status === "success"
emailInputEl.getValidationResult().status === "success" &&
result.status === "success"
? emailInputEl.value
: undefined;
updateSignupButton();
@ -204,7 +205,8 @@ validateWithIndicator(passwordVerifyInputEl, {
debounceDelay: 0,
callback: (result) => {
registerForm.password =
passwordInputEl.isValid() && result.status === "success"
passwordInputEl.getValidationResult().status === "success" &&
result.status === "success"
? passwordInputEl.value
: undefined;
updateSignupButton();

View file

@ -4,6 +4,7 @@ import * as LiveBurst from "./live-burst";
import * as LiveAcc from "./live-acc";
import * as TimerProgress from "./timer-progress";
import * as PageTransition from "../states/page-transition";
import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame";
const unfocusPx = 3;
let state = false;
@ -41,13 +42,13 @@ function initializeCache(): void {
// with cursor is a special case that is only used on the initial page load
// to avoid the cursor being invisible and confusing the user
export function set(value: boolean, withCursor = false): void {
initializeCache();
requestDebouncedAnimationFrame("focus.set", () => {
initializeCache();
if (value && !state) {
state = true;
if (value && !state) {
state = true;
// batch DOM operations for better performance
requestAnimationFrame(() => {
// batch DOM operations for better performance
if (cache.focus) {
for (const el of cache.focus) {
el.classList.add("focus");
@ -58,17 +59,15 @@ export function set(value: boolean, withCursor = false): void {
el.style.cursor = "none";
}
}
});
Caret.stopAnimation();
LiveSpeed.show();
LiveBurst.show();
LiveAcc.show();
TimerProgress.show();
} else if (!value && state) {
state = false;
Caret.stopAnimation();
LiveSpeed.show();
LiveBurst.show();
LiveAcc.show();
TimerProgress.show();
} else if (!value && state) {
state = false;
requestAnimationFrame(() => {
if (cache.focus) {
for (const el of cache.focus) {
el.classList.remove("focus");
@ -79,14 +78,14 @@ export function set(value: boolean, withCursor = false): void {
el.style.cursor = "";
}
}
});
Caret.startAnimation();
LiveSpeed.hide();
LiveBurst.hide();
LiveAcc.hide();
TimerProgress.hide();
}
Caret.startAnimation();
LiveSpeed.hide();
LiveBurst.hide();
LiveAcc.hide();
TimerProgress.hide();
}
});
}
$(document).on("mousemove", function (event) {

View file

@ -23,6 +23,7 @@ import { TimerColor, TimerOpacity } from "@monkeytype/schemas/configs";
import { convertRemToPixels } from "../utils/numbers";
import { findSingleActiveFunboxWithFunction } from "./funbox/list";
import * as TestState from "./test-state";
import { requestDebouncedAnimationFrame } from "../utils/debounced-animation-frame";
const debouncedZipfCheck = debounce(250, async () => {
const supports = await JSONData.checkIfLanguageSupportsZipf(Config.language);
@ -491,13 +492,9 @@ export function appendEmptyWordElement(
`<div class='word' data-wordindex='${index}'><letter class='invisible'>_</letter></div>`
);
}
let updateWordsInputPositionAnimationFrameId: null | number = null;
export function updateWordsInputPosition(): void {
if (updateWordsInputPositionAnimationFrameId !== null) {
cancelAnimationFrame(updateWordsInputPositionAnimationFrameId);
}
updateWordsInputPositionAnimationFrameId = requestAnimationFrame(() => {
updateWordsInputPositionAnimationFrameId = null;
requestDebouncedAnimationFrame("test-ui.updateWordsInputPosition", () => {
if (ActivePage.get() !== "test") return;
const isTestRightToLeft = TestState.isDirectionReversed
? !TestState.isLanguageRightToLeft

View file

@ -0,0 +1,21 @@
const pendingFrames = new Map<string, number>();
export function requestDebouncedAnimationFrame(
frameId: string,
callback: () => void
): void {
cancelIfPending(frameId);
const frame = requestAnimationFrame(() => {
pendingFrames.delete(frameId);
callback();
});
pendingFrames.set(frameId, frame);
}
function cancelIfPending(frameId: string): void {
const pending = pendingFrames.get(frameId);
if (pending !== undefined) {
cancelAnimationFrame(pending);
pendingFrames.delete(frameId);
}
}

View file

@ -164,18 +164,22 @@ export function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function escapeHTML(str: string): string {
export function escapeHTML<T extends string | null | undefined>(str: T): T {
if (str === null || str === undefined) {
return str;
}
str = str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
return str;
const escapeMap: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
"/": "&#x2F;",
"`": "&#x60;",
};
return str.replace(/[&<>"'/`]/g, (char) => escapeMap[char] as string) as T;
}
export function isUsernameValid(name: string): boolean {

View file

@ -163,6 +163,9 @@ export function loadTestSettingsFromUrl(getOverride?: string): void {
const getValue = Misc.findGetParameter("testSettings", getOverride);
if (getValue === null) return;
// if the encoding structure or method ever changes, make sure to support the old data format
// otherwise eiko will be sad
const { data: de, error } = tryCatchSync(() =>
parseJsonWithSchema(decompressFromURI(getValue) ?? "", TestSettingsSchema)
);

View file

@ -0,0 +1,62 @@
{
"keymapShowTopRow": false,
"type": "ansi",
"keys": {
"row1": [
["`", "~"],
["1", "!"],
["2", "@"],
["3", "#"],
["4", "$"],
["5", "%"],
["6", "^"],
["7", "&"],
["8", "*"],
["9", "("],
["0", ")"],
["[", "{"],
["]", "}"]
],
"row2": [
[";", ":"],
["u", "U"],
["o", "O"],
["f", "F"],
["j", "J"],
["q", "Q"],
["k", "K"],
["l", "L"],
["r", "R"],
["v", "V"],
["/", "?"],
["=", "+"],
["\\", "|"]
],
"row3": [
["e", "E"],
["i", "I"],
["a", "A"],
["c", "C"],
["y", "Y"],
["d", "D"],
["h", "H"],
["t", "T"],
["n", "N"],
["s", "S"],
["-", "_"]
],
"row4": [
[",", "<"],
[".", ">"],
["p", "P"],
["g", "G"],
["'", "\""],
["b", "B"],
["m", "M"],
["w", "W"],
["x", "X"],
["z", "Z"]
],
"row5": [[" "]]
}
}

View file

@ -38799,6 +38799,108 @@
"source": "James Clear, Atomic Habits: An Easy & Proven Way to Build Good Habits & Break Bad Ones",
"id": 7679,
"length": 381
},
{
"text": "A traveler from beyond the stars, Bard is an agent of serendipity who fights to maintain a balance where life can endure the indifference of chaos. Many Runeterrans sing songs that ponder his extraordinary nature, yet they all agree that the cosmic vagabond is drawn to artifacts of great magical power. Surrounded by a jubilant choir of helpful spirit meeps, it is impossible to mistake his actions as malevolent, as Bard always serves the greater good... in his own odd way.",
"source": "League of Legends",
"id": 7680,
"length": 476
},
{
"text": "The eccentric Professor Cecil B. Heimerdinger is one of the most innovative and esteemed inventors the world has ever known. As the longest serving member of the Council of Piltover, he saw the best and the worst of the city's unending desire for progress. Nonetheless, this brilliant scientist and teacher will always remain dedicated to using his unconventional devices to improve the lives of others.",
"source": "League of Legends",
"id": 7681,
"length": 403
},
{
"text": "An enthusiastic master of dark sorcery, Veigar has embraced powers that few mortals dare approach. As a free-spirited inhabitant of Bandle City, he longed to push beyond the limitations of yordle magic, and turned instead to arcane texts that had been hidden away for thousands of years. Now a stubborn creature with an endless fascination for the mysteries of the universe, Veigar is often underestimated by others - but even though he believes himself truly evil, he possesses an inner morality that leads some to question his deeper motivations.",
"source": "League of Legends",
"id": 7682,
"length": 548
},
{
"text": "Xerath is an Ascended Magus of ancient Shurima, a being of arcane energy writhing in the broken shards of a magical sarcophagus. For millennia, he was trapped beneath the desert sands, but the rise of Shurima freed him from his ancient prison. Driven insane with power, he now seeks to take what he believes is rightfully his and replace the upstart civilizations of the world with one fashioned in his image.",
"source": "League of Legends",
"id": 7683,
"length": 409
},
{
"text": "The whole idea of what happens when you read a book, I find absolutely stunning. Heres some product of a tree, little black squiggles on it, you open it up, and inside your head is the voice of someone speaking, who may have been dead 3000 years, and there he is talking directly to you, what a magical thing that is.",
"source": "Carl Sagan",
"length": 318,
"id": 7684
},
{
"text": "Mathematics, in an earlier view, is the science of space and quantity; in a later view, it is the science of pattern and deductive structure.",
"source": "Philip J. Davis, Reuben Hersh - The mathematical experience (1981)",
"length": 141,
"id": 7685
},
{
"text": "His Holiness the Flying Spaghetti Monster is Eternal, without beginning and without end, and with a whole tangled mess in the middle.",
"source": "Evangelical Pastafarian church - The Loose Canon, Second Announcement Regarding Canonical Belief",
"length": 133,
"id": 7686
},
{
"text": "We are stuck with technology when all we really want is just stuff that works. How do you recognize something that is still technology? A good clue is if it comes with a manual.",
"source": "Douglas Adams",
"length": 177,
"id": 7687
},
{
"text": "Nothing disturbs me more than the glorification of stupidity.",
"source": "Carl Sagan",
"length": 61,
"id": 7688
},
{
"text": "“Then how,” Dex said, “how does the idea of maybe being meaningless sit well with you?” Mosscap considered. “Because I know that no matter what, Im wonderful.”",
"source": "Becky Chambers — Monk and Robot",
"length": 160,
"id": 7689
},
{
"text": "I can wait for the galaxy outside to get a little kinder.",
"source": "Becky Chambers — The Long Way to a Small, Angry Planet",
"length": 57,
"id": 7690
},
{
"text": "Thats such an incredibly organic bias, the idea that your squishy physical existence is some sort of pinnacle that all programs aspire to.",
"source": "Becky Chambers — The Long Way to a Small, Angry Planet",
"length": 139,
"id": 7691
},
{
"text": "There are only two hard things in Computer Science: cache invalidation, naming things, and off-by-one error.",
"source": "Phil Karlton",
"length": 108,
"id": 7692
},
{
"text": "You gotta put your past behind you. Look, kid, bad things happen, and you can't do anything about it. Right? Wrong!",
"source": "The Lion King (1994)",
"id": 7693,
"length": 115
},
{
"text": "One often meets his destiny on the road he takes to avoid it. Your mind is like this water my friend; when it is agitated, it is hard to see, but if you allow it to settle, the answer becomes clear.",
"source": "Kung Fu Panda (2008)",
"id": 7694,
"length": 198
},
{
"text": "Yesterday is history, tomorrow is a mystery, but today is a gift. That is why it is called the present.",
"source": "Kung Fu Panda (2008)",
"id": 7695,
"length": 103
},
{
"text": "Your story may not have such a happy beginning, but that doesn't make you who you are. It is the rest of your story. Who you choose to be.",
"source": "Kung Fu Panda 2 (2011)",
"id": 7696,
"length": 138
}
]
}

View file

@ -6624,6 +6624,30 @@
"source": "Ken Liu - La ménagerie de papier",
"length": 153,
"id": 1125
},
{
"text": "Lhomme de science le sait bien, lui, que seule la science a pu, au fil des siècles, lui apporter lhorloge pointeuse et le parcmètre automatique sans lesquels il nest pas de bonheur terrestre possible.",
"source": "Pierre Desproges - Vivons heureux en attendant la mort",
"length": 203,
"id": 1126
},
{
"text": "Une situation prérévolutionnaire éclate lorsque ceux den haut ne peuvent plus, ceux den bas ne veulent plus, et ceux du milieu basculent avec ceux den bas.",
"source": "Lénine",
"length": 158,
"id": 1127
},
{
"text": "Il y a des décennies où rien ne se passe et des semaines où des décennies se produisent.",
"source": "Lénine",
"length": 88,
"id": 1128
},
{
"text": "Il n'est pas de sauveurs suprêmes\nNi Dieu, ni César, ni Tribun,\nProducteurs, sauvons-nous nous-mêmes\nDécrétons le salut commun.",
"source": "L'internationale",
"length": 127,
"id": 1129
}
]
}

View file

@ -636,6 +636,24 @@
"source": "Tabacaria, Fernando Pessoa",
"length": 88,
"id": 106
},
{
"text": "Entendeu o céu que devia regar com as suas lágrimas o solo da formosa Petrópolis.",
"source": "Linha Reta e Linha Curva, Machado de Assis",
"length": 81,
"id": 107
},
{
"text": "Olhos de ressaca? Vá, de ressaca. É o que me dá ideia daquela feição nova. Traziam não sei que fluido misterioso e enérgico, uma força que arrastava para dentro, como a vaga que se retira da praia, nos dias de ressaca.",
"source": "Dom Casmurro, Machado de Assis",
"length": 218,
"id": 108
},
{
"text": "Apesar de generosa, é exigente, e quer da parte dos seus afilhados algum esforço próprio. A fortuna não é Danaide. Quando vê que um tonel esgota a água que se lhe põe dentro vai levar os seus cântaros a outra parte.",
"source": "Luís Soares, Machado de Assis",
"length": 215,
"id": 109
}
]
}

View file

@ -182,6 +182,7 @@ export const LayoutNameSchema = z.enum(
"estonian",
"stronk",
"dhorf",
"gust",
"recurva",
"seht-drai",
"ints",