@studiotitta/studio-editori 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.js +9 -0
  3. package/dist/komponentit/Editori.d.ts +1 -0
  4. package/dist/komponentit/Editori.js +29 -0
  5. package/dist/komponentit/Muokattava.d.ts +10 -0
  6. package/dist/komponentit/Muokattava.js +7 -0
  7. package/dist/komponentit/index.d.ts +2 -0
  8. package/dist/komponentit/index.js +5 -0
  9. package/dist/maarittely.d.ts +215 -0
  10. package/dist/maarittely.js +170 -0
  11. package/dist/palvelin/apurit.d.ts +26 -0
  12. package/dist/palvelin/apurit.js +70 -0
  13. package/dist/palvelin/asetukset.d.ts +26 -0
  14. package/dist/palvelin/asetukset.js +55 -0
  15. package/dist/palvelin/github/avain.d.ts +15 -0
  16. package/dist/palvelin/github/avain.js +121 -0
  17. package/dist/palvelin/github/tiedostot.d.ts +56 -0
  18. package/dist/palvelin/github/tiedostot.js +144 -0
  19. package/dist/palvelin/github/yhteys.d.ts +28 -0
  20. package/dist/palvelin/github/yhteys.js +89 -0
  21. package/dist/palvelin/index.d.ts +15 -0
  22. package/dist/palvelin/index.js +19 -0
  23. package/dist/palvelin/istunto.d.ts +34 -0
  24. package/dist/palvelin/istunto.js +86 -0
  25. package/dist/palvelin/kaannos.d.ts +12 -0
  26. package/dist/palvelin/kaannos.js +102 -0
  27. package/dist/palvelin/kaannosreitti.d.ts +9 -0
  28. package/dist/palvelin/kaannosreitti.js +42 -0
  29. package/dist/palvelin/kirjautuminen.d.ts +25 -0
  30. package/dist/palvelin/kirjautuminen.js +183 -0
  31. package/dist/palvelin/kuvat.d.ts +29 -0
  32. package/dist/palvelin/kuvat.js +130 -0
  33. package/dist/palvelin/kuvateksti.d.ts +15 -0
  34. package/dist/palvelin/kuvateksti.js +95 -0
  35. package/dist/palvelin/lue.d.ts +6 -0
  36. package/dist/palvelin/lue.js +41 -0
  37. package/dist/palvelin/posti.d.ts +9 -0
  38. package/dist/palvelin/posti.js +45 -0
  39. package/dist/palvelin/reitit.d.ts +10 -0
  40. package/dist/palvelin/reitit.js +71 -0
  41. package/dist/palvelin/tallennus.d.ts +18 -0
  42. package/dist/palvelin/tallennus.js +177 -0
  43. package/dist/palvelin/tila.d.ts +21 -0
  44. package/dist/palvelin/tila.js +116 -0
  45. package/dist/palvelin/tunnus.d.ts +30 -0
  46. package/dist/palvelin/tunnus.js +84 -0
  47. package/dist/palvelin/varasto.d.ts +18 -0
  48. package/dist/palvelin/varasto.js +87 -0
  49. package/dist/selain/EditorinPalkki.d.ts +1 -0
  50. package/dist/selain/EditorinPalkki.js +35 -0
  51. package/dist/selain/Muokkaustila.d.ts +3 -0
  52. package/dist/selain/Muokkaustila.js +646 -0
  53. package/dist/selain/PalkkiLataaja.d.ts +1 -0
  54. package/dist/selain/PalkkiLataaja.js +19 -0
  55. package/dist/selain/Paneeli.d.ts +44 -0
  56. package/dist/selain/Paneeli.js +259 -0
  57. package/dist/selain/index.d.ts +4 -0
  58. package/dist/selain/index.js +8 -0
  59. package/dist/selain/kieli.d.ts +10 -0
  60. package/dist/selain/kieli.js +14 -0
  61. package/dist/selain/kuva.d.ts +7 -0
  62. package/dist/selain/kuva.js +45 -0
  63. package/dist/selain/polku.d.ts +21 -0
  64. package/dist/selain/polku.js +108 -0
  65. package/dist/selain/tyypit.d.ts +22 -0
  66. package/dist/selain/tyypit.js +2 -0
  67. package/dist/tarkistus.d.ts +26 -0
  68. package/dist/tarkistus.js +324 -0
  69. package/package.json +59 -0
