Fix: fix #1817, some more improvements to the loading screen

This commit is contained in:
Pieter Vander Vennet 2024-03-11 01:17:33 +01:00
parent e36e594b89
commit 6394ee8e68
2 changed files with 545 additions and 523 deletions

View file

@ -17,19 +17,38 @@ import * as eli_global from "../src/assets/global-raster-layers.json"
import ValidationUtils from "../src/Models/ThemeConfig/Conversion/ValidationUtils" import ValidationUtils from "../src/Models/ThemeConfig/Conversion/ValidationUtils"
import { LayerConfigJson } from "../src/Models/ThemeConfig/Json/LayerConfigJson" import { LayerConfigJson } from "../src/Models/ThemeConfig/Json/LayerConfigJson"
import { QuestionableTagRenderingConfigJson } from "../src/Models/ThemeConfig/Json/QuestionableTagRenderingConfigJson" import { QuestionableTagRenderingConfigJson } from "../src/Models/ThemeConfig/Json/QuestionableTagRenderingConfigJson"
import Script from "./Script"
import crypto from "crypto"
const sharp = require("sharp") const sharp = require("sharp")
const template = readFileSync("theme.html", "utf8")
let codeTemplate = readFileSync("src/index_theme.ts.template", "utf8")
function enc(str: string): string { class GenerateLayouts extends Script {
private readonly template = readFileSync("theme.html", "utf8")
private readonly codeTemplate = readFileSync("src/index_theme.ts.template", "utf8")
private readonly removeOtherLanguages = readFileSync("src/UI/RemoveOtherLanguages.ts", "utf8")
.split("\n")
.slice(1)
.map((s) => s.trim())
.filter((s) => s !== "")
.join("\n")
private readonly removeOtherLanguagesHash =
"sha256-" + crypto.createHash("sha256").update(this.removeOtherLanguages).digest("base64")
private previousSrc: Set<string> = new Set<string>()
private eliUrlsCached: string[]
private date = new Date().toISOString()
constructor() {
super("Generates an '<theme>.html' and 'index_<theme>.ts' for every theme")
}
enc(str: string): string {
return encodeURIComponent(str.toLowerCase()) return encodeURIComponent(str.toLowerCase())
} }
async function createIcon(iconPath: string, size: number, alreadyWritten: string[]) { async createIcon(iconPath: string, size: number, alreadyWritten: string[]) {
let name = iconPath.split(".").slice(0, -1).join(".") // drop svg suffix let name = iconPath.split(".").slice(0, -1).join(".") // drop svg suffix
if (name.startsWith("./")) { if (name.startsWith("./")) {
name = name.substr(2) name = name.substring(2)
} }
const newname = `assets/generated/images/${name.replace(/\//g, "_")}${size}.png` const newname = `assets/generated/images/${name.replace(/\//g, "_")}${size}.png`
@ -57,9 +76,9 @@ async function createIcon(iconPath: string, size: number, alreadyWritten: string
} }
return newname return newname
} }
async function createSocialImage(layout: LayoutConfig, template: "" | "Wide"): Promise<string> { async createSocialImage(layout: LayoutConfig, template: "" | "Wide"): Promise<string> {
if (!layout.icon.endsWith(".svg")) { if (!layout.icon.endsWith(".svg")) {
console.warn( console.warn(
"Not creating a social image for " + "Not creating a social image for " +
@ -96,7 +115,9 @@ async function createSocialImage(layout: LayoutConfig, template: "" | "Wide"): P
return { return {
$: { $: {
id: "icon", id: "icon",
transform: `translate(${cx - r},${cy - r}) scale(${(r * 2) / Number(width)}) `, transform: `translate(${cx - r},${cy - r}) scale(${
(r * 2) / Number(width)
}) `,
}, },
g: [svg], g: [svg],
} }
@ -114,15 +135,15 @@ async function createSocialImage(layout: LayoutConfig, template: "" | "Wide"): P
writeFileSync(path, xml) writeFileSync(path, xml)
console.log("Created social image at ", path) console.log("Created social image at ", path)
return path return path
} }
async function createManifest( async createManifest(
layout: LayoutConfig, layout: LayoutConfig,
alreadyWritten: string[] alreadyWritten: string[]
): Promise<{ ): Promise<{
manifest: any manifest: any
whiteIcons: string[] whiteIcons: string[]
}> { }> {
Translation.forcedLanguage = "en" Translation.forcedLanguage = "en"
const icons = [] const icons = []
@ -153,8 +174,8 @@ async function createManifest(
const sizes = [72, 96, 120, 128, 144, 152, 180, 192, 384, 512] const sizes = [72, 96, 120, 128, 144, 152, 180, 192, 384, 512]
for (const size of sizes) { for (const size of sizes) {
const name = await createIcon(path, size, alreadyWritten) const name = await this.createIcon(path, size, alreadyWritten)
const whiteIcon = await createIcon(whiteBackgroundPath, size, alreadyWritten) const whiteIcon = await this.createIcon(whiteBackgroundPath, size, alreadyWritten)
whiteIcons.push(whiteIcon) whiteIcons.push(whiteIcon)
icons.push({ icons.push({
src: name, src: name,
@ -196,9 +217,9 @@ async function createManifest(
manifest, manifest,
whiteIcons, whiteIcons,
} }
} }
function asLangSpan(t: Translation, tag = "span"): string { asLangSpan(t: Translation, tag = "span"): string {
const values: string[] = [] const values: string[] = []
for (const lang in t.translations) { for (const lang in t.translations) {
if (lang === "_context") { if (lang === "_context") {
@ -207,15 +228,11 @@ function asLangSpan(t: Translation, tag = "span"): string {
values.push(`<${tag} lang="${lang}">${t.translations[lang]}</${tag}>`) values.push(`<${tag} lang="${lang}">${t.translations[lang]}</${tag}>`)
} }
return values.join("\n") return values.join("\n")
} }
let previousSrc: Set<string> = new Set<string>() async eliUrls(): Promise<string[]> {
if (this.eliUrlsCached) {
let eliUrlsCached: string[] return this.eliUrlsCached
async function eliUrls(): Promise<string[]> {
if (eliUrlsCached) {
return eliUrlsCached
} }
const urls: string[] = [] const urls: string[] = []
const regex = /{switch:([^}]+)}/ const regex = /{switch:([^}]+)}/
@ -260,17 +277,17 @@ async function eliUrls(): Promise<string[]> {
urls.push(styleSpec["glyphs"]) urls.push(styleSpec["glyphs"])
} }
} }
eliUrlsCached = urls this.eliUrlsCached = urls
return Utils.NoNull(urls).sort() return Utils.NoNull(urls).sort()
} }
async function generateCsp( async generateCsp(
layout: LayoutConfig, layout: LayoutConfig,
layoutJson: LayoutConfigJson, layoutJson: LayoutConfigJson,
options: { options: {
scriptSrcs: string[] scriptSrcs: string[]
} }
): Promise<string> { ): Promise<string> {
const apiUrls: string[] = [ const apiUrls: string[] = [
...Constants.defaultOverpassUrls, ...Constants.defaultOverpassUrls,
Constants.countryCoderEndpoint, Constants.countryCoderEndpoint,
@ -279,7 +296,7 @@ async function generateCsp(
"https://api.openstreetmap.org", "https://api.openstreetmap.org",
"https://pietervdvn.goatcounter.com", "https://pietervdvn.goatcounter.com",
"https://cache.mapcomplete.org", "https://cache.mapcomplete.org",
].concat(...(await eliUrls())) ].concat(...(await this.eliUrls()))
SpecialVisualizations.specialVisualizations.forEach((sv) => { SpecialVisualizations.specialVisualizations.forEach((sv) => {
if (typeof sv.needsUrls === "function") { if (typeof sv.needsUrls === "function") {
@ -339,17 +356,16 @@ async function generateCsp(
const connectSrc = Array.from(hosts).sort() const connectSrc = Array.from(hosts).sort()
const newSrcs = connectSrc.filter((newItem) => !previousSrc.has(newItem)) const newSrcs = connectSrc.filter((newItem) => !this.previousSrc.has(newItem))
console.log( console.log(
"Got", "Got",
hosts.size, hosts.size,
"connect-src items for theme", "connect-src items for theme",
layout.id, layout.id,
"(extra sources: ", newSrcs.length > 0 ? "(extra sources: " + newSrcs.join(" ") + ")" : ""
newSrcs.join(" ") + ")"
) )
previousSrc = hosts this.previousSrc = hosts
const csp: Record<string, string> = { const csp: Record<string, string> = {
"default-src": "'self'", "default-src": "'self'",
@ -359,9 +375,11 @@ async function generateCsp(
"report-to": "https://report.mapcomplete.org/csp", "report-to": "https://report.mapcomplete.org/csp",
"worker-src": "'self' blob:", // Vite somehow loads the worker via a 'blob' "worker-src": "'self' blob:", // Vite somehow loads the worker via a 'blob'
"style-src": "'self' 'unsafe-inline'", // unsafe-inline is needed to change the default background pin colours "style-src": "'self' 'unsafe-inline'", // unsafe-inline is needed to change the default background pin colours
"script-src": ["'self'", "https://gc.zgo.at/count.js", ...(options?.scriptSrcs ?? [])].join( "script-src": [
" " "'self'",
), "https://gc.zgo.at/count.js",
...(options?.scriptSrcs?.map((s) => "'" + s + "'") ?? []),
].join(" "),
} }
const content = Object.keys(csp) const content = Object.keys(csp)
.map((k) => k + " " + csp[k]) .map((k) => k + " " + csp[k])
@ -371,15 +389,14 @@ async function generateCsp(
`<meta http-equiv ="Report-To" content='{"group":"csp-endpoint", "max_age": 86400,"endpoints": [\{"url": "https://report.mapcomplete.org/csp"}], "include_subdomains": true}'>`, `<meta http-equiv ="Report-To" content='{"group":"csp-endpoint", "max_age": 86400,"endpoints": [\{"url": "https://report.mapcomplete.org/csp"}], "include_subdomains": true}'>`,
`<meta http-equiv="Content-Security-Policy" content="${content}">`, `<meta http-equiv="Content-Security-Policy" content="${content}">`,
].join("\n") ].join("\n")
} }
async function createLandingPage( async createLandingPage(
layout: LayoutConfig, layout: LayoutConfig,
layoutJson: LayoutConfigJson, layoutJson: LayoutConfigJson,
manifest,
whiteIcons, whiteIcons,
alreadyWritten alreadyWritten
) { ) {
Locale.language.setData(layout.language[0]) Locale.language.setData(layout.language[0])
const targetLanguage = layout.language[0] const targetLanguage = layout.language[0]
const ogTitle = Translations.T(layout.title).textFor(targetLanguage).replace(/"/g, '\\"') const ogTitle = Translations.T(layout.title).textFor(targetLanguage).replace(/"/g, '\\"')
@ -391,16 +408,16 @@ async function createLandingPage(
let ogImage = layout.socialImage let ogImage = layout.socialImage
let twitterImage = ogImage let twitterImage = ogImage
if (ogImage === LayoutConfig.defaultSocialImage && layout.official) { if (ogImage === LayoutConfig.defaultSocialImage && layout.official) {
ogImage = (await createSocialImage(layout, "")) ?? layout.socialImage ogImage = (await this.createSocialImage(layout, "")) ?? layout.socialImage
twitterImage = (await createSocialImage(layout, "Wide")) ?? layout.socialImage twitterImage = (await this.createSocialImage(layout, "Wide")) ?? layout.socialImage
} }
if (twitterImage.endsWith(".svg")) { if (twitterImage.endsWith(".svg")) {
// svgs are badly supported as social image, we use a generated svg instead // svgs are badly supported as social image, we use a generated svg instead
twitterImage = await createIcon(twitterImage, 512, alreadyWritten) twitterImage = await this.createIcon(twitterImage, 512, alreadyWritten)
} }
if (ogImage.endsWith(".svg")) { if (ogImage.endsWith(".svg")) {
ogImage = await createIcon(ogImage, 512, alreadyWritten) ogImage = await this.createIcon(ogImage, 512, alreadyWritten)
} }
let customCss = "" let customCss = ""
@ -409,7 +426,7 @@ async function createLandingPage(
const cssContent = readFileSync(layout.customCss) const cssContent = readFileSync(layout.customCss)
customCss = "<style>" + cssContent + "</style>" customCss = "<style>" + cssContent + "</style>"
} catch (e) { } catch (e) {
customCss = `<link rel='stylesheet' href="${layout.customCss}"/>` customCss = `<link rel="stylesheet" href="${layout.customCss}"/>`
} }
} }
@ -442,7 +459,7 @@ async function createLandingPage(
let themeSpecific = [ let themeSpecific = [
`<title>${ogTitle}</title>`, `<title>${ogTitle}</title>`,
`<link rel="manifest" href="${enc(layout.id)}.webmanifest">`, `<link rel="manifest" href="${this.enc(layout.id)}.webmanifest">`,
og, og,
customCss, customCss,
`<link rel="icon" href="${icon}" sizes="any" type="image/svg+xml">`, `<link rel="icon" href="${icon}" sizes="any" type="image/svg+xml">`,
@ -450,9 +467,10 @@ async function createLandingPage(
].join("\n") ].join("\n")
const loadingText = Translations.t.general.loadingTheme.Subs({ theme: layout.title }) const loadingText = Translations.t.general.loadingTheme.Subs({ theme: layout.title })
const templateLines = template.split("\n") // const templateLines: string[] = this.template.split("\n").slice(1) // Slice to remove the 'export {}'-line
let output = template
.replace("Loading MapComplete, hang on...", asLangSpan(loadingText, "h1")) return this.template
.replace("Loading MapComplete, hang on...", this.asLangSpan(loadingText, "h1"))
.replace( .replace(
"Made with OpenStreetMap", "Made with OpenStreetMap",
Translations.t.general.poweredByOsm.textFor(targetLanguage) Translations.t.general.poweredByOsm.textFor(targetLanguage)
@ -460,29 +478,31 @@ async function createLandingPage(
.replace(/<!-- THEME-SPECIFIC -->.*<!-- THEME-SPECIFIC-END-->/s, themeSpecific) .replace(/<!-- THEME-SPECIFIC -->.*<!-- THEME-SPECIFIC-END-->/s, themeSpecific)
.replace( .replace(
/<!-- CSP -->/, /<!-- CSP -->/,
await generateCsp(layout, layoutJson, { await this.generateCsp(layout, layoutJson, {
scriptSrcs: [], scriptSrcs: [this.removeOtherLanguagesHash],
}) })
) )
.replace( .replace(
/<!-- DESCRIPTION START -->.*<!-- DESCRIPTION END -->/s, /<!-- DESCRIPTION START -->.*<!-- DESCRIPTION END -->/s,
asLangSpan(layout.shortDescription) this.asLangSpan(layout.shortDescription)
) )
.replace( .replace(
/<!-- IMAGE-START -->.*<!-- IMAGE-END -->/s, /<!-- IMAGE-START -->.*<!-- IMAGE-END -->/s,
"<img class='p-8 h-32 w-32 self-start' src='" + icon + "' />" "<img class='p-4 h-32 w-32 self-start' src='" + icon + "' />"
) )
.replace( .replace(
/.*\/src\/index\.ts.*/, /.*\/src\/index\.ts.*/,
`<script type="module" src="./index_${layout.id}.ts"></script>` `<script type="module" src="./index_${layout.id}.ts"></script>`
) )
.replace("Version", Constants.vNumber)
return output .replace(
} /\n.*RemoveOtherLanguages.*\n/i,
"\n<script>" + this.removeOtherLanguages + "</script>\n"
)
.replace("Version", `${Constants.vNumber} <div class='text-xs'>${this.date}</div>`)
}
async function createIndexFor(theme: LayoutConfig) { async createIndexFor(theme: LayoutConfig) {
const filename = "index_" + theme.id + ".ts" const filename = "index_" + theme.id + ".ts"
const imports = [ const imports = [
@ -490,7 +510,9 @@ async function createIndexFor(theme: LayoutConfig) {
`import { ThemeMetaTagging } from "./src/assets/generated/metatagging/${theme.id}"`, `import { ThemeMetaTagging } from "./src/assets/generated/metatagging/${theme.id}"`,
] ]
for (const layerName of Constants.added_by_default) { for (const layerName of Constants.added_by_default) {
imports.push(`import ${layerName} from "./src/assets/generated/layers/${layerName}.json"`) imports.push(
`import ${layerName} from "./src/assets/generated/layers/${layerName}.json"`
)
} }
writeFileSync(filename, imports.join("\n") + "\n") writeFileSync(filename, imports.join("\n") + "\n")
@ -500,22 +522,25 @@ async function createIndexFor(theme: LayoutConfig) {
addLayers.push(` layout.layers.push(<any> ${layerName})`) addLayers.push(` layout.layers.push(<any> ${layerName})`)
} }
codeTemplate = codeTemplate.replace(" // LAYOUT.ADD_LAYERS", addLayers.join("\n")) let codeTemplate = this.codeTemplate.replace(
" // LAYOUT.ADD_LAYERS",
addLayers.join("\n")
)
appendFileSync(filename, codeTemplate) appendFileSync(filename, codeTemplate)
} }
function createDir(path) { createDir(path) {
if (!existsSync(path)) { if (!existsSync(path)) {
mkdirSync(path) mkdirSync(path)
} }
} }
async function main(): Promise<void> { async main(): Promise<void> {
const alreadyWritten = [] const alreadyWritten = []
createDir("./public/assets/") this.createDir("./public/assets/")
createDir("./public/assets/generated") this.createDir("./public/assets/generated")
createDir("./public/assets/generated/images") this.createDir("./public/assets/generated/images")
const blacklist = [ const blacklist = [
"", "",
@ -554,25 +579,24 @@ async function main(): Promise<void> {
console.log("Could not write manifest for ", layoutName, " because ", err) console.log("Could not write manifest for ", layoutName, " because ", err)
} }
} }
const { manifest, whiteIcons } = await createManifest(layout, alreadyWritten) const { manifest, whiteIcons } = await this.createManifest(layout, alreadyWritten)
const manif = JSON.stringify(manifest, undefined, 2) const manif = JSON.stringify(manifest, undefined, 2)
const manifestLocation = encodeURIComponent(layout.id.toLowerCase()) + ".webmanifest" const manifestLocation = encodeURIComponent(layout.id.toLowerCase()) + ".webmanifest"
writeFile("public/" + manifestLocation, manif, err) writeFile("public/" + manifestLocation, manif, err)
// Create a landing page for the given theme // Create a landing page for the given theme
const landing = await createLandingPage( const landing = await this.createLandingPage(
layout, layout,
layoutConfigJson, layoutConfigJson,
manifest,
whiteIcons, whiteIcons,
alreadyWritten alreadyWritten
) )
writeFile(enc(layout.id) + ".html", landing, err) writeFile(this.enc(layout.id) + ".html", landing, err)
await createIndexFor(layout) await this.createIndexFor(layout)
} }
const { manifest } = await createManifest( const { manifest } = await this.createManifest(
new LayoutConfig({ new LayoutConfig({
icon: "./assets/svg/mapcomplete_logo.svg", icon: "./assets/svg/mapcomplete_logo.svg",
id: "index", id: "index",
@ -589,9 +613,7 @@ async function main(): Promise<void> {
const manif = JSON.stringify(manifest, undefined, 2) const manif = JSON.stringify(manifest, undefined, 2)
writeFileSync("public/index.webmanifest", manif) writeFileSync("public/index.webmanifest", manif)
}
} }
ScriptUtils.fixUtils() new GenerateLayouts().run()
main().then(() => {
console.log("All done!")
})

View file

@ -59,12 +59,12 @@
</p> </p>
</div> </div>
<div class="flex justify-between items-start w-full"> <div class="flex justify-between items-end w-full">
<!-- IMAGE-START --> <!-- IMAGE-START -->
<img aria-hidden="true" class="p-8 h-32 w-32 self-start" src="./assets/svg/add.svg"> <img aria-hidden="true" class="p-4 h-32 w-32 self-start" src="./assets/svg/add.svg">
<!-- IMAGE-END --> <!-- IMAGE-END -->
<div class="h-min subtle"> <div class="h-min subtle flex flex-col items-end">
Version Version
</div> </div>