mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-23 20:54:48 -04:00
Merge branch 'main' into publift
This commit is contained in:
commit
6e1520b26d
39
locale/pl/blog/niebinie-z-okładki.md
Normal file
39
locale/pl/blog/niebinie-z-okładki.md
Normal file
@ -0,0 +1,39 @@
|
||||
# Niebinie z okładki. Alin Szewczyk w lipcowym numerze „Elle”
|
||||
|
||||
<small>2023-09-16 | [@Tess](/@Tess)</small>
|
||||
|
||||

|
||||
|
||||
<p class="small">Alin Szewczyk na okładce „Elle”; fotografia: Lola Banet, make-up: Anna Kobalczyk, strój: Valentino.</p>
|
||||
|
||||
**„Alin. Niebinarny model podbija światowe wybiegi” – czytamy na okładce magazynu „Elle”. Choć Alin Szewczyk ma zaledwie 24 lata, zagrał już główną rolę w filmie, a na wybiegach prezentował kreacje najważniejszych domów mody na świecie.**
|
||||
|
||||
Magazyn „Elle” sprzedaje się w nakładzie około 40 tysięcy egzemplarzy, co oznacza, że dociera naprawdę szeroko. W lipcu wraz z kompendium wiedzy o modzie do osób czytelniczych trafił wywiad z Alin Szewczyk, osobą niebinarną zajmującą się modelingiem i aktorstwem. Twarz Alin zobaczyłośmy już na okładce. Choć wielu osobom może się wydawać, że to nic poważnego – ot, ciuchy, zdjęcia i wybiegi, błaha tematyka, nie to, co sprawy naprawdę ważne ([jak polityka](https://zaimki.pl/blog/osoby-niebinarne-na-listy-wyborcze)) – ta wiadomość oznaczała ni mniej, ni więcej, że w całej Polsce można było trafić na przekaz, że niebinarny model z Polski jest zauważalny w skali światowej. To kolejna cegiełka do naszej widoczności w społeczeństwie, a w dodatku wiadomość niezwykle odświeżająca: można być osobą queerową, transpłciową, niebinarną w Polsce – i mimo wszystko robić karierę! W rzeczywistości pełnej transfobii i enbyfobii potrzebujemy nie tylko historii o zmaganiach z problemami, ale też opowieści o sukcesie.
|
||||
|
||||
> Kariera Alin nabrała rozpędu po tym, jak w sezonie wiosna-lato 2023 zamykał pokaz Prady podczas włoskiego tygodnia mody w Mediolanie. Potem były kolejne prêt-à-porter i haute couture. Damskie i męskie. JW Anderson, Dries Van Noten, Sportmax i wreszcie Louis Vuitton. Dla tego ostatniego domu mody Alin przeszedł już trzy razy, ostatnio w Seulu na spektakularnym show kolekcji cruise. Ale jego talent ma wiele wymiarów. Alin próbuje swoich sił także w filmie – debiutował w „Pewnego razu w listopadzie” w reż. Andrzeja Jakimowskiego u boku Agaty Kuleszy i właśnie zagrał pierwszą główną rolę w produkcji Netflixa „Fanfik”.
|
||||
|
||||
To wstęp do wywiadu, które przeprowadziły z Alin Szewczyk Maja Chitro oraz Angelika Warlikowska. Model opowiedział im o odkrywaniu swojej tożsamości, o formach językowych, jakich używa w języku polskim i angielskim i o doświadczeniach misgenderingu.
|
||||
|
||||
> **Jak się do Ciebie zwracać?**
|
||||
>
|
||||
> ALIN SZEWCZYK: Identyfikuję się jako osoba niebinarna, transmęska. Oznacza to, że nie wpasowuję się w pojmowanie tego, kim dla społeczeństwa jest kobieta lub mężczyzna. Z kolei w swojej fizycznej ekspresji skłaniam się w stronę męską. W języku polskim dopiero uczymy się mówić o osobach niebinarnych. Znam ludzi, którzy używają osobatywów albo neutralnych form „ono”, „jeno”, „jenu”. Ja używam męskich, żeby było prościej. A w języku angielskim mówię o sobie „they/them”.
|
||||
>
|
||||
> **Zdarzyło się, że ktoś odmówił używania wobec Ciebie męskich zaimków?**
|
||||
>
|
||||
> Ostatnio nie, bo funkcjonuję wśród ludzi, którzy mnie szanują. Jeśli dojdzie do użycia innej formy, to przez pomyłkę, z przyzwyczajenia. Grono, w którym się obracam, jest akceptujące. Ale zdarzają się osoby, które mówią, że dla nich jestem dziewczyną, więc będą zwracać się do mnie w żeńskiej formie.
|
||||
>
|
||||
> **Co wtedy czujesz?**
|
||||
>
|
||||
> Myślę: „Znowu to samo”. Raczej mnie to nie rusza, ale wolę unikać takich sytuacji, bo im częściej się trafiają, tym gorzej wpływają na moją głowę. Prawda jest taka, że wciąż niewiele osób spoza okołoqueerowego środowiska ma styczność ze społecznością trans, więc nie zastanawia się nad tym.
|
||||
>
|
||||
> **Pamiętasz, kiedy po raz pierwszy świadomie zacząłeś mówić, że jesteś osobą niebinarną?**
|
||||
>
|
||||
> Odkrywanie swojej tożsamości to długi proces. Wiedziałem, że jestem transpłciowy już w wieku 10 lat. Przez to, że nie znałem słów, które pomogłyby mi to nazwać, myślałem długo, że jestem transchłopakiem. Tuż po liceum, gdy zacząłem żyć na własną rękę, zrozumiałem, że nie uznaję podziału na dwie płcie, nie czuję tego ani nie czaję, dlaczego miałbym być dziewczyna albo chłopakiem. Słowo „niebinarność” pozwoliło mi osadzić się w rzeczywistości. Mniej więcej od 2019 roku zacząłem funkcjonować otwarcie jako osoba niebinarna, a w 2020 zmieniłem zaimki na męskie. (…)
|
||||
>
|
||||
> **Po premierze filmu pojawił się na Netfliksie dokument „Jesteśmy idealni”, który jest rozwinięciem historii kilku osób startujących do głównej roli.**
|
||||
>
|
||||
> To dla mnie bardzo ważny projekt, w którym kamera podąża za ludźmi nieheteronormatywnymi. Twórcy dokumentu, Marek Kozakiewicz i Anu Czerwiński, pokazali ich codzienność, wzloty, upadki, problemy, z którymi się borykają. Każda z tych osób jest inna, każda się otworzyła. Tytuł „Jesteśmy idealni” jest dla mnie puszczeniem oczka, bo – jak widać w dokumencie – nie jesteśmy. I bardzo dobrze zdajemy sobie z tego sprawę. Czasem czujemy się samotni w tym, jak nas postrzegają ci, którzy nas nie rozumieją. Podczas kręcenia tego materiału została wykonana tytaniczna praca emocjonalna zarówno ze strony twórców, jak i osób, które w nim występowały. Takie produkcje jak „Fanfik” czy „Jesteśmy idealni” mogą pomóc otworzyć oczy na to, że ludzie tacy jak ja to też ludzie. Zostawiłem w tym projekcie swoje flaki, jestem nim poruszony, dumny i cieszę się, bo zmienił moje życie na lepsze, zbliżył z powrotem z rodziną. Udowodnił moją sprawczość w życiu.
|
||||
|
||||
To oczywiście tylko fragment rozmowy z Alin – ten najbardziej koncentrujący się na kwestiach językowych. Nie jest to jedyny interesujący fragment wywiadu. Alin wyznał „Elle” m.in., że nie odpowiada mu konsumpcjonizm i mimo kariery… marzy o wybudowaniu mostu. Dosłownie – zdobył uprawienia spawacza i chciałby przekuć te umiejętności w czyn.
|
||||
|
||||
[„Popkultura nie pozwoli Wam ignorować niebinarności”](https://zaimki.pl/blog/popkultura-niebinarność) – pisałośmy, opowiadając o rolach Emmy D’Arcy, Belli Ramsey oraz innych niebinarnych osób aktorskich. Bo czy tego chcemy, czy nie, bez względu na to, co sądzimy o branży modowej czy Hollywood, to głośne kariery sprawiają, że nie da się przemilczeć naszego istnienia. To wywiady z osobami celebryckimi wymuszają korzystanie z poprawnych form i normalizują podawanie zaimków w biogramach. To ich zdjęcia trafiają na okładki głośnych pism – i oby było tak jak najczęściej!
|
@ -86,7 +86,7 @@ Na ten moment nie podejrzewam, aby kwestia osób transpłciowych i niebinarnych
|
||||
|
||||
### SAN KOCOŃ, ono/jego (Zieloni, Koalicja Obywatelska)
|
||||
|
||||
Potrzebujemy osób niebinarnych w Sejmie, bo potrzebujemy reprezentacji różnych grup społecznych w polityce. Ludzi różnych doświadczeń. Istotna kwestia — przecież ja nie startuję do Sejmu, bo jestem osobom niebinarną i teraz mam wielki plan szokować wszystkie osoby o konserwatywnych poglądach swoim istnieniem. Zajmuję się polityką młodzieżową i tak przypadkiem się zdarzyło, że jestem osobą niebinarną. Przyznam jednak, że uruchomienie tej całej dyskusji o niebinarności i języku neutralnym w polityce, to bardzo niespodziewany, ale przyjemny dodatek do mojej kandydatury.
|
||||
Potrzebujemy osób niebinarnych w Sejmie, bo potrzebujemy reprezentacji różnych grup społecznych w polityce. Ludzi różnych doświadczeń. Istotna kwestia — przecież ja nie startuję do Sejmu, bo jestem osobą niebinarną i teraz mam wielki plan szokować wszystkie osoby o konserwatywnych poglądach swoim istnieniem. Zajmuję się polityką młodzieżową i tak przypadkiem się zdarzyło, że jestem osobą niebinarną. Przyznam jednak, że uruchomienie tej całej dyskusji o niebinarności i języku neutralnym w polityce, to bardzo niespodziewany, ale przyjemny dodatek do mojej kandydatury.
|
||||
|
||||
### IRYS BARDA, on/onu (Razem, Nowa Lewica)
|
||||
Z takiego samego powodu dlaczego w sejmie potrzebujemy żeby zasiadały kobiety osoby z niepełnosprawnościami i z innych marginalizowanych grup społecznych.
|
||||
|
BIN
locale/pl/img/blog/elle-alin.jpg
Normal file
BIN
locale/pl/img/blog/elle-alin.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 127 KiB |
5
new/.eslintrc.json
Normal file
5
new/.eslintrc.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": ["prettier", "plugin:@typescript-eslint/recommended"],
|
||||
"parser": "@typescript-eslint/parser"
|
||||
}
|
16
new/.gitignore
vendored
Normal file
16
new/.gitignore
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
# This may repeat some stuff in the top-level gitignore.
|
||||
# Any such case is intentional.
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Project-specific
|
||||
.env
|
||||
/locales
|
||||
|
||||
# Editors
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
2
new/.npmrc
Normal file
2
new/.npmrc
Normal file
@ -0,0 +1,2 @@
|
||||
shamefully-hoist=true
|
||||
link-workspace-packages=false
|
4
new/.prettierignore
Normal file
4
new/.prettierignore
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
pnpm-lock.yaml
|
7
new/.prettierrc
Normal file
7
new/.prettierrc
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false
|
||||
}
|
50
new/README.md
Normal file
50
new/README.md
Normal file
@ -0,0 +1,50 @@
|
||||
# `/new`
|
||||
|
||||
This is the working directory for the Pronouns.page rewrite.
|
||||
|
||||
## Setup instructions
|
||||
|
||||
> Note that this section will assume you are using [pnpm](https://pnpm.io).
|
||||
|
||||
1. Install dependencies using `pnpm install`.
|
||||
2. Run the `setup.mjs` script using `node ./setup.mjs`.
|
||||
If you are using a package manager *other* than pnpm, you should specify that as an argument (`node ./setup.mjs yarn`).
|
||||
This is not recommended, however.
|
||||
3. Build the `common` package (`pnpm build`). This step has to be done every time you change it.
|
||||
4. The next step depends _entirely_ on what part of the project you're working on:
|
||||
1. For the backend: Run `pnpm dev`. This will automatically build and restart the server when you change the code.
|
||||
It's actually quite fast.
|
||||
|
||||
## Development environment
|
||||
|
||||
### `setup.mjs`
|
||||
|
||||
Run `setup.mjs` for a quick setup. It'll symlink some directories and will work as the equivalent of `make switch` et al.
|
||||
The reason for making this a script is that make was causing problems for Windows users.
|
||||
|
||||
This script requires a package manager to run, but does not have any non-builtin dependencies.
|
||||
If you are using a package manager other than `pnpm`, you should specify it as the first argument to the script.
|
||||
|
||||
```
|
||||
node setup.mjs [pnpm/npm/yarn/bun]
|
||||
```
|
||||
|
||||
### Package managers
|
||||
|
||||
pnpm should work without any prior setup.
|
||||
I haven't tested yarn and npm here, and I don't think I will either.
|
||||
Just use pnpm for as little friction as possible.
|
||||
|
||||
### Style and lints
|
||||
|
||||
Prettier and ESLint are used for formatting and linting respectively.
|
||||
|
||||
You can run `pnpm format` from the top-level directory of the rewrite to format all the code easily.
|
||||
|
||||
### Other notes
|
||||
|
||||
Currently, the packages here are using `"type": "module"`. This is mostly experimental and can be changed if it turns out to be annoying/code-breaking.
|
||||
|
||||
## Process and collaboration
|
||||
|
||||
Most decisions should be discussed in the team Discord.
|
8
new/backend/.gitignore
vendored
Normal file
8
new/backend/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
|
||||
# Remove the database file
|
||||
db.sqlite
|
||||
# ...oh, and Prisma's dist directory for safety
|
||||
/prisma/dist
|
30
new/backend/example.env
Normal file
30
new/backend/example.env
Normal file
@ -0,0 +1,30 @@
|
||||
# -- Example environment file, provided for convenience.
|
||||
# -- It is recommended that you read through it.
|
||||
|
||||
# -- Specifies the environment. If this variable is not provided, it defaults to "development".
|
||||
# -- Allowed values:
|
||||
# "development", "dev": Development environment
|
||||
# "production", "prod": Production environment
|
||||
ENVIRONMENT=development
|
||||
# -- By default, ENVIRONMENT dictates the log level.
|
||||
# -- However, the log level can be set manually through LOG_LEVEL.
|
||||
# -- See pino's log levels. They're conventional. This value is also case-insensitive.
|
||||
# LOG_LEVEL=
|
||||
|
||||
# -- Whether or not to allow API calls for unpublished (e.g. not-yet-ready) locales
|
||||
ALLOW_UNPUBLISHED_LOCALES=true
|
||||
|
||||
# -- This base URL only refers to the backend.
|
||||
# -- This also means we don't need HOME_URL, since the API doesn't have a homepage per se.
|
||||
HTTP_BASE_URL=http://localhost:4000
|
||||
# -- These two variables configure the bind address. It defaults to 0.0.0.0:4000.
|
||||
# HTTP_HOST=0.0.0.0
|
||||
# HTTP_PORT=4000
|
||||
|
||||
# -- This variable determines the database to connect to.
|
||||
# -- Note that this has to be of the same database type as specified in the Prisma schema.
|
||||
# -- Right now it's SQLite since we already use it.
|
||||
DATABASE_URL=file:./db.sqlite
|
||||
|
||||
# -- Do change this variable, lest you wish to anger the computer.
|
||||
SECURITY_SECRET=changeme
|
3
new/backend/nodemon.json
Normal file
3
new/backend/nodemon.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"watch": ["dist/**", "package.json", "tsconfig.json"]
|
||||
}
|
49
new/backend/package.json
Normal file
49
new/backend/package.json
Normal file
@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@pronounspage/backend",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"format": "prettier -w .",
|
||||
"lint": "eslint src/",
|
||||
"clean": "rimraf ./dist ./tsconfig.tsbuildinfo",
|
||||
"build:server": "tsc",
|
||||
"build": "run-p build:*",
|
||||
"watch:server": "tsc --watch",
|
||||
"watch": "run-p watch:*",
|
||||
"cleanbuild": "run-s clean build",
|
||||
"start": "node dist/index.js",
|
||||
"start:fresh": "run-s build start",
|
||||
"start:watch": "run-p build:watch",
|
||||
"dev:start": "nodemon dist/index.js",
|
||||
"dev": "run-p watch dev:start",
|
||||
"db:generate": "prisma generate"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"dependencies": {
|
||||
".prisma/client": "link:prisma/dist",
|
||||
"@fastify/type-provider-typebox": "^3.5.0",
|
||||
"@prisma/client": "5.3.1",
|
||||
"@pronounspage/common": "workspace:*",
|
||||
"@sinclair/typebox": "^0.31.14",
|
||||
"csv-parse": "^5.5.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"fastify": "^4.21.0",
|
||||
"pino": "^8.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.5.6",
|
||||
"nodemon": "^3.0.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"pino-pretty": "^10.2.0",
|
||||
"prisma": "^5.3.1",
|
||||
"rimraf": "^5.0.1",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"imports": {
|
||||
"#self/*": "./dist/*.js"
|
||||
}
|
||||
}
|
334
new/backend/prisma/schema.prisma
Normal file
334
new/backend/prisma/schema.prisma
Normal file
@ -0,0 +1,334 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
output = "dist"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model OldAuditLog {
|
||||
id String @id
|
||||
userId String?
|
||||
username String?
|
||||
event String
|
||||
payload String?
|
||||
|
||||
@@map("audit_log")
|
||||
}
|
||||
|
||||
model NewAuditLog {
|
||||
id Int @id @default(autoincrement())
|
||||
userId String
|
||||
username String
|
||||
email String
|
||||
loggedAt Int
|
||||
locale String
|
||||
actionType String
|
||||
actionData String
|
||||
|
||||
@@map("audit_logs_new")
|
||||
}
|
||||
|
||||
model Authenticator {
|
||||
id String @id
|
||||
userId String?
|
||||
type String
|
||||
payload String
|
||||
validUntil Int?
|
||||
users User? @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@index([userId], map: "authenticators_userId")
|
||||
@@index([type], map: "authenticators_type")
|
||||
@@map("authenticators")
|
||||
}
|
||||
|
||||
// Some manual changes here:
|
||||
// 1. `bannedBy` is a nullable
|
||||
model BanProposal {
|
||||
id String @id
|
||||
userId String
|
||||
bannedBy String?
|
||||
bannedTerms String
|
||||
bannedReason String
|
||||
bannedByUser User? @relation("ban_proposals_bannedByTousers", fields: [bannedBy], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||
user User @relation("ban_proposals_userIdTousers", fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@index([userId], map: "ban_proposals_userId")
|
||||
@@map("ban_proposals")
|
||||
}
|
||||
|
||||
model Ban {
|
||||
type String
|
||||
value String
|
||||
|
||||
@@id([type, value])
|
||||
@@map("ban")
|
||||
}
|
||||
|
||||
model Inclusive {
|
||||
id String @id
|
||||
insteadOf String
|
||||
say String
|
||||
because String
|
||||
locale String
|
||||
approved Int
|
||||
base_id String?
|
||||
author_id String?
|
||||
categories String?
|
||||
links String?
|
||||
deleted Int @default(0)
|
||||
clarification String?
|
||||
users User? @relation(fields: [author_id], references: [id], onUpdate: NoAction)
|
||||
|
||||
@@index([insteadOf], map: "inclusive_insteadOf")
|
||||
@@index([locale], map: "inclusive_locale")
|
||||
@@map("inclusive")
|
||||
}
|
||||
|
||||
/// The underlying table does not contain a valid unique identifier and can therefore currently not be handled by Prisma Client.
|
||||
model Link {
|
||||
url String? @id
|
||||
expiresAt Int?
|
||||
favicon String?
|
||||
relMe String?
|
||||
nodeinfo String?
|
||||
|
||||
@@map("links")
|
||||
@@ignore
|
||||
}
|
||||
|
||||
model Name {
|
||||
id String @id
|
||||
name String
|
||||
locale String
|
||||
origin String?
|
||||
meaning String?
|
||||
usage String?
|
||||
legally String?
|
||||
pros String?
|
||||
cons String?
|
||||
notablePeople String?
|
||||
links String?
|
||||
namedays String?
|
||||
namedaysComment String?
|
||||
deleted Int @default(0)
|
||||
approved Int @default(0)
|
||||
base_id String?
|
||||
author_id String?
|
||||
|
||||
@@index([locale], map: "names_locale")
|
||||
@@index([name], map: "names_name")
|
||||
@@index([locale, name], map: "names_name_locale")
|
||||
@@map("names")
|
||||
}
|
||||
|
||||
model Noun {
|
||||
id String @id
|
||||
masc String
|
||||
fem String
|
||||
neutr String
|
||||
mascPl String
|
||||
femPl String
|
||||
neutrPl String
|
||||
approved Int
|
||||
base_id String?
|
||||
locale String @default("pl")
|
||||
author_id String?
|
||||
deleted Int @default(0)
|
||||
sources String?
|
||||
users User? @relation(fields: [author_id], references: [id], onUpdate: NoAction)
|
||||
|
||||
@@index([masc], map: "nouns_masc")
|
||||
@@index([locale], map: "nouns_locale")
|
||||
@@map("nouns")
|
||||
}
|
||||
|
||||
model Profile {
|
||||
id String @id
|
||||
userId String
|
||||
locale String
|
||||
names String
|
||||
pronouns String
|
||||
description String
|
||||
birthday String?
|
||||
links String
|
||||
flags String
|
||||
words String
|
||||
active Int
|
||||
teamName String?
|
||||
footerName String?
|
||||
footerAreas String?
|
||||
customFlags String @default("{}")
|
||||
card String?
|
||||
credentials String?
|
||||
credentialsLevel Int?
|
||||
credentialsName Int?
|
||||
cardDark String?
|
||||
opinions String @default("{}")
|
||||
timezone String?
|
||||
sensitive String @default("[]")
|
||||
users User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
user_connections UserConnection[]
|
||||
|
||||
@@unique([userId, locale], map: "sqlite_autoindex_profiles_2")
|
||||
@@index([footerAreas], map: "profiles_footerAreas")
|
||||
@@index([footerName], map: "profiles_footerName")
|
||||
@@index([teamName], map: "profiles_teamName")
|
||||
@@index([locale, userId], map: "profiles_locale_userId")
|
||||
@@index([userId], map: "profiles_userId")
|
||||
@@index([locale], map: "profiles_locale")
|
||||
@@map("profiles")
|
||||
}
|
||||
|
||||
model Report {
|
||||
id String @id
|
||||
userId String?
|
||||
reporterId String?
|
||||
comment String
|
||||
isAutomatic Int?
|
||||
isHandled Int?
|
||||
snapshot String?
|
||||
users_reports_reporterIdTousers User? @relation("reports_reporterIdTousers", fields: [reporterId], references: [id], onUpdate: NoAction)
|
||||
users_reports_userIdTousers User? @relation("reports_userIdTousers", fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@index([userId], map: "reports_userId")
|
||||
@@index([isHandled], map: "reports_isHandled")
|
||||
@@index([isAutomatic], map: "reports_isAutomatic")
|
||||
@@map("reports")
|
||||
}
|
||||
|
||||
/// The underlying table does not contain a valid unique identifier and can therefore currently not be handled by Prisma Client.
|
||||
model SocialLookup {
|
||||
userId String
|
||||
provider String
|
||||
identifier String
|
||||
users User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@index([provider, identifier], map: "social_lookup_provider_identifier")
|
||||
@@map("social_lookup")
|
||||
@@ignore
|
||||
}
|
||||
|
||||
model Source {
|
||||
id String @id
|
||||
locale String
|
||||
pronouns String
|
||||
type String
|
||||
author String?
|
||||
title String
|
||||
extra String?
|
||||
year Int?
|
||||
fragments String
|
||||
comment String?
|
||||
link String?
|
||||
submitter_id String?
|
||||
approved Int? @default(0)
|
||||
deleted Int? @default(0)
|
||||
base_id String?
|
||||
key String?
|
||||
images String?
|
||||
spoiler Int @default(0)
|
||||
users User? @relation(fields: [submitter_id], references: [id], onUpdate: NoAction)
|
||||
|
||||
@@index([locale], map: "sources_locale")
|
||||
@@map("sources")
|
||||
}
|
||||
|
||||
model Stat {
|
||||
id String
|
||||
locale String
|
||||
users Int
|
||||
data String
|
||||
|
||||
@@id([id, locale])
|
||||
@@map("stats")
|
||||
}
|
||||
|
||||
/// The underlying table does not contain a valid unique identifier and can therefore currently not be handled by Prisma Client.
|
||||
model SubscriptionMessage {
|
||||
id String? @id
|
||||
subscription_id String
|
||||
campaign String
|
||||
|
||||
@@map("subscription_messages")
|
||||
@@ignore
|
||||
}
|
||||
|
||||
model Term {
|
||||
id String @id
|
||||
term String
|
||||
original String?
|
||||
definition String
|
||||
locale String
|
||||
approved Int
|
||||
base_id String?
|
||||
author_id String?
|
||||
deleted Int @default(0)
|
||||
flags String @default("[]")
|
||||
category String?
|
||||
images String @default("")
|
||||
key String?
|
||||
users User? @relation(fields: [author_id], references: [id], onUpdate: NoAction)
|
||||
|
||||
@@index([term], map: "terms_term")
|
||||
@@index([locale], map: "terms_locale")
|
||||
@@map("terms")
|
||||
}
|
||||
|
||||
model UserConnection {
|
||||
id String @id
|
||||
from_profileId String
|
||||
to_userId String
|
||||
relationship String
|
||||
users User @relation(fields: [to_userId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
profiles Profile @relation(fields: [from_profileId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@index([to_userId], map: "user_connections_to_userId")
|
||||
@@index([from_profileId], map: "user_connections_from_profileId")
|
||||
@@map("user_connections")
|
||||
}
|
||||
|
||||
model UserMessage {
|
||||
id String @id
|
||||
userId String
|
||||
adminId String
|
||||
message String
|
||||
|
||||
@@map("user_messages")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
username String @unique(map: "users_username")
|
||||
email String @unique(map: "users_email")
|
||||
roles String
|
||||
avatarSource String?
|
||||
bannedReason String?
|
||||
suspiciousChecked Unsupported("tinyint") @default(dbgenerated("0"))
|
||||
usernameNorm String? @unique(map: "users_usernameNorm")
|
||||
bannedTerms String?
|
||||
bannedBy String?
|
||||
lastActive Int?
|
||||
banSnapshot String?
|
||||
inactiveWarning Int?
|
||||
adminNotifications Int @default(7)
|
||||
loginAttempts String?
|
||||
timesheets String?
|
||||
socialLookup Int @default(0)
|
||||
authenticators Authenticator[]
|
||||
ban_proposals_ban_proposals_bannedByTousers BanProposal[] @relation("ban_proposals_bannedByTousers")
|
||||
ban_proposals_ban_proposals_userIdTousers BanProposal[] @relation("ban_proposals_userIdTousers")
|
||||
inclusive Inclusive[]
|
||||
nouns Noun[]
|
||||
profiles Profile[]
|
||||
reports_reports_reporterIdTousers Report[] @relation("reports_reporterIdTousers")
|
||||
reports_reports_userIdTousers Report[] @relation("reports_userIdTousers")
|
||||
social_lookup SocialLookup[] @ignore
|
||||
sources Source[]
|
||||
terms Term[]
|
||||
user_connections UserConnection[]
|
||||
|
||||
@@map("users")
|
||||
}
|
138
new/backend/src/config.ts
Normal file
138
new/backend/src/config.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import "dotenv/config";
|
||||
import {
|
||||
identity,
|
||||
parseBool,
|
||||
parseInteger,
|
||||
toLowerCase,
|
||||
} from "@pronounspage/common/util";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs";
|
||||
import type { log } from "#self/log";
|
||||
|
||||
export enum Environment {
|
||||
DEVELOPMENT = "dev",
|
||||
PRODUCTION = "production",
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
environment: Environment;
|
||||
logLevel?: (typeof log)["level"];
|
||||
http: {
|
||||
baseUrl: string;
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
database: {
|
||||
url: string;
|
||||
}
|
||||
security: {
|
||||
secret: string;
|
||||
};
|
||||
localeDataPath: string;
|
||||
allowUnpublishedLocales: boolean;
|
||||
}
|
||||
|
||||
function envVarOrDefault<T>(
|
||||
key: string,
|
||||
parse: (value: string) => T,
|
||||
defaultValue: T
|
||||
): T;
|
||||
function envVarOrDefault<T>(
|
||||
key: string,
|
||||
parse: (value: string) => T,
|
||||
defaultValue?: undefined
|
||||
): T | undefined;
|
||||
function envVarOrDefault<T>(
|
||||
key: string,
|
||||
parse: (value: string) => T,
|
||||
defaultValue: T | undefined = undefined
|
||||
): T | undefined {
|
||||
const value = process.env[key];
|
||||
return value == null ? defaultValue : parse(String(value));
|
||||
}
|
||||
|
||||
function envVarNotNull<T>(key: string, parse: (value: string) => T): T {
|
||||
const value = envVarOrDefault(key, parse);
|
||||
if (value === undefined) {
|
||||
throw new Error(`Environment variable ${key} is missing`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseEnvironment(value: string): Environment {
|
||||
switch (value.toLowerCase()) {
|
||||
case "dev":
|
||||
case "development":
|
||||
return Environment.DEVELOPMENT;
|
||||
case "prod":
|
||||
case "production":
|
||||
return Environment.PRODUCTION;
|
||||
default:
|
||||
throw new Error(`"${value}" is not a valid environment`);
|
||||
}
|
||||
}
|
||||
|
||||
function parsePath(value: string): string {
|
||||
return path.resolve(process.cwd(), value);
|
||||
}
|
||||
|
||||
export function loadConfigFromEnv(): Config {
|
||||
return {
|
||||
environment: envVarOrDefault(
|
||||
"ENVIRONMENT",
|
||||
parseEnvironment,
|
||||
Environment.DEVELOPMENT
|
||||
),
|
||||
logLevel: envVarOrDefault("LOG_LEVEL", toLowerCase, undefined),
|
||||
http: {
|
||||
baseUrl: envVarNotNull("HTTP_BASE_URL", identity),
|
||||
host: envVarOrDefault("HTTP_HOST", identity, "0.0.0.0"),
|
||||
port: envVarOrDefault("HTTP_PORT", parseInteger, 4000),
|
||||
},
|
||||
database: {
|
||||
url: envVarNotNull("DATABASE_URL", identity),
|
||||
},
|
||||
security: {
|
||||
secret: envVarNotNull("SECURITY_SECRET", identity),
|
||||
},
|
||||
localeDataPath: envVarOrDefault(
|
||||
"LOCALE_DATA_PATH",
|
||||
parsePath,
|
||||
"../locales"
|
||||
),
|
||||
allowUnpublishedLocales: envVarOrDefault(
|
||||
"ALLOW_UNPUBLISHED_LOCALES",
|
||||
parseBool,
|
||||
false
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateConfig(config: Config): string | undefined {
|
||||
if (!fs.existsSync(config.localeDataPath)) {
|
||||
return `Locale data path is set to ${config.localeDataPath}, but it does not exist`;
|
||||
}
|
||||
// I mean these two next ones really aren't necessary, but I find it funny
|
||||
// ...who said we can't have a little fun here and there?
|
||||
const secretLower = config.security.secret.toLowerCase();
|
||||
if (secretLower === "changeme") {
|
||||
return `You didn't change the secret? The secret is quite literally "${secretLower}"`;
|
||||
}
|
||||
if (
|
||||
secretLower === "changemeonprod!" &&
|
||||
config.environment === Environment.PRODUCTION
|
||||
) {
|
||||
return `Hey now, this is production! You have to change the secret on prod! Look, it says so: ${config.security.secret}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let loadedConfig: Config | undefined = undefined;
|
||||
|
||||
export function getConfig() {
|
||||
if (loadedConfig == undefined) {
|
||||
loadedConfig = loadConfigFromEnv();
|
||||
}
|
||||
return loadedConfig;
|
||||
}
|
33
new/backend/src/global.d.ts
vendored
Normal file
33
new/backend/src/global.d.ts
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
import type {
|
||||
FastifyInstance,
|
||||
FastifyPluginAsync,
|
||||
// FastifyPluginCallback,
|
||||
FastifyPluginOptions,
|
||||
FastifyRequest,
|
||||
FastifyReply,
|
||||
RawReplyDefaultExpression,
|
||||
RawRequestDefaultExpression,
|
||||
RawServerDefault,
|
||||
} from "fastify";
|
||||
import type { TypeBoxTypeProvider } from "@fastify/type-provider-typebox";
|
||||
import type { log } from "#self/log";
|
||||
|
||||
type RawServer = RawServerDefault;
|
||||
type RequestExpr = RawRequestDefaultExpression<RawServer>;
|
||||
type ReplyExpr = RawReplyDefaultExpression<RawServer>;
|
||||
type Log = typeof log;
|
||||
type TypeProvider = TypeBoxTypeProvider;
|
||||
|
||||
declare global {
|
||||
type AppInstance = FastifyInstance<
|
||||
RawServer,
|
||||
RequestExpr,
|
||||
ReplyExpr,
|
||||
typeof log,
|
||||
TypeProvider
|
||||
>;
|
||||
type AppPluginAsync<
|
||||
Options extends FastifyPluginOptions = Record<never, never>,
|
||||
> = FastifyPluginAsync<Options, RawServer, TypeProvider, Log>;
|
||||
type AppReply = FastifyReply<RawServer, RequestExpr, ReplyExpr>;
|
||||
}
|
41
new/backend/src/index.ts
Normal file
41
new/backend/src/index.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { startServer } from "#self/server";
|
||||
import { getConfig, validateConfig } from "#self/config";
|
||||
import log from "#self/log";
|
||||
import { exit, pid, ppid } from "node:process";
|
||||
import {
|
||||
getActiveLocaleDescriptions,
|
||||
loadAllLocales,
|
||||
loadLocaleDescriptions,
|
||||
} from "./locales.js";
|
||||
|
||||
log.info(`Pronouns.page Backend Server - PID: ${pid}, PPID: ${ppid}`);
|
||||
|
||||
try {
|
||||
const validationError = validateConfig(getConfig());
|
||||
if (validationError != undefined) {
|
||||
log.error(`Configuration is invalid: ${validationError}`);
|
||||
exit(1);
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(`Could not validate configuration: ${e}`);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const localeDescriptions = await loadLocaleDescriptions();
|
||||
const active = getActiveLocaleDescriptions();
|
||||
await loadAllLocales();
|
||||
log.info(
|
||||
`${localeDescriptions.length} locale(s) are registered, of which ${active.length} are active`
|
||||
);
|
||||
} catch (e) {
|
||||
log.error(`Could not load locales: ${e}`);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const memUsage = process.memoryUsage();
|
||||
const memUsagePercent = (memUsage.heapUsed / memUsage.heapTotal) * 100;
|
||||
log.info(
|
||||
`Finished preparations - system stats: MEM ${memUsagePercent.toFixed(2)}%`
|
||||
);
|
||||
await startServer();
|
347
new/backend/src/locales.ts
Normal file
347
new/backend/src/locales.ts
Normal file
@ -0,0 +1,347 @@
|
||||
/*
|
||||
* This file is for interop with the old locales.js file to reduce duplication.
|
||||
*/
|
||||
|
||||
import { getConfig } from "#self/config";
|
||||
import * as path from "node:path";
|
||||
import * as suml from "@pronounspage/common/suml";
|
||||
import fsp from "node:fs/promises";
|
||||
import log from "#self/log";
|
||||
import * as csv from "csv-parse/sync";
|
||||
import {
|
||||
capitalizeFirstLetter,
|
||||
compileTemplate,
|
||||
isNotBlank,
|
||||
parseBool,
|
||||
} from "@pronounspage/common/util";
|
||||
|
||||
export interface LocaleDescription {
|
||||
code: string;
|
||||
name: string;
|
||||
url: string;
|
||||
published: boolean;
|
||||
/**
|
||||
* This locale's language family.
|
||||
*
|
||||
* @experimental
|
||||
* This is currently not used for published versions.
|
||||
*/
|
||||
symbol: string;
|
||||
/**
|
||||
* This locale's language family.
|
||||
*
|
||||
* @experimental
|
||||
* This is currently not used for published versions.
|
||||
*
|
||||
* @privateRemarks
|
||||
* This could technically be an enum but for now it's just a string as there
|
||||
* doesn't seem to be any apparent benefit to including locales
|
||||
*/
|
||||
family: string;
|
||||
}
|
||||
|
||||
let loadedDescriptions: Array<LocaleDescription>;
|
||||
let indexedDescriptions: Record<string, LocaleDescription>;
|
||||
let activeLocaleDescriptions: Array<LocaleDescription> | undefined;
|
||||
|
||||
export async function loadLocaleDescriptions(): Promise<
|
||||
Array<LocaleDescription>
|
||||
> {
|
||||
if (loadedDescriptions == undefined) {
|
||||
loadedDescriptions = (
|
||||
await import(
|
||||
"file://" +
|
||||
path.resolve(getConfig().localeDataPath, "locales.js")
|
||||
)
|
||||
).default;
|
||||
indexedDescriptions = Object.fromEntries(
|
||||
loadedDescriptions.map((v) => [v.code, v]),
|
||||
);
|
||||
activeLocaleDescriptions = undefined;
|
||||
}
|
||||
return loadedDescriptions;
|
||||
}
|
||||
|
||||
export function getCachedLocaleDescriptions(): Array<LocaleDescription> {
|
||||
return loadedDescriptions;
|
||||
}
|
||||
|
||||
export function getActiveLocaleDescriptions(): Array<LocaleDescription> {
|
||||
if (activeLocaleDescriptions == undefined) {
|
||||
const config = getConfig();
|
||||
const cached = getCachedLocaleDescriptions();
|
||||
|
||||
activeLocaleDescriptions = [];
|
||||
for (const locale of cached) {
|
||||
if (config.allowUnpublishedLocales || locale.published) {
|
||||
activeLocaleDescriptions.push(locale);
|
||||
}
|
||||
}
|
||||
}
|
||||
return activeLocaleDescriptions;
|
||||
}
|
||||
|
||||
export function isLocaleActive(localeCode: string): boolean {
|
||||
return getActiveLocaleDescriptions().some((v) => v.code === localeCode);
|
||||
}
|
||||
|
||||
export interface Pronoun {
|
||||
keys: Array<string>;
|
||||
description: string;
|
||||
history: string;
|
||||
source?: string;
|
||||
isNormative: boolean;
|
||||
isPlural: boolean;
|
||||
isPluralHonorific: boolean;
|
||||
isPronounceable: boolean;
|
||||
|
||||
thirdForm?: string;
|
||||
smallForm?: string;
|
||||
|
||||
forms: Record<string, { written: string; pronounced: string }>;
|
||||
}
|
||||
|
||||
export interface PronounExample {
|
||||
singular?: string;
|
||||
plural?: string;
|
||||
isHonorific: boolean;
|
||||
}
|
||||
|
||||
export class Locale {
|
||||
private _code: string;
|
||||
private _dataDirectory: string;
|
||||
|
||||
constructor(code: string, dataDirectory: string) {
|
||||
this._code = code;
|
||||
this._dataDirectory = dataDirectory;
|
||||
}
|
||||
|
||||
private path(pathParts: Array<string>) {
|
||||
return path.resolve(this._dataDirectory, ...pathParts);
|
||||
}
|
||||
|
||||
private async readFile(parts: Array<string>): Promise<string>;
|
||||
private async readFile<T>(
|
||||
parts: Array<string>,
|
||||
parser: (value: string) => T,
|
||||
): Promise<T>;
|
||||
private async readFile<T>(
|
||||
pathParts: Array<string>,
|
||||
parser?: (value: string) => T,
|
||||
) {
|
||||
// Some may complain about this
|
||||
const filePath = this.path(pathParts);
|
||||
try {
|
||||
log.trace(`[${this.code}] Loading file ${filePath}`);
|
||||
const data = await fsp.readFile(filePath, "utf-8");
|
||||
if (parser) {
|
||||
return parser(data);
|
||||
} else {
|
||||
return data;
|
||||
}
|
||||
} catch (e) {
|
||||
const err = new Error(
|
||||
`[${this.code}] Could not load file ${filePath}: ${e}`,
|
||||
);
|
||||
err.cause = e;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private async importFile(parts: Array<string>): Promise<unknown> {
|
||||
const filePath = this.path(parts);
|
||||
try {
|
||||
log.trace(`[${this.code}] Importing file ${filePath} for locale`);
|
||||
return await import(`file://${filePath}`);
|
||||
} catch (e) {
|
||||
const err = new Error(
|
||||
`[${this.code}] Could not import file ${filePath}: ${e}`,
|
||||
);
|
||||
err.cause = e;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private _config: unknown | null = null;
|
||||
private _translations: unknown | null = null;
|
||||
private _pronouns: Array<Pronoun> = [];
|
||||
private _pronounsByAlias: Record<string, number> = {};
|
||||
|
||||
private _examples: Array<PronounExample> = []; // TODO: Type these properly
|
||||
// private _morphemes: Array<string>;
|
||||
|
||||
public async load() {
|
||||
const tsvParse = (data: string) =>
|
||||
csv.parse(data, {
|
||||
columns: true,
|
||||
cast: false,
|
||||
relaxColumnCount: true,
|
||||
relaxQuotes: true,
|
||||
delimiter: "\t",
|
||||
});
|
||||
this._config = await this.readFile(["config.suml"], suml.parse);
|
||||
this._translations = await this.readFile(
|
||||
["translations.suml"],
|
||||
suml.parse,
|
||||
);
|
||||
// NOTE(tecc): This currently doesn't work because the morphemes.js files (as well as many others) rely on ESM syntax.
|
||||
// This is both inconsistent with the locales.js and expectedTranslations.js files,
|
||||
// and causes Node to throw an error.
|
||||
// this._morphemes = (await this.importFile("pronouns/morphemes.js")).default;
|
||||
this._pronouns = [];
|
||||
this._pronounsByAlias = {};
|
||||
const pronouns = await this.readFile(
|
||||
["pronouns/pronouns.tsv"],
|
||||
tsvParse,
|
||||
);
|
||||
for (const pronoun of pronouns) {
|
||||
const partial: Partial<Pronoun> = { forms: {} };
|
||||
for (const [key, value] of Object.entries<string>(pronoun)) {
|
||||
switch (key) {
|
||||
case "key":
|
||||
partial.keys = value.split(",");
|
||||
break;
|
||||
case "normative":
|
||||
partial.isNormative = parseBool(value);
|
||||
break;
|
||||
case "plural":
|
||||
partial.isPlural = parseBool(value);
|
||||
break;
|
||||
case "pluralHonorific":
|
||||
partial.isPluralHonorific = parseBool(value);
|
||||
break;
|
||||
case "pronounceable":
|
||||
partial.isPronounceable = parseBool(value);
|
||||
break;
|
||||
case "description":
|
||||
case "history":
|
||||
case "thirdForm":
|
||||
case "smallForm":
|
||||
partial[key] = isNotBlank(value) ? value : undefined;
|
||||
break;
|
||||
case "sourcesInfo":
|
||||
partial.source = value;
|
||||
break;
|
||||
default:
|
||||
const [written, pronounced] = value.split("|");
|
||||
partial.forms![key] = { written, pronounced };
|
||||
break;
|
||||
}
|
||||
}
|
||||
const i = this._pronouns.length;
|
||||
const obj = partial as Pronoun;
|
||||
this._pronouns.push(obj);
|
||||
for (const key of obj.keys) {
|
||||
this._pronounsByAlias[key] = i;
|
||||
}
|
||||
}
|
||||
const examples = await this.readFile(
|
||||
["pronouns/examples.tsv"],
|
||||
tsvParse,
|
||||
);
|
||||
this._examples = [];
|
||||
for (const example of examples) {
|
||||
this._examples.push({
|
||||
singular: isNotBlank(example.singular)
|
||||
? example.singular
|
||||
: undefined,
|
||||
plural: isNotBlank(example.plural) ? example.plural : undefined,
|
||||
isHonorific: example.isHonorific,
|
||||
} satisfies PronounExample);
|
||||
}
|
||||
}
|
||||
|
||||
public get code() {
|
||||
return this._code;
|
||||
}
|
||||
|
||||
public get description() {
|
||||
return indexedDescriptions[this.code];
|
||||
}
|
||||
|
||||
public get config() {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
public get pronouns() {
|
||||
return Object.values(this._pronouns);
|
||||
}
|
||||
|
||||
public pronoun(key: string): Pronoun | null {
|
||||
const index = this._pronounsByAlias[key];
|
||||
if (index == null) {
|
||||
return null;
|
||||
}
|
||||
return this._pronouns[index];
|
||||
}
|
||||
|
||||
public get examples() {
|
||||
return this._examples;
|
||||
}
|
||||
}
|
||||
|
||||
export function examplesFor(
|
||||
pronoun: Pronoun,
|
||||
examples: Array<PronounExample>,
|
||||
): Array<string> {
|
||||
const finished = [];
|
||||
|
||||
const templating: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(pronoun.forms)) {
|
||||
templating[key] = value.written;
|
||||
templating[`'${key}`] = capitalizeFirstLetter(value.written);
|
||||
}
|
||||
|
||||
for (const example of examples) {
|
||||
let template = example.singular;
|
||||
if (pronoun.isPlural && example.plural) {
|
||||
template = example.plural;
|
||||
}
|
||||
if (template == null) {
|
||||
continue;
|
||||
}
|
||||
finished.push(compileTemplate(template, templating));
|
||||
}
|
||||
return finished;
|
||||
}
|
||||
|
||||
const loadedLocales: Record<string, Locale> = {};
|
||||
|
||||
/**
|
||||
* Load a locale.
|
||||
* This will only succeed if the locale is both
|
||||
* @param localeCode the locale code
|
||||
*/
|
||||
export function getLocale(localeCode: string): Locale | null {
|
||||
if (!isLocaleActive(localeCode)) {
|
||||
return null;
|
||||
}
|
||||
let locale = loadedLocales[localeCode];
|
||||
if (locale == null) {
|
||||
locale = new Locale(
|
||||
localeCode,
|
||||
path.resolve(getConfig().localeDataPath, localeCode),
|
||||
);
|
||||
loadedLocales[localeCode] = locale;
|
||||
}
|
||||
return locale;
|
||||
}
|
||||
|
||||
export async function loadAllLocales() {
|
||||
const descriptions = await loadLocaleDescriptions();
|
||||
const promises = [];
|
||||
for (const description of descriptions) {
|
||||
const locale = getLocale(description.code);
|
||||
if (locale) {
|
||||
promises.push(
|
||||
locale
|
||||
.load()
|
||||
.then(() =>
|
||||
log.debug(`Successfully loaded locale ${locale.code}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
await Promise.all(promises);
|
||||
log.info("All active locales have been loaded into memory");
|
||||
}
|
35
new/backend/src/log.ts
Normal file
35
new/backend/src/log.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { pino, LoggerOptions } from "pino";
|
||||
import { getConfig, Environment } from "#self/config";
|
||||
|
||||
const loggerConfigurations = {
|
||||
[Environment.DEVELOPMENT]: {
|
||||
level: "debug",
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
options: {
|
||||
translateTime: "HH:MM:ss Z",
|
||||
ignore: "pid,hostname",
|
||||
},
|
||||
},
|
||||
},
|
||||
[Environment.PRODUCTION]: {
|
||||
level: "info",
|
||||
},
|
||||
} satisfies Record<Environment, LoggerOptions>;
|
||||
|
||||
let environment, logLevel;
|
||||
try {
|
||||
const config = getConfig();
|
||||
environment = config.environment;
|
||||
logLevel = config.logLevel;
|
||||
} catch (e) {
|
||||
environment = Environment.DEVELOPMENT;
|
||||
}
|
||||
|
||||
const configuration = loggerConfigurations[environment];
|
||||
if (logLevel) {
|
||||
configuration.level = logLevel;
|
||||
}
|
||||
export const log = pino(configuration);
|
||||
|
||||
export default log;
|
40
new/backend/src/server.ts
Normal file
40
new/backend/src/server.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import fastify from "fastify";
|
||||
import log from "#self/log";
|
||||
import { getConfig } from "#self/config";
|
||||
import v1 from "#self/server/v1";
|
||||
|
||||
export async function startServer() {
|
||||
const config = getConfig();
|
||||
const app = fastify({
|
||||
logger: log,
|
||||
ajv: {
|
||||
customOptions: {
|
||||
coerceTypes: "array",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let start: Date;
|
||||
|
||||
app.get("/ping", (req, reply) => {
|
||||
reply.send({
|
||||
message: `Pong! The server is running!`,
|
||||
status: {
|
||||
uptime: Date.now() - start.getTime(),
|
||||
},
|
||||
});
|
||||
});
|
||||
app.register(v1, { prefix: "/v1" });
|
||||
|
||||
try {
|
||||
await app.listen({
|
||||
host: config.http.host,
|
||||
port: config.http.port,
|
||||
});
|
||||
start = new Date(Date.now());
|
||||
log.info(`(Public base URL: ${config.http.baseUrl})`);
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
return;
|
||||
}
|
||||
}
|
89
new/backend/src/server/v1.ts
Normal file
89
new/backend/src/server/v1.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { examplesFor, getLocale, Pronoun, PronounExample } from "#self/locales";
|
||||
import pronouns from "#self/server/v1/pronouns";
|
||||
|
||||
export type V1AppInstance = AppInstance;
|
||||
|
||||
export interface ApiErrorOptions {
|
||||
code: number;
|
||||
message: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an error object for the v1 API endpoints.
|
||||
* This function may seem a bit extraneous but it's a useful utility for consistency.
|
||||
* @param options
|
||||
*/
|
||||
export function error<T extends ApiErrorOptions>(options: T) {
|
||||
const response: Record<string, unknown> = {
|
||||
code: options.code,
|
||||
message: options.message,
|
||||
};
|
||||
if (options.detail) {
|
||||
response.detail = options.detail;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export function replyError<T extends ApiErrorOptions>(
|
||||
reply: AppReply,
|
||||
options: T,
|
||||
): AppReply {
|
||||
const response = error(options);
|
||||
return reply.status(options.code).send(response);
|
||||
}
|
||||
|
||||
export const ApiError = {
|
||||
INVALID_LOCALE: {
|
||||
code: 404,
|
||||
message: "Invalid locale",
|
||||
},
|
||||
UNKNOWN_PRONOUN: {
|
||||
code: 404,
|
||||
message: "We aren't aware of any such pronoun. Perhaps there's a typo?\nNote that for custom pronouns, you need to specify all necessary forms: https://en.pronouns.page/faq#custom-pronouns",
|
||||
},
|
||||
} satisfies Record<string, ApiErrorOptions>;
|
||||
|
||||
export const localeSpecific = Type.Object({
|
||||
locale: Type.String(),
|
||||
});
|
||||
|
||||
export function transformPronoun(
|
||||
pronoun: Pronoun,
|
||||
examples: Array<PronounExample>,
|
||||
) {
|
||||
const morphemes: Record<string, string> = {};
|
||||
const pronunciations: Record<string, string> = {};
|
||||
for (const [name, form] of Object.entries(pronoun.forms)) {
|
||||
morphemes[name] = form.written;
|
||||
pronunciations[name] = form.pronounced;
|
||||
}
|
||||
return {
|
||||
canonicalName: pronoun.keys[0],
|
||||
description: pronoun.description,
|
||||
normative: pronoun.isNormative,
|
||||
morphemes,
|
||||
pronunciations,
|
||||
plural: [pronoun.isPlural],
|
||||
pluralHonorific: [pronoun.isPluralHonorific],
|
||||
aliases: pronoun.keys.slice(1),
|
||||
history: pronoun.history ?? "",
|
||||
pronounceable: pronoun.isPronounceable,
|
||||
thirdForm: pronoun.thirdForm ?? null,
|
||||
smallForm: pronoun.smallForm ?? null,
|
||||
sourcesInfo: pronoun.source ?? null,
|
||||
examples: examplesFor(pronoun, examples),
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Version 1 of the API. This would be the "legacy" version.
|
||||
* Since the output of every endpoint is locale-specific,
|
||||
* every route is prefixed with the respective locale ID.
|
||||
*/
|
||||
|
||||
export const routes = async function(app: AppInstance) {
|
||||
app.register(pronouns);
|
||||
} satisfies AppPluginAsync;
|
||||
export default routes;
|
84
new/backend/src/server/v1/pronouns.ts
Normal file
84
new/backend/src/server/v1/pronouns.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import {
|
||||
localeSpecific,
|
||||
ApiError,
|
||||
replyError,
|
||||
transformPronoun,
|
||||
} from "#self/server/v1";
|
||||
import { getLocale, PronounExample } from "#self/locales";
|
||||
import { isNotBlank, parseBool } from "@pronounspage/common/util";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
export const plugin = async function (app: AppInstance) {
|
||||
// TODO: "Memoize" this route to reduce memory usage
|
||||
app.get(
|
||||
"/:locale/pronouns",
|
||||
{
|
||||
schema: {
|
||||
params: localeSpecific,
|
||||
},
|
||||
},
|
||||
(request, reply) => {
|
||||
const locale = getLocale(request.params.locale);
|
||||
if (locale === null) {
|
||||
return replyError(reply, ApiError.INVALID_LOCALE);
|
||||
}
|
||||
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const pronoun of locale.pronouns) {
|
||||
obj[pronoun.keys[0]] = transformPronoun(
|
||||
pronoun,
|
||||
locale.examples
|
||||
);
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
);
|
||||
|
||||
function parseExample(s: string): PronounExample {
|
||||
const [singular, plural, isHonorific] = s.split("|");
|
||||
return {
|
||||
singular: isNotBlank(singular) ? singular : undefined,
|
||||
plural: isNotBlank(plural) ? plural : undefined,
|
||||
isHonorific: isNotBlank(isHonorific) && parseBool(isHonorific),
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: See previous TODO. Although maybe not with query parameters.
|
||||
app.get(
|
||||
"/:locale/pronouns/*",
|
||||
{
|
||||
schema: {
|
||||
params: Type.Intersect([
|
||||
localeSpecific,
|
||||
Type.Object({ "*": Type.String() }),
|
||||
]),
|
||||
querystring: Type.Object({
|
||||
"examples[]": Type.Array(Type.String(), { default: [] }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
(req, reply) => {
|
||||
const locale = getLocale(req.params.locale);
|
||||
if (locale == null) {
|
||||
return replyError(reply, ApiError.INVALID_LOCALE);
|
||||
}
|
||||
|
||||
const key = req.params["*"].split("/").filter(isNotBlank).join("/"); // This is to get rid of any extraneous parts
|
||||
const found = locale.pronoun(key);
|
||||
if (found == null) {
|
||||
return replyError(reply, ApiError.UNKNOWN_PRONOUN);
|
||||
}
|
||||
|
||||
const examples = req.query["examples[]"];
|
||||
|
||||
return transformPronoun(
|
||||
found,
|
||||
examples.length < 1
|
||||
? locale.examples
|
||||
: examples.map(parseExample)
|
||||
);
|
||||
}
|
||||
);
|
||||
} satisfies AppPluginAsync;
|
||||
export default plugin;
|
0
new/backend/src/server/v1/user.ts
Normal file
0
new/backend/src/server/v1/user.ts
Normal file
20
new/backend/tsconfig.json
Normal file
20
new/backend/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "ESNext",
|
||||
"baseUrl": ".",
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"resolvePackageJsonExports": true,
|
||||
"paths": {
|
||||
"#self/*": ["./src/*.js"]
|
||||
},
|
||||
"incremental": true,
|
||||
"strictNullChecks": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["./src/**/*"]
|
||||
}
|
1
new/common/.npmignore
Normal file
1
new/common/.npmignore
Normal file
@ -0,0 +1 @@
|
||||
/tsconfig.json
|
45
new/common/package.json
Normal file
45
new/common/package.json
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@pronounspage/common",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "eslint src/**",
|
||||
"clean": "rimraf dist",
|
||||
"build:esm": "tsc --module es6 --outDir dist/esm",
|
||||
"build:cjs": "tsc --module commonjs --outDir dist/cjs",
|
||||
"build:types": "tsc --emitDeclarationOnly --declaration --outDir dist/types",
|
||||
"build": "run-p build:esm build:cjs build:types",
|
||||
"cleanbuild": "run-s clean build"
|
||||
},
|
||||
"exports": {
|
||||
"./*": {
|
||||
"types": "./dist/types/*.d.ts",
|
||||
"require": "./dist/cjs/*.js",
|
||||
"import": "./dist/esm/*.js"
|
||||
},
|
||||
".": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"require": "./dist/cjs/index.d.ts",
|
||||
"import": "./dist/esm/index.js"
|
||||
}
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"./dist/types/*.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"devDependencies": {
|
||||
"npm-run-all": "^4.1.5",
|
||||
"rimraf": "^5.0.1",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"suml": "^0.2.2"
|
||||
}
|
||||
}
|
1
new/common/src/global.d.ts
vendored
Normal file
1
new/common/src/global.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
declare module "suml";
|
14
new/common/src/suml.ts
Normal file
14
new/common/src/suml.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import * as SumlImpl from "suml";
|
||||
|
||||
declare module "suml";
|
||||
|
||||
console.log(SumlImpl);
|
||||
const instance = new SumlImpl.default();
|
||||
|
||||
export function parse(value: string): unknown {
|
||||
return instance.parse(value);
|
||||
}
|
||||
|
||||
export function dump(value: unknown): string {
|
||||
return instance.dump(value);
|
||||
}
|
104
new/common/src/util.ts
Normal file
104
new/common/src/util.ts
Normal file
@ -0,0 +1,104 @@
|
||||
export function identity<T>(value: T): T {
|
||||
return value;
|
||||
}
|
||||
|
||||
// Generally, don't use this function to define commonly used functions.
|
||||
// Prefer to write a manual implementation.
|
||||
export function not<Args extends Array<unknown>>(
|
||||
f: (...args: Args) => boolean
|
||||
): (...args: Args) => boolean {
|
||||
return (...args: Args) => !f(...args);
|
||||
}
|
||||
export function isNotNull<T>(value: T | null | undefined): value is T {
|
||||
return value != null;
|
||||
}
|
||||
export const isNull = not(isNotNull); // I know I said not to do this, but it's not used yet *however* it irks me to not have completeness.
|
||||
export function isNotBlank(value: string | null | undefined): boolean {
|
||||
if (!isNotNull(value)) {
|
||||
return false;
|
||||
}
|
||||
if (value.length < 1) {
|
||||
return false;
|
||||
}
|
||||
return value.trim().length > 1;
|
||||
}
|
||||
export const isBlank = not(isNotBlank);
|
||||
|
||||
// This is unnecessarily typed but I don't really care, it works doesn't it?
|
||||
export function toLowerCase<S extends string>(value: S): Lowercase<S> {
|
||||
return value.toLowerCase() as Lowercase<S>;
|
||||
}
|
||||
|
||||
export function parseInteger(value: string, radix?: number): number {
|
||||
if (radix != null && (radix < 2 || radix > 36)) {
|
||||
throw new Error(
|
||||
`invalid radix ${radix} - must be between 2 and 36 (inclusive)`
|
||||
);
|
||||
}
|
||||
const parsed = parseInt(value, radix);
|
||||
if (isNaN(parsed)) {
|
||||
throw new Error("parsed value is NaN");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseBool(value: unknown): boolean {
|
||||
switch (typeof value) {
|
||||
case "boolean":
|
||||
return value;
|
||||
case "string":
|
||||
value = value.trim().toLowerCase();
|
||||
if (value === "true" || value === "1" || value === "yes") {
|
||||
return true;
|
||||
}
|
||||
if (value === "false" || value === "0" || value === "no") {
|
||||
return false;
|
||||
}
|
||||
throw new Error(`unknown boolean constant: ${value}`);
|
||||
case "number":
|
||||
return value != 0;
|
||||
default:
|
||||
throw new Error(`Cannot convert value ${value} to a boolean`);
|
||||
}
|
||||
}
|
||||
|
||||
export function capitalizeFirstLetter(value: string) {
|
||||
return value.charAt(0).toUpperCase() + value.substring(1);
|
||||
}
|
||||
|
||||
// TODO: Benchmark this function with different implementations.
|
||||
export function compileTemplate(
|
||||
template: string,
|
||||
values: Record<string, string>
|
||||
): string {
|
||||
let string = "";
|
||||
let key = null;
|
||||
for (let i = 0; i < template.length; i++) {
|
||||
const c = template.charAt(i);
|
||||
if (key === null) {
|
||||
switch (c) {
|
||||
case "{":
|
||||
key = "";
|
||||
continue;
|
||||
default:
|
||||
string += c;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
switch (c) {
|
||||
case "}":
|
||||
string += values[key];
|
||||
key = null;
|
||||
continue;
|
||||
default:
|
||||
key += c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
/*for (const key in values) {
|
||||
const value = values[key];
|
||||
string = string.replace(`{${key}}`, value);
|
||||
}*/
|
||||
return string;
|
||||
}
|
8
new/common/tsconfig.json
Normal file
8
new/common/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["./src/**/*"]
|
||||
}
|
14
new/package.json
Normal file
14
new/package.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier -w ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^6.4.1",
|
||||
"@typescript-eslint/parser": "^6.4.1",
|
||||
"eslint": "^8.48.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"prettier": "^3.0.2",
|
||||
"typescript": "^5.2.2"
|
||||
}
|
||||
}
|
2453
new/pnpm-lock.yaml
generated
Normal file
2453
new/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
new/pnpm-workspace.yaml
Normal file
3
new/pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- "common"
|
||||
- "backend"
|
130
new/setup.mjs
Normal file
130
new/setup.mjs
Normal file
@ -0,0 +1,130 @@
|
||||
import fsp from "node:fs/promises";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import * as child_process from "node:child_process";
|
||||
|
||||
console.log("Setting up project");
|
||||
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
|
||||
const packageManager = argv[0] ?? "pnpm";
|
||||
|
||||
let currentlySettingUp = "";
|
||||
let needsToInstallDependencies = false;
|
||||
|
||||
const url = new URL(import.meta.url);
|
||||
let projectDir = url.pathname
|
||||
.split("/")
|
||||
.filter((v) => v && v.length > 0)
|
||||
.join(path.sep)
|
||||
// This is a cheap but probably check to see if we're *not* on Windows.
|
||||
if (path.sep !== "\\") {
|
||||
// You could inline `path.sep` as "\" but why not go that extra mile
|
||||
// We're already using it so
|
||||
projectDir = path.sep + projectDir;
|
||||
}
|
||||
projectDir = path.dirname(path.resolve(projectDir));
|
||||
|
||||
const legacyDir = path.resolve(projectDir, "..");
|
||||
|
||||
function message(str) {
|
||||
let prefix = "";
|
||||
if (!!currentlySettingUp && currentlySettingUp.trim().length >= 0) {
|
||||
prefix = `[${currentlySettingUp}] `;
|
||||
}
|
||||
console.log(" " + prefix + str);
|
||||
}
|
||||
|
||||
console.log(`
|
||||
Project directory: ${projectDir}
|
||||
Legacy directory: ${legacyDir}
|
||||
`);
|
||||
|
||||
async function symlink(to, from) {
|
||||
if (!fs.existsSync(from)) {
|
||||
message(`Creating symlink from ${from} to ${to}`);
|
||||
await fsp.symlink(to, from, "dir");
|
||||
} else {
|
||||
message(`File ${from} already exists - not symlinking to ${to}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create temporary symlinks
|
||||
// This step may very well be removed in the future.
|
||||
{
|
||||
currentlySettingUp = "locales";
|
||||
|
||||
const to = path.resolve(legacyDir, "locale"),
|
||||
from = path.resolve(projectDir, "locales");
|
||||
await symlink(to, from);
|
||||
}
|
||||
|
||||
{
|
||||
currentlySettingUp = "common";
|
||||
|
||||
const commonDir = path.resolve(projectDir, "common");
|
||||
const commonDistDir = path.resolve(commonDir, "dist");
|
||||
|
||||
if (!fs.existsSync(commonDistDir)) {
|
||||
message("Building common package");
|
||||
child_process.spawnSync(packageManager, ["run", "build"], {
|
||||
cwd: commonDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
currentlySettingUp = "backend";
|
||||
|
||||
const backendDir = path.resolve(projectDir, "backend");
|
||||
const envFile = path.resolve(backendDir, ".env"),
|
||||
exampleEnvFile = path.resolve(backendDir, "example.env");
|
||||
if (!fs.existsSync(envFile)) {
|
||||
message(`Missing .env file for backend, creating from example.env`);
|
||||
await fsp.cp(exampleEnvFile, envFile);
|
||||
} else {
|
||||
message(`Environment file already exists, continuing`);
|
||||
}
|
||||
|
||||
const prismaDistDir = path.resolve(backendDir, "prisma", "dist")
|
||||
if (!fs.existsSync(prismaDistDir)) {
|
||||
message("Generating Prisma client");
|
||||
child_process.spawnSync(packageManager, ["run", "db:generate"], {
|
||||
cwd: backendDir,
|
||||
stdio: "inherit"
|
||||
});
|
||||
} else {
|
||||
message("Prisma client has already been generated and is not broken");
|
||||
}
|
||||
|
||||
// This is a cheap fix for a bug that occurs when the .prisma/client
|
||||
// package has already been linked, but the target (prisma/dist) doesn't exist
|
||||
const prismaClientFile = path.resolve(backendDir, "node_modules", ".prisma", "client");
|
||||
let shouldFixPrismaClient = true;
|
||||
if (fs.existsSync(prismaClientFile)) {
|
||||
shouldFixPrismaClient &&= !(await fsp.stat(prismaClientFile)).isDirectory();
|
||||
}
|
||||
if (shouldFixPrismaClient ) {
|
||||
message("Fixing .prisma/client package")
|
||||
if (fs.existsSync(prismaClientFile)) {
|
||||
await fsp.rm(prismaClientFile);
|
||||
}
|
||||
needsToInstallDependencies = true;
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
currentlySettingUp = "finalise";
|
||||
if (needsToInstallDependencies) {
|
||||
message("Installing dependencies")
|
||||
child_process.spawnSync(packageManager, ["install"], {
|
||||
cwd: projectDir,
|
||||
stdio: "inherit"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone!`);
|
8
new/tsconfig.json
Normal file
8
new/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": true
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user