@@ -0,0 +1,2 @@
1
+ export { aika, kohdelista, kuva, kytkin, luku, kielivalinta, kuvanOsoite, maaritteleSisalto, onSallittuKuvapolku, onSallittuPolku, ryhma, teksti, tekstialue, tekstilista, valinta, type AikaKentta, type Kaannos, type KentanArvo, type KentanPolku, type Kentta, type KielivalintaKentta, type KohdelistaKentta, type KuvaKentta, type KytkinKentta, type LukuKentta, type RyhmaKentta, type TekstilistaKentta, type ValintaKentta, type SisallonMaarittely, type Sivu, type SivunMaarittely, type TallennettuSivu, type TekstiKentta, type TekstialueKentta, } from "./maarittely.ts";
2
+ export { kielista, merkkimaara, tarkistaSivu, type Huomio, type Tarkistustulos, } from "./tarkistus.ts";
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ /*
2
+ Studio Editori — yhteinen osa.
3
+
4
+ Sisältömalli ja sen tarkistus. Ei riippuvuuksia Nextiin, Reactiin tai
5
+ Nodeen, joten tätä voi käyttää sivuston asetustiedostossa, palvelimella
6
+ ja selaimessa.
7
+ */
8
+ export { aika, kohdelista, kuva, kytkin, luku, kielivalinta, kuvanOsoite, maaritteleSisalto, onSallittuKuvapolku, onSallittuPolku, ryhma, teksti, tekstialue, tekstilista, valinta, } from "./maarittely.js";
9
+ export { kielista, merkkimaara, tarkistaSivu, } from "./tarkistus.js";
@@ -0,0 +1 @@
1
+ export declare function Editori(): Promise<import("react").JSX.Element | null>;
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { draftMode } from "next/headers";
3
+ /* ==========================================================================
4
+ EDITORIN KYTKIN
5
+ ==========================================================================
6
+ Sivusto lisää tämän juurilayoutiin kerran:
7
+
8
+ <Editori />
9
+
10
+ Tavallinen kävijä ei saa siitä riviäkään koodia: komponentti palauttaa
11
+ tyhjää, ellei Draft Mode ole päällä, ja Draft Mode kytkeytyy vasta
12
+ editorin kirjautumisessa. Palkki tarkistaa lisäksi istunnon palvelimelta
13
+ ja piilottaa itsensä, jos sitä ei ole — Draft Mode yksin ei ole oikeus
14
+ muokata mitään.
15
+ ========================================================================== */
16
+ export async function Editori() {
17
+ const { isEnabled: muokkaustila } = await draftMode();
18
+ if (!muokkaustila)
19
+ return null;
20
+ /*
21
+ Tuonti tehdään vasta tässä, ei tiedoston alussa. Muuten editorin
22
+ selainkoodi päätyisi samaan nippuun sivuston oman koodin kanssa, ja
23
+ tavallinen kävijä lataisi sen turhaan — vaikka komponenttia ei koskaan
24
+ näytetä hänelle. Tarkistettavissa: `npm run build` ja etusivun
25
+ js-tiedostoista ei löydy editorin tekstejä.
26
+ */
27
+ const { PalkkiLataaja } = await import("../selain/PalkkiLataaja.js");
28
+ return _jsx(PalkkiLataaja, {});
29
+ }
@@ -0,0 +1,10 @@
1
+ import type { ComponentPropsWithoutRef, ElementType, ReactNode } from "react";
2
+ type MuokattavaProps<T extends ElementType> = {
3
+ /** Kentän polku sisältömallissa: "sivu.kentta". */
4
+ kentta: string;
5
+ /** HTML-elementti, oletuksena span. */
6
+ as?: T;
7
+ children?: ReactNode;
8
+ } & Omit<ComponentPropsWithoutRef<T>, "as" | "children">;
9
+ export declare function Muokattava<T extends ElementType = "span">({ kentta, as, children, ...muut }: MuokattavaProps<T>): Promise<import("react").JSX.Element>;
10
+ export {};
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { draftMode } from "next/headers";
3
+ export async function Muokattava({ kentta, as, children, ...muut }) {
4
+ const Elementti = as ?? "span";
5
+ const { isEnabled: muokkaustila } = await draftMode();
6
+ return (_jsx(Elementti, { ...muut, ...(muokkaustila ? { "data-editori-kentta": kentta } : {}), children: children }));
7
+ }
@@ -0,0 +1,2 @@
1
+ export { Muokattava } from "./Muokattava.tsx";
2
+ export { Editori } from "./Editori.tsx";
@@ -0,0 +1,5 @@
1
+ /*
2
+ Studio Editori — sivuston komponentit (React Server Components).
3
+ */
4
+ export { Muokattava } from "./Muokattava.js";
5
+ export { Editori } from "./Editori.js";
@@ -0,0 +1,215 @@
1
+ interface KentanPerus {
2
+ /** Nimi, jonka muokkaaja näkee editorissa. Oletuksena kentän avain. */
3
+ nimi?: string;
4
+ /** Lyhyt ohje muokkaajalle, näkyy kentän alla. */
5
+ ohje?: string;
6
+ /** Tyhjää arvoa ei hyväksytä tallennettaessa. */
7
+ pakollinen?: boolean;
8
+ /**
9
+ * true, jos kenttä asetetaan kerran eikä siihen palata viikoittain
10
+ * (alkamisaika, ryhmäkoko, kuva). Editori siirtää sen "Lisätiedot"-
11
+ * taitoksen alle, jotta arkinen työ pysyy yhdessä silmäyksessä.
12
+ */
13
+ lisatieto?: boolean;
14
+ }
15
+ interface TekstinPerus<K extends boolean> extends KentanPerus {
16
+ /** Kenttä tallennetaan jokaisella sivuston kielellä: { "fi": "…", "en": "…" }. */
17
+ kaannettava: K;
18
+ /**
19
+ * Suunniteltu enimmäispituus merkkeinä. Ylitys tuottaa varoituksen,
20
+ * ei estä tallennusta: designin rajat ovat ohjeita, eivät lukkoja.
21
+ */
22
+ suositusPituus?: number;
23
+ }
24
+ /** Lyhyt yksirivinen teksti: otsikot, iskulauseet, painikkeet. */
25
+ export interface TekstiKentta<K extends boolean = boolean> extends TekstinPerus<K> {
26
+ tyyppi: "teksti";
27
+ }
28
+ /** Kappaleet ilman muotoilua. Rivinvaihdot säilyvät. */
29
+ export interface TekstialueKentta<K extends boolean = boolean> extends TekstinPerus<K> {
30
+ tyyppi: "tekstialue";
31
+ }
32
+ /** Kokonaisluku, esimerkiksi ryhmäkoko tai varatut paikat. */
33
+ export interface LukuKentta extends KentanPerus {
34
+ tyyppi: "luku";
35
+ pienin?: number;
36
+ suurin?: number;
37
+ /** Mihin toiseen kenttään tätä verrataan: arvo ei saa ylittää sitä. */
38
+ enintaanKuin?: string;
39
+ }
40
+ /** Valinta muutamasta vaihtoehdosta, esimerkiksi kurssin tila. */
41
+ export interface ValintaKentta extends KentanPerus {
42
+ tyyppi: "valinta";
43
+ /**
44
+ * `merkitse: true` nostaa arvon näkyviin kohdelistaan. Sitä käytetään
45
+ * tiloille, jotka muokkaajan on syytä nähdä yhdellä silmäyksellä —
46
+ * esimerkiksi "loppunut", ei "auki".
47
+ */
48
+ vaihtoehdot: readonly {
49
+ arvo: string;
50
+ nimi: string;
51
+ selite?: string;
52
+ merkitse?: boolean;
53
+ }[];
54
+ }
55
+ /**
56
+ * Kyllä/ei, esimerkiksi onko kurssi piilotettu.
57
+ *
58
+ * Teksti on kiinteä ja kertoo, mitä rastin laittaminen tekee — ei sitä,
59
+ * mikä tila nyt on. Vaihtuva teksti rastin vieressä on juuri niin
60
+ * hämmentävä kuin miltä kuulostaa: "Piilotettu" ilman rastia näyttää
61
+ * siltä, että kurssi olisi piilotettu.
62
+ */
63
+ export interface KytkinKentta extends KentanPerus {
64
+ tyyppi: "kytkin";
65
+ /** Teksti rastin vieressä, esim. "Piilota kurssi sivustolta". */
66
+ teksti?: string;
67
+ /** true, jos rastittaminen piilottaa kohteen. Listassa näkyy merkintä. */
68
+ piilottaa?: boolean;
69
+ }
70
+ /** Päivä ja kellonaika ISO-muodossa. */
71
+ export interface AikaKentta extends KentanPerus {
72
+ tyyppi: "aika";
73
+ /** Näytetäänkö kellonaika vai pelkkä päivä. */
74
+ kellonaika?: boolean;
75
+ }
76
+ /**
77
+ * Millä kielillä kohde näkyy.
78
+ *
79
+ * Monikielisellä sivustolla kaikki ei kuulu kaikille: englanninkielinen
80
+ * kurssi voi olla tarjolla vain englanninkielisillä sivuilla. Arvo on lista
81
+ * kielikoodeja; tyhjä lista tarkoittaa, ettei kohde näy millään kielellä.
82
+ *
83
+ * Yksikielisellä sivustolla kenttää ei näytetä editorissa lainkaan.
84
+ */
85
+ export interface KielivalintaKentta extends KentanPerus {
86
+ tyyppi: "kielivalinta";
87
+ }
88
+ /** Kuva: tiedosto sekä sen vaihtoehtoinen teksti. */
89
+ export interface KuvaKentta extends KentanPerus {
90
+ tyyppi: "kuva";
91
+ /** Kuvasuhde, esim. 3 / 2. Kuva rajataan tähän. */
92
+ suhde?: number;
93
+ /** Kansio sivuston mediakirjastossa, esim. "kurssit". */
94
+ kansio?: string;
95
+ /** Vaaditaanko vaihtoehtoinen teksti. Oletuksena kyllä. */
96
+ pakollinenAlt?: boolean;
97
+ }
98
+ /** Lista tekstejä, esimerkiksi kappaleet tai "mitä otat mukaan". */
99
+ export interface TekstilistaKentta<K extends boolean = boolean> extends KentanPerus {
100
+ tyyppi: "tekstilista";
101
+ kaannettava: K;
102
+ /** Monirivinen kohta listassa. */
103
+ monirivinen?: boolean;
104
+ enintaan?: number;
105
+ }
106
+ /**
107
+ * Kenttäryhmä, esimerkiksi hakukonetiedot.
108
+ *
109
+ * Tallennetussa tiedostossa ryhmä on olio: `meta: { title, description }`.
110
+ * Editorissa se näkyy omana lohkonaan, jotta sivun kentät eivät sekoitu
111
+ * hakukoneteksteihin.
112
+ */
113
+ export interface RyhmaKentta extends KentanPerus {
114
+ tyyppi: "ryhma";
115
+ kentat: Record<string, Kentta>;
116
+ }
117
+ /**
118
+ * Lista samanlaisia kohteita, esimerkiksi kurssit.
119
+ *
120
+ * Jokaisella kohteella on omat kenttänsä, ja kohteita voi lisätä, poistaa ja
121
+ * järjestää. `tunnus` kertoo, mikä kenttä yksilöi kohteen (esimerkiksi kurssin
122
+ * osoitetunnus), jotta muutokset osataan yhdistää oikeaan kohteeseen.
123
+ */
124
+ export interface KohdelistaKentta extends KentanPerus {
125
+ tyyppi: "kohdelista";
126
+ tunnus: string;
127
+ /** Mikä kenttä näytetään listassa kohteen nimenä. */
128
+ otsikko: string;
129
+ /**
130
+ * Yksi kohde omalla nimellään, esimerkiksi "kurssi".
131
+ *
132
+ * Editori käyttää sanaa painikkeissa ja ohjeissa: "Lisää kurssi",
133
+ * "Poista kurssi", "Valitse kurssi, niin näet sen tiedot." Ilman tätä
134
+ * puhuttaisiin kohteista, mikä ei ole kenenkään sana omasta työstään.
135
+ */
136
+ yksikko?: string;
137
+ kentat: Record<string, Kentta>;
138
+ }
139
+ export type Kentta = TekstiKentta | TekstialueKentta | LukuKentta | ValintaKentta | KytkinKentta | AikaKentta | KuvaKentta | KielivalintaKentta | TekstilistaKentta | RyhmaKentta | KohdelistaKentta;
140
+ type TekstinAsetukset<K extends boolean> = Omit<TekstinPerus<K>, "kaannettava"> & {
141
+ kaannettava?: K;
142
+ };
143
+ export declare function teksti<K extends boolean = false>(asetukset?: TekstinAsetukset<K>): TekstiKentta<K>;
144
+ export declare function tekstialue<K extends boolean = false>(asetukset?: TekstinAsetukset<K>): TekstialueKentta<K>;
145
+ export declare function luku(asetukset?: Omit<LukuKentta, "tyyppi">): LukuKentta;
146
+ export declare function valinta(asetukset: Omit<ValintaKentta, "tyyppi">): ValintaKentta;
147
+ export declare function kytkin(asetukset?: Omit<KytkinKentta, "tyyppi">): KytkinKentta;
148
+ export declare function aika(asetukset?: Omit<AikaKentta, "tyyppi">): AikaKentta;
149
+ export declare function kuva(asetukset?: Omit<KuvaKentta, "tyyppi">): KuvaKentta;
150
+ export declare function kielivalinta(asetukset?: Omit<KielivalintaKentta, "tyyppi">): KielivalintaKentta;
151
+ export declare function tekstilista<K extends boolean = false>(asetukset?: Omit<TekstilistaKentta<K>, "tyyppi" | "kaannettava"> & {
152
+ kaannettava?: K;
153
+ }): TekstilistaKentta<K>;
154
+ export declare function ryhma(asetukset: Omit<RyhmaKentta, "tyyppi">): RyhmaKentta;
155
+ export declare function kohdelista(asetukset: Omit<KohdelistaKentta, "tyyppi">): KohdelistaKentta;
156
+ export interface SivunMaarittely {
157
+ /** Nimi, jonka muokkaaja näkee editorissa. Oletuksena sivun avain. */
158
+ nimi?: string;
159
+ /** Sisältötiedosto sivuston repossa, esim. "sisalto/etusivu.json". */
160
+ tiedosto: string;
161
+ kentat: Record<string, Kentta>;
162
+ }
163
+ export interface SisallonMaarittely<L extends string = string, S extends Record<string, SivunMaarittely> = Record<string, SivunMaarittely>> {
164
+ /** Sivuston kielet. Ensimmäinen on oletuskieli. */
165
+ kielet: readonly [L, ...L[]];
166
+ sivut: S;
167
+ }
168
+ export declare function onSallittuPolku(polku: string): boolean;
169
+ export declare function onSallittuKuvapolku(polku: string): boolean;
170
+ /** Kuvan julkinen osoite repopolusta: public/kuvat/x.webp → /kuvat/x.webp */
171
+ export declare function kuvanOsoite(polku: string): string;
172
+ /**
173
+ * Tarkistaa sivuston sisältömallin ja palauttaa sen sellaisenaan. Virhe
174
+ * kaataa buildin heti, jotta rikkinäinen malli ei koskaan päädy
175
+ * tuotantoon asti.
176
+ */
177
+ export declare function maaritteleSisalto<const L extends string, const S extends Record<string, SivunMaarittely>>(maarittely: SisallonMaarittely<L, S>): SisallonMaarittely<L, S>;
178
+ /** Käännettävän kentän tallennettu muoto. Puuttuva kieli = kääntämättä. */
179
+ export type Kaannos<L extends string> = Partial<Record<L, string>>;
180
+ /** Kentän arvo valmiiksi yhdellä kielellä, niin kuin komponentti sen saa. */
181
+ export type KentanArvo<K extends Kentta> = K extends {
182
+ tyyppi: "luku";
183
+ } ? number | undefined : K extends {
184
+ tyyppi: "kytkin";
185
+ } ? boolean : K extends {
186
+ tyyppi: "kuva";
187
+ } ? {
188
+ src: string;
189
+ alt: string;
190
+ } | undefined : K extends {
191
+ tyyppi: "tekstilista" | "kielivalinta";
192
+ } ? string[] : K extends {
193
+ tyyppi: "kohdelista";
194
+ } ? Record<string, unknown>[] : K extends {
195
+ tyyppi: "ryhma";
196
+ } ? Record<string, unknown> : string;
197
+ /** Kentän arvo sellaisena kuin se on tiedostossa. */
198
+ type TallennettuArvo<K extends Kentta, L extends string> = K extends {
199
+ kaannettava: true;
200
+ } ? K extends {
201
+ tyyppi: "tekstilista";
202
+ } ? Partial<Record<L, string[]>> : Kaannos<L> : KentanArvo<K>;
203
+ /** Sivun sisältö sellaisena kuin se on JSON-tiedostossa. */
204
+ export type TallennettuSivu<S extends SivunMaarittely, L extends string> = {
205
+ [A in keyof S["kentat"]]?: TallennettuArvo<S["kentat"][A], L>;
206
+ };
207
+ /** Sivun sisältö yhdellä kielellä, valmiina komponentin käyttöön. */
208
+ export type Sivu<S extends SivunMaarittely> = {
209
+ [A in keyof S["kentat"]]: KentanArvo<S["kentat"][A]>;
210
+ };
211
+ /** Kentän polku editorissa ja sivun merkinnöissä: "sivu.kentta". */
212
+ export type KentanPolku<M extends SisallonMaarittely> = {
213
+ [S in keyof M["sivut"] & string]: `${S}.${keyof M["sivut"][S]["kentat"] & string}`;
214
+ }[keyof M["sivut"] & string];
215
+ export {};
@@ -0,0 +1,170 @@
1
+ /* ==========================================================================
2
+ SISÄLTÖMALLI
3
+ ==========================================================================
4
+ Sivusto kuvaa kerran, mitä sen sivuilla saa muokata. Kaikki muu johdetaan
5
+ tästä kuvauksesta: editorin kentät, tallennuksen tarkistus ja tyypit, joilla
6
+ sivuston komponentit lukevat sisältönsä.
7
+
8
+ export default maaritteleSisalto({
9
+ kielet: ["fi", "en"],
10
+ sivut: {
11
+ etusivu: {
12
+ tiedosto: "sisalto/etusivu.json",
13
+ kentat: {
14
+ avauskysymys: teksti({ kaannettava: true, suositusPituus: 90 }),
15
+ },
16
+ },
17
+ },
18
+ });
19
+
20
+ Tiedosto on tarkoituksella riippumaton Nextistä, Reactista ja Nodesta:
21
+ samaa kuvausta käyttävät palvelin, selaimen editori ja testit.
22
+ ========================================================================== */
23
+ export function teksti(asetukset = {}) {
24
+ return { ...asetukset, tyyppi: "teksti", kaannettava: (asetukset.kaannettava ?? false) };
25
+ }
26
+ export function tekstialue(asetukset = {}) {
27
+ return { ...asetukset, tyyppi: "tekstialue", kaannettava: (asetukset.kaannettava ?? false) };
28
+ }
29
+ export function luku(asetukset = {}) {
30
+ return { ...asetukset, tyyppi: "luku" };
31
+ }
32
+ export function valinta(asetukset) {
33
+ return { ...asetukset, tyyppi: "valinta" };
34
+ }
35
+ export function kytkin(asetukset = {}) {
36
+ return { ...asetukset, tyyppi: "kytkin" };
37
+ }
38
+ export function aika(asetukset = {}) {
39
+ return { ...asetukset, tyyppi: "aika" };
40
+ }
41
+ export function kuva(asetukset = {}) {
42
+ return { ...asetukset, tyyppi: "kuva" };
43
+ }
44
+ export function kielivalinta(asetukset = {}) {
45
+ return { ...asetukset, tyyppi: "kielivalinta" };
46
+ }
47
+ export function tekstilista(asetukset = {}) {
48
+ return {
49
+ ...asetukset,
50
+ tyyppi: "tekstilista",
51
+ kaannettava: (asetukset.kaannettava ?? false),
52
+ };
53
+ }
54
+ export function ryhma(asetukset) {
55
+ return { ...asetukset, tyyppi: "ryhma" };
56
+ }
57
+ export function kohdelista(asetukset) {
58
+ return { ...asetukset, tyyppi: "kohdelista" };
59
+ }
60
+ /**
61
+ * Sallitut sisältötiedostot. Editori kirjoittaa sivuston repoon vain näihin
62
+ * polkuihin, joten sääntö on tarkoituksella tiukka: ei hakemistosta ulos
63
+ * pääsyä, ei piilotiedostoja, ei muuta kuin JSONia sisalto-kansiossa.
64
+ */
65
+ const SALLITTU_POLKU = /^sisalto\/(?:[a-z0-9][a-z0-9-]*\/)*[a-z0-9][a-z0-9-]*\.json$/;
66
+ const SALLITTU_KIELI = /^[a-z]{2}$/;
67
+ const SALLITTU_AVAIN = /^[a-zA-Z][a-zA-Z0-9]*$/;
68
+ export function onSallittuPolku(polku) {
69
+ return SALLITTU_POLKU.test(polku);
70
+ }
71
+ /**
72
+ * Sallitut kuvatiedostot sivuston repossa.
73
+ *
74
+ * Kuvat ovat julkisessa kansiossa, koska sivusto tarjoilee ne sellaisenaan.
75
+ * Kansion nimen saa valita sivustokohtaisesti (`public/kuvat`,
76
+ * `public/media`), mutta sääntö on yhtä tiukka kuin sisällöllä: ei
77
+ * hakemistosta ulos, ei isoja kirjaimia, vain webp.
78
+ */
79
+ const SALLITTU_KUVAPOLKU = /^public\/[a-z0-9][a-z0-9-]*\/(?:[a-z0-9][a-z0-9-]*\/)*[a-z0-9][a-z0-9-]*\.webp$/;
80
+ export function onSallittuKuvapolku(polku) {
81
+ return SALLITTU_KUVAPOLKU.test(polku);
82
+ }
83
+ /** Kuvan julkinen osoite repopolusta: public/kuvat/x.webp → /kuvat/x.webp */
84
+ export function kuvanOsoite(polku) {
85
+ return polku.replace(/^public/, "");
86
+ }
87
+ /**
88
+ * Tarkistaa sivuston sisältömallin ja palauttaa sen sellaisenaan. Virhe
89
+ * kaataa buildin heti, jotta rikkinäinen malli ei koskaan päädy
90
+ * tuotantoon asti.
91
+ */
92
+ export function maaritteleSisalto(maarittely) {
93
+ const virheet = [];
94
+ const { kielet, sivut } = maarittely;
95
+ if (kielet.length === 0)
96
+ virheet.push("Kieliä on oltava vähintään yksi.");
97
+ for (const kieli of kielet) {
98
+ if (!SALLITTU_KIELI.test(kieli)) {
99
+ virheet.push(`Kielikoodi "${kieli}" ei kelpaa. Käytä kahta pientä kirjainta, esim. "fi".`);
100
+ }
101
+ }
102
+ if (new Set(kielet).size !== kielet.length)
103
+ virheet.push("Sama kieli on listassa kahdesti.");
104
+ const tiedostot = new Map();
105
+ for (const [avain, sivu] of Object.entries(sivut)) {
106
+ if (!SALLITTU_AVAIN.test(avain))
107
+ virheet.push(`Sivun avain "${avain}" ei kelpaa.`);
108
+ if (!onSallittuPolku(sivu.tiedosto)) {
109
+ virheet.push(`Sivun "${avain}" tiedosto "${sivu.tiedosto}" ei kelpaa. ` +
110
+ `Sallittu muoto on sisalto/nimi.json (pienet kirjaimet, numerot ja väliviivat).`);
111
+ }
112
+ const aiempi = tiedostot.get(sivu.tiedosto);
113
+ if (aiempi)
114
+ virheet.push(`Sivut "${aiempi}" ja "${avain}" käyttävät samaa tiedostoa.`);
115
+ tiedostot.set(sivu.tiedosto, avain);
116
+ tarkistaKentat(sivu.kentat, avain, virheet);
117
+ }
118
+ if (virheet.length > 0) {
119
+ throw new Error("Sisältömallissa on virheitä:\n- " + virheet.join("\n- "));
120
+ }
121
+ return maarittely;
122
+ }
123
+ /**
124
+ * Tarkistaa yhden kenttäjoukon. Kutsuu itseään kohdelistan sisällä, jotta
125
+ * myös listan kohteiden kentät tulevat tarkistetuiksi.
126
+ */
127
+ function tarkistaKentat(kentat, polku, virheet) {
128
+ for (const [avain, maaritys] of Object.entries(kentat)) {
129
+ const kohde = `${polku}.${avain}`;
130
+ if (!SALLITTU_AVAIN.test(avain))
131
+ virheet.push(`Kentän avain "${kohde}" ei kelpaa.`);
132
+ if ("suositusPituus" in maaritys) {
133
+ const pituus = maaritys.suositusPituus;
134
+ if (pituus !== undefined && !(Number.isInteger(pituus) && pituus > 0)) {
135
+ virheet.push(`Kentän "${kohde}" suositusPituus on oltava positiivinen kokonaisluku.`);
136
+ }
137
+ }
138
+ if (maaritys.tyyppi === "valinta") {
139
+ if (maaritys.vaihtoehdot.length === 0) {
140
+ virheet.push(`Valintakentällä "${kohde}" ei ole yhtään vaihtoehtoa.`);
141
+ }
142
+ const arvot = new Set(maaritys.vaihtoehdot.map((vaihtoehto) => vaihtoehto.arvo));
143
+ if (arvot.size !== maaritys.vaihtoehdot.length) {
144
+ virheet.push(`Valintakentässä "${kohde}" on sama arvo kahdesti.`);
145
+ }
146
+ }
147
+ if (maaritys.tyyppi === "luku") {
148
+ const { pienin, suurin } = maaritys;
149
+ if (pienin !== undefined && suurin !== undefined && pienin > suurin) {
150
+ virheet.push(`Kentän "${kohde}" pienin arvo on suurempi kuin suurin.`);
151
+ }
152
+ }
153
+ if (maaritys.tyyppi === "ryhma") {
154
+ tarkistaKentat(maaritys.kentat, kohde, virheet);
155
+ }
156
+ if (maaritys.tyyppi === "kohdelista") {
157
+ if (!Object.hasOwn(maaritys.kentat, maaritys.tunnus)) {
158
+ virheet.push(`Kohdelistan "${kohde}" tunnuskenttää "${maaritys.tunnus}" ei ole sen kentissä.`);
159
+ }
160
+ if (!Object.hasOwn(maaritys.kentat, maaritys.otsikko)) {
161
+ virheet.push(`Kohdelistan "${kohde}" otsikkokenttää "${maaritys.otsikko}" ei ole sen kentissä.`);
162
+ }
163
+ const tunnus = maaritys.kentat[maaritys.tunnus];
164
+ if (tunnus && tunnus.tyyppi !== "teksti") {
165
+ virheet.push(`Kohdelistan "${kohde}" tunnuksen pitää olla tekstikenttä.`);
166
+ }
167
+ tarkistaKentat(maaritys.kentat, kohde, virheet);
168
+ }
169
+ }
170
+ }
@@ -0,0 +1,26 @@
1
+ import { type EditorinAsetukset } from "./asetukset.ts";
2
+ /** Sivu, jolla kirjautuminen ja muokkaustila näytetään. */
3
+ export declare const EDITORIN_SIVU = "/editori";
4
+ export declare function onSuojattu(pyynto: Request): boolean;
5
+ export declare function lueEvaste(pyynto: Request, nimi: string): string | undefined;
6
+ /** Ohjaa editorin sivulle ja kertoo tilan osoiterivillä. */
7
+ export declare function ohjaa(pyynto: Request, tila: string, evasteet?: string[]): Response;
8
+ /** JSON-vastaus. Editorin rajapinta puhuu selaimelle JSONia, ei HTML:ää. */
9
+ export declare function json(data: unknown, tila?: number): Response;
10
+ /**
11
+ * Kirjautunut muokkaaja pyynnöstä, tai `null`.
12
+ *
13
+ * Tämä lukee evästeen suoraan pyynnöstä eikä `next/headers`-rajapinnasta,
14
+ * jotta reittejä voi testata tavallisilla Request-olioilla.
15
+ */
16
+ export declare function muokkaajaPyynnosta(pyynto: Request, asetukset: EditorinAsetukset): Promise<string | null>;
17
+ /**
18
+ * Lukee asetukset niin, ettei puuttuva ympäristömuuttuja kaada pyyntöä
19
+ * virhesivuun.
20
+ *
21
+ * Tämä on käyttöönoton yleisin virhe: avain on jäänyt lisäämättä Verceliin,
22
+ * arvo on kopioitu väärään kenttään tai siihen on tarttunut lainausmerkit.
23
+ * Muokkaaja saa ymmärrettävän viestin, ja tarkka syy menee palvelimen
24
+ * lokiin — ei koskaan selaimeen.
25
+ */
26
+ export declare function asetuksetTaiNull(yliajot: Partial<EditorinAsetukset>): EditorinAsetukset | null;
@@ -0,0 +1,70 @@
1
+ /* Pyyntöjen pikkuapurit, joita sekä kirjautuminen että tallennus käyttävät. */
2
+ import { lueAsetukset } from "./asetukset.js";
3
+ import { EVASTE_ISTUNTO } from "./istunto.js";
4
+ import { lueTunnus } from "./tunnus.js";
5
+ /** Sivu, jolla kirjautuminen ja muokkaustila näytetään. */
6
+ export const EDITORIN_SIVU = "/editori";
7
+ export function onSuojattu(pyynto) {
8
+ return new URL(pyynto.url).protocol === "https:";
9
+ }
10
+ export function lueEvaste(pyynto, nimi) {
11
+ const otsake = pyynto.headers.get("cookie");
12
+ if (!otsake)
13
+ return undefined;
14
+ for (const pala of otsake.split(";")) {
15
+ const [avain, ...arvo] = pala.trim().split("=");
16
+ if (avain === nimi)
17
+ return arvo.join("=");
18
+ }
19
+ return undefined;
20
+ }
21
+ /** Ohjaa editorin sivulle ja kertoo tilan osoiterivillä. */
22
+ export function ohjaa(pyynto, tila, evasteet = []) {
23
+ const kohde = new URL(EDITORIN_SIVU, pyynto.url);
24
+ kohde.searchParams.set("tila", tila);
25
+ const otsakkeet = new Headers({ Location: kohde.toString() });
26
+ for (const arvo of evasteet)
27
+ otsakkeet.append("Set-Cookie", arvo);
28
+ /* 303 vaihtaa POSTin GETiksi, joten sivun päivitys ei lähetä lomaketta uudelleen. */
29
+ return new Response(null, { status: 303, headers: otsakkeet });
30
+ }
31
+ /** JSON-vastaus. Editorin rajapinta puhuu selaimelle JSONia, ei HTML:ää. */
32
+ export function json(data, tila = 200) {
33
+ return new Response(JSON.stringify(data), {
34
+ status: tila,
35
+ headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" },
36
+ });
37
+ }
38
+ /**
39
+ * Kirjautunut muokkaaja pyynnöstä, tai `null`.
40
+ *
41
+ * Tämä lukee evästeen suoraan pyynnöstä eikä `next/headers`-rajapinnasta,
42
+ * jotta reittejä voi testata tavallisilla Request-olioilla.
43
+ */
44
+ export async function muokkaajaPyynnosta(pyynto, asetukset) {
45
+ const tunnus = lueEvaste(pyynto, EVASTE_ISTUNTO);
46
+ if (!tunnus)
47
+ return null;
48
+ const sisalto = await lueTunnus(tunnus, asetukset.avain, "istunto");
49
+ if (!sisalto)
50
+ return null;
51
+ return asetukset.sallitut.includes(sisalto.sahkoposti) ? sisalto.sahkoposti : null;
52
+ }
53
+ /**
54
+ * Lukee asetukset niin, ettei puuttuva ympäristömuuttuja kaada pyyntöä
55
+ * virhesivuun.
56
+ *
57
+ * Tämä on käyttöönoton yleisin virhe: avain on jäänyt lisäämättä Verceliin,
58
+ * arvo on kopioitu väärään kenttään tai siihen on tarttunut lainausmerkit.
59
+ * Muokkaaja saa ymmärrettävän viestin, ja tarkka syy menee palvelimen
60
+ * lokiin — ei koskaan selaimeen.
61
+ */
62
+ export function asetuksetTaiNull(yliajot) {
63
+ try {
64
+ return lueAsetukset(yliajot);
65
+ }
66
+ catch (virhe) {
67
+ console.error("[editori] Asetukset puuttuvat:", virhe instanceof Error ? virhe.message : virhe);
68
+ return null;
69
+ }
70
+ }
@@ -0,0 +1,26 @@
1
+ export interface EditorinAsetukset {
2
+ /** Salainen avain istuntojen ja kirjautumislinkkien allekirjoitukseen. */
3
+ avain: string;
4
+ /** Sähköpostiosoitteet, jotka saavat muokata sivustoa. */
5
+ sallitut: string[];
6
+ /** Lähettäjä, esim. `Studio Titta <editori@studiotitta.fi>`. */
7
+ lahettaja: string;
8
+ /** Resendin API-avain. Ilman tätä viestiä ei lähetetä. */
9
+ postiAvain?: string;
10
+ /** Kirjautumislinkin voimassaolo minuutteina. */
11
+ linkinKestoMinuuttia: number;
12
+ /** Istunnon kesto päivinä. */
13
+ istunnonKestoPaivaa: number;
14
+ /**
15
+ * Sidotaanko kirjautumislinkki siihen selaimeen, josta se pyydettiin.
16
+ * Oletuksena kyllä: sähköpostistaan kalasteltu linkki ei silloin kelpaa
17
+ * muualla. Jos muokkaaja avaa postinsa eri laitteella kuin missä pyysi
18
+ * linkin, hän saa selkeän ohjeen pyytää uusi linkki samassa selaimessa.
19
+ */
20
+ selainsidos: boolean;
21
+ }
22
+ /** Sähköpostiosoite vertailumuodossa. Ei validointi, vaan normalisointi. */
23
+ export declare function normalisoiOsoite(osoite: string): string;
24
+ export declare function lueAsetukset(yliajot?: Partial<EditorinAsetukset>): EditorinAsetukset;
25
+ /** Saako tämä osoite muokata sivustoa? */
26
+ export declare function onSallittuMuokkaaja(osoite: string, asetukset: EditorinAsetukset): boolean;
@@ -0,0 +1,55 @@
1
+ /* ==========================================================================
2
+ EDITORIN ASETUKSET
3
+ ==========================================================================
4
+ Kaikki salaisuudet luetaan ympäristömuuttujista, ei koskaan koodista.
5
+ Sivusto voi antaa arvot myös suoraan, jos se haluaa lukea ne muualta.
6
+
7
+ EDITORI_AVAIN satunnainen salainen avain, allekirjoittaa istunnot
8
+ EDITORI_SALLITUT sähköpostiosoitteet, jotka saavat muokata, pilkuilla
9
+ EDITORI_LAHETTAJA lähettäjä kirjautumisviesteissä
10
+ RESEND_API_KEY sähköpostin lähetys (ilman tätä linkki tulostuu lokiin)
11
+
12
+ Puuttuva tai liian lyhyt avain on virhe heti pyynnön alussa. Editori ei
13
+ saa koskaan toimia heikolla avaimella "väliaikaisesti".
14
+ ========================================================================== */
15
+ const OLETUKSET = {
16
+ linkinKestoMinuuttia: 15,
17
+ istunnonKestoPaivaa: 30,
18
+ selainsidos: true,
19
+ };
20
+ /** Sähköpostiosoite vertailumuodossa. Ei validointi, vaan normalisointi. */
21
+ export function normalisoiOsoite(osoite) {
22
+ return osoite.trim().toLowerCase();
23
+ }
24
+ export function lueAsetukset(yliajot = {}) {
25
+ const avain = yliajot.avain ?? process.env.EDITORI_AVAIN ?? "";
26
+ const sallitut = yliajot.sallitut ??
27
+ (process.env.EDITORI_SALLITUT ?? "")
28
+ .split(",")
29
+ .map(normalisoiOsoite)
30
+ .filter((osoite) => osoite !== "");
31
+ const lahettaja = yliajot.lahettaja ?? process.env.EDITORI_LAHETTAJA ?? "";
32
+ if (avain.length < 32) {
33
+ throw new Error("Editorin asetus EDITORI_AVAIN puuttuu tai on liian lyhyt (vähintään 32 merkkiä). " +
34
+ "Luo uusi arvo komennolla: openssl rand -base64 32");
35
+ }
36
+ if (sallitut.length === 0) {
37
+ throw new Error("Editorin asetus EDITORI_SALLITUT puuttuu. Anna sähköpostiosoitteet pilkuilla eroteltuna.");
38
+ }
39
+ if (lahettaja === "") {
40
+ throw new Error('Editorin asetus EDITORI_LAHETTAJA puuttuu, esim. "Studio Titta <editori@studiotitta.fi>".');
41
+ }
42
+ return {
43
+ avain,
44
+ sallitut,
45
+ lahettaja,
46
+ postiAvain: yliajot.postiAvain ?? process.env.RESEND_API_KEY,
47
+ linkinKestoMinuuttia: yliajot.linkinKestoMinuuttia ?? OLETUKSET.linkinKestoMinuuttia,
48
+ istunnonKestoPaivaa: yliajot.istunnonKestoPaivaa ?? OLETUKSET.istunnonKestoPaivaa,
49
+ selainsidos: yliajot.selainsidos ?? OLETUKSET.selainsidos,
50
+ };
51
+ }
52
+ /** Saako tämä osoite muokata sivustoa? */
53
+ export function onSallittuMuokkaaja(osoite, asetukset) {
54
+ return asetukset.sallitut.includes(normalisoiOsoite(osoite));
55
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Lukee avaimen ympäristömuuttujan arvosta.
3
+ *
4
+ * Hyväksyy kolme muotoa, koska kaikkia näkee käytännössä:
5
+ * - PEM sellaisenaan (rivinvaihdot tallessa)
6
+ * - PEM, jossa rivinvaihdot on kirjoitettu merkkeinä \n
7
+ * - koko PEM base64-koodattuna yhdelle riville
8
+ */
9
+ export declare function lueYksityinenAvain(arvo: string): Promise<CryptoKey>;
10
+ /**
11
+ * GitHub Appin JWT. Voimassa GitHubin sallimat 10 minuuttia, mutta
12
+ * käytämme yhdeksää: GitHub hylkää tunnuksen, jos palvelimen kello on
13
+ * hieman edellä.
14
+ */
15
+ export declare function luoAppJwt(appId: string, avain: CryptoKey): Promise<string>;