website-xp-phone 1.5.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.
- package/.astro/content-assets.mjs +1 -0
- package/.astro/content-modules.mjs +1 -0
- package/.astro/content.d.ts +199 -0
- package/.astro/data-store.json +1 -0
- package/.astro/settings.json +8 -0
- package/.astro/types.d.ts +1 -0
- package/.devcontainer/devcontainer.json +23 -0
- package/.env.firebase.example +8 -0
- package/.firebaserc +5 -0
- package/.gitattributes +2 -0
- package/.github/copilot-instructions.md +131 -0
- package/.github/dependabot.yml +11 -0
- package/.github/workflows/ci.yml +45 -0
- package/.github/workflows/deploy-admin.yml +48 -0
- package/.github/workflows/static.yml +43 -0
- package/.gitmodules +5 -0
- package/FIREBASE_SETUP.md +69 -0
- package/README.md +63 -0
- package/SECURITY.md +11 -0
- package/admin/Admin.csproj +7 -0
- package/admin/Dockerfile +14 -0
- package/admin/Program.cs +8 -0
- package/deploy-admin-cloud-run.md +229 -0
- package/eslint.config.js +28 -0
- package/firebase.json +5 -0
- package/firestore.rules +29 -0
- package/index.html +52 -0
- package/package.json +48 -0
- package/pagerts_output.json +1 -0
- package/public/5.html +967 -0
- package/public/BAHNSCHRIFT.TTF +0 -0
- package/public/Beep.ogg +0 -0
- package/public/Clippy.png +0 -0
- package/public/Layered Network Security Model for Home Networks (slides).pdf +0 -0
- package/public/Layered Network Security Model for Home Networks.pdf +0 -0
- package/public/TODO.pdf +0 -0
- package/public/WoW_Config.zip +3 -0
- package/public/addons/energy-swing.txt +1 -0
- package/public/addons/lego-yoda-death-readme.txt +11 -0
- package/public/addons/lego-yoda-death.mp3 +0 -0
- package/public/addons/mana-blast.txt +1 -0
- package/public/addons/rage-volley.txt +1 -0
- package/public/addons/rueg-cell.txt +1 -0
- package/public/addons/rueg-elvui-profile.txt +1 -0
- package/public/addons/rueg-grid2.txt +214 -0
- package/public/addons/rueg-plater-smol.txt +1 -0
- package/public/addons/rueg-plater.txt +1 -0
- package/public/addons/rueg-wa-druid.txt +1 -0
- package/public/addons/rueg-wa-priest.txt +1 -0
- package/public/addons/rueg-wa-rogue.txt +1 -0
- package/public/addons/rueg-wa-shaman.txt +1 -0
- package/public/addons/rueg-wa-warrior.txt +1 -0
- package/public/addons/spirit-smash.txt +1 -0
- package/public/avatar.jpg +0 -0
- package/public/avatar.png +0 -0
- package/public/crunchy_kick.ogg +0 -0
- package/public/documents/resume.html +312 -0
- package/public/favicon.ico +0 -0
- package/public/images/Ateric1.png +0 -0
- package/public/images/Ateric2.png +0 -0
- package/public/images/equal1.png +0 -0
- package/public/images/hyperawareofwhatacatis.png +0 -0
- package/public/images/kogg1.png +0 -0
- package/public/images/kogg2.png +0 -0
- package/public/images/rueg1.png +0 -0
- package/public/images/rueg2.png +0 -0
- package/public/incorrect_responses.txt +126 -0
- package/public/loading.css +51 -0
- package/public/resume.pdf +0 -0
- package/public/robots.txt +9 -0
- package/public/soundcloud.json +57 -0
- package/public/spinner.svg +12 -0
- package/public/tada.wav +0 -0
- package/public/yooh.mp3 +0 -0
- package/render.yaml +5 -0
- package/scripts/ensure-blog-worktree.mjs +24 -0
- package/scripts/generate-soundcloud-json.mjs +198 -0
- package/scripts/git-worktree-helper.mjs +122 -0
- package/scripts/hoist-dev-blog-local.mjs +149 -0
- package/scripts/music-schema.mjs +56 -0
- package/scripts/publish-soundcloud-json.mjs +32 -0
- package/scripts/sync-music-links-from-worktree.mjs +32 -0
- package/src/App.tsx +1500 -0
- package/src/addons.json +76 -0
- package/src/components/Addon.tsx +223 -0
- package/src/components/BlogContent.tsx +103 -0
- package/src/components/CopyToClipboardButton.tsx +21 -0
- package/src/components/MenuBar.tsx +151 -0
- package/src/components/MenuBarWithContext.tsx +6 -0
- package/src/components/Modal.tsx +17 -0
- package/src/components/MusicContent.tsx +309 -0
- package/src/components/NavBarController.tsx +55 -0
- package/src/components/NavBarControllerWrapper.tsx +13 -0
- package/src/components/Page.tsx +56 -0
- package/src/components/SitemapContent.tsx +125 -0
- package/src/contacts.json +32 -0
- package/src/env.d.ts +13 -0
- package/src/lib/assistantStateMachine.ts +80 -0
- package/src/lib/audioOverlap.ts +99 -0
- package/src/lib/keyboardInputUtils.ts +182 -0
- package/src/lib/musicSchema.ts +85 -0
- package/src/lib/naggingAssistantClient.ts +241 -0
- package/src/lib/resumeAnalytics.ts +163 -0
- package/src/main.tsx +35 -0
- package/src/pages.json +50 -0
- package/src/sections.json +243 -0
- package/src/src+addons.zip +3 -0
- package/src/styles/main.css +465 -0
- package/src/utils/blogSecurity.ts +87 -0
- package/src/utils/menuItems.ts +33 -0
- package/src/windowing/MinimizedSections.tsx +86 -0
- package/src/windowing/Section.tsx +586 -0
- package/src/windowing/context.tsx +13 -0
- package/src/windowing/hooks.ts +10 -0
- package/src/windowing/index.ts +7 -0
- package/src/windowing/provider.tsx +74 -0
- package/src/windowing/server.ts +3 -0
- package/src/windowing/types.ts +33 -0
- package/src/windowing/utils.ts +135 -0
- package/tests/generate-soundcloud-json.test.mjs +63 -0
- package/tests/music-schema.test.mjs +53 -0
- package/tsconfig.json +26 -0
- package/vite.config.ts +304 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
|
|
3
|
+
export type Heading = string;
|
|
4
|
+
export type HttpUrl = `http://${string}` | `https://${string}`;
|
|
5
|
+
export type LinkUrl = HttpUrl | `/${string}`;
|
|
6
|
+
|
|
7
|
+
export type Content = string | (string | SectionProps)[];
|
|
8
|
+
|
|
9
|
+
export type SectionProps = {
|
|
10
|
+
className?: string | undefined;
|
|
11
|
+
heading?: Heading | undefined;
|
|
12
|
+
content?: Content | undefined;
|
|
13
|
+
link?: LinkUrl | undefined;
|
|
14
|
+
printout?: string | string[] | undefined;
|
|
15
|
+
children?: React.ReactNode;
|
|
16
|
+
depth?: number | undefined;
|
|
17
|
+
uuid?: string | undefined;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type SectionMetadata = {
|
|
21
|
+
uuid: string;
|
|
22
|
+
heading: string;
|
|
23
|
+
depth: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type ContentWithUUID<T> = Omit<T, "content"> & {
|
|
27
|
+
uuid: string;
|
|
28
|
+
content?: string | (string | ContentWithUUID<T>)[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type PageMetadata = {
|
|
32
|
+
sections: SectionMetadata[];
|
|
33
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { SectionProps, SectionMetadata, ContentWithUUID } from "./types";
|
|
2
|
+
|
|
3
|
+
const createUUID = () => {
|
|
4
|
+
if (
|
|
5
|
+
typeof globalThis.crypto !== "undefined" &&
|
|
6
|
+
typeof globalThis.crypto.randomUUID === "function"
|
|
7
|
+
) {
|
|
8
|
+
return globalThis.crypto.randomUUID();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return `uuid-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function ensureValidLinkUrl(link: string, heading?: string): void {
|
|
15
|
+
if (link.startsWith("/")) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let parsed: URL;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
parsed = new URL(link);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Invalid section link URL${heading ? ` for '${heading}'` : ""}: '${link}'.`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`Section link must use http/https or absolute local path${heading ? ` for '${heading}'` : ""}: '${link}'.`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function validateSectionLinks(item: SectionProps): void {
|
|
37
|
+
if (typeof item.link === "string") {
|
|
38
|
+
ensureValidLinkUrl(item.link, item.heading);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!Array.isArray(item.content)) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (const subItem of item.content) {
|
|
46
|
+
if (typeof subItem === "string") {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
validateSectionLinks(subItem);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validateContentLinks<T extends SectionProps>(content: T | T[]): void {
|
|
55
|
+
if (Array.isArray(content)) {
|
|
56
|
+
for (const item of content) {
|
|
57
|
+
validateSectionLinks(item);
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
validateSectionLinks(content);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Generic function to recursively assign UUIDs to content items
|
|
67
|
+
*/
|
|
68
|
+
function assignUUIDs<T extends SectionProps>(
|
|
69
|
+
item: T,
|
|
70
|
+
depth: number = 0,
|
|
71
|
+
metadata: Map<string, SectionMetadata> = new Map(),
|
|
72
|
+
): { result: ContentWithUUID<T>; metadata: Map<string, SectionMetadata> } {
|
|
73
|
+
const uuid = createUUID();
|
|
74
|
+
const heading = item.heading || "";
|
|
75
|
+
|
|
76
|
+
// Store metadata
|
|
77
|
+
if (heading) {
|
|
78
|
+
metadata.set(uuid, { uuid, heading, depth });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Handle content recursively
|
|
82
|
+
let newContent: string | (string | ContentWithUUID<T>)[] | undefined;
|
|
83
|
+
if (item.content) {
|
|
84
|
+
if (typeof item.content === "string") {
|
|
85
|
+
newContent = item.content;
|
|
86
|
+
} else if (Array.isArray(item.content)) {
|
|
87
|
+
const mapped = item.content.map((subItem: unknown) => {
|
|
88
|
+
if (typeof subItem === "string") {
|
|
89
|
+
return subItem;
|
|
90
|
+
} else {
|
|
91
|
+
const result = assignUUIDs(subItem as T, depth + 1, metadata);
|
|
92
|
+
return result.result;
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
newContent = mapped as (string | ContentWithUUID<T>)[];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const resultWithUUID: ContentWithUUID<T> = {
|
|
100
|
+
...item,
|
|
101
|
+
uuid,
|
|
102
|
+
content: newContent,
|
|
103
|
+
depth,
|
|
104
|
+
} as ContentWithUUID<T>;
|
|
105
|
+
|
|
106
|
+
return { result: resultWithUUID, metadata };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Process an array or single content item
|
|
111
|
+
*/
|
|
112
|
+
export function processContent<T extends SectionProps>(
|
|
113
|
+
content: T | T[],
|
|
114
|
+
): {
|
|
115
|
+
processed: ContentWithUUID<T> | ContentWithUUID<T>[];
|
|
116
|
+
metadata: SectionMetadata[];
|
|
117
|
+
} {
|
|
118
|
+
validateContentLinks(content);
|
|
119
|
+
|
|
120
|
+
const metadata = new Map<string, SectionMetadata>();
|
|
121
|
+
|
|
122
|
+
if (Array.isArray(content)) {
|
|
123
|
+
const processed = content.map((item) => {
|
|
124
|
+
const result = assignUUIDs(item, 0, metadata);
|
|
125
|
+
return result.result;
|
|
126
|
+
});
|
|
127
|
+
return { processed, metadata: Array.from(metadata.values()) };
|
|
128
|
+
} else {
|
|
129
|
+
const result = assignUUIDs(content, 0, metadata);
|
|
130
|
+
return {
|
|
131
|
+
processed: result.result,
|
|
132
|
+
metadata: Array.from(result.metadata.values()),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildTracks,
|
|
6
|
+
isTrackPath,
|
|
7
|
+
parseOutput,
|
|
8
|
+
titleFromPath,
|
|
9
|
+
} from "../scripts/generate-soundcloud-json.mjs";
|
|
10
|
+
|
|
11
|
+
test("parseOutput extracts the first JSON payload from pagerts output", () => {
|
|
12
|
+
const payload = parseOutput(
|
|
13
|
+
`noise
|
|
14
|
+
[{
|
|
15
|
+
"resources": [{ "link": { "value": "/akinevz/track-one" } }]
|
|
16
|
+
}]
|
|
17
|
+
trailing logs`,
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
assert.equal(Array.isArray(payload), true);
|
|
21
|
+
assert.equal(payload.length, 1);
|
|
22
|
+
assert.deepEqual(payload[0].resources[0].link.value, "/akinevz/track-one");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("parseOutput rejects output without JSON", () => {
|
|
26
|
+
assert.throws(() => parseOutput("no json payload here"), /does not contain JSON payload/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("isTrackPath only accepts direct track paths", () => {
|
|
30
|
+
assert.equal(isTrackPath("/akinevz/track-one"), true);
|
|
31
|
+
assert.equal(isTrackPath("/akinevz"), false);
|
|
32
|
+
assert.equal(isTrackPath("/akinevz/sets/mixtape"), false);
|
|
33
|
+
assert.equal(isTrackPath("/akinevz/likes"), false);
|
|
34
|
+
assert.equal(isTrackPath("/akinevz/tracks"), false);
|
|
35
|
+
assert.equal(isTrackPath("/akinevz/comments"), false);
|
|
36
|
+
assert.equal(isTrackPath("/someone-else/track"), false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("titleFromPath derives the last path segment", () => {
|
|
40
|
+
assert.equal(titleFromPath("/akinevz/microtonal-vudoo"), "microtonal-vudoo");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("buildTracks deduplicates resources and maps them into soundcloud URLs", () => {
|
|
44
|
+
const tracks = buildTracks([
|
|
45
|
+
{ link: { value: "/akinevz/track-one" } },
|
|
46
|
+
{ link: { value: "/akinevz/track-one" } },
|
|
47
|
+
{ link: { value: "/akinevz/track-two" } },
|
|
48
|
+
{ link: { value: "/akinevz/sets/mixtape" } },
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
assert.deepEqual(tracks, [
|
|
52
|
+
{
|
|
53
|
+
path: "/akinevz/track-one",
|
|
54
|
+
title: "track-one",
|
|
55
|
+
url: "https://soundcloud.com/akinevz/track-one",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
path: "/akinevz/track-two",
|
|
59
|
+
title: "track-two",
|
|
60
|
+
url: "https://soundcloud.com/akinevz/track-two",
|
|
61
|
+
},
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
|
|
4
|
+
import { buildMusicGroupSchema, serializeJsonLd } from "../scripts/music-schema.mjs";
|
|
5
|
+
|
|
6
|
+
test("buildMusicGroupSchema includes the requested profile fields", () => {
|
|
7
|
+
const schema = buildMusicGroupSchema([
|
|
8
|
+
{ title: "latest-track", url: "https://soundcloud.com/akinevz/latest-track" },
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
assert.deepEqual(schema, {
|
|
12
|
+
"@context": "https://schema.org",
|
|
13
|
+
"@type": "MusicGroup",
|
|
14
|
+
name: "akinevz",
|
|
15
|
+
alternateName: [
|
|
16
|
+
"KINE",
|
|
17
|
+
"KALE",
|
|
18
|
+
"I lied my name isn't actually KINE",
|
|
19
|
+
"I lied my name isn't actually KALE",
|
|
20
|
+
],
|
|
21
|
+
url: "https://akinevz.com",
|
|
22
|
+
genre: ["Electronic", "Experimental", "Industrial", "Drone", "Glitch"],
|
|
23
|
+
description:
|
|
24
|
+
"Independent sound designer, music creator, and electronic music artist.",
|
|
25
|
+
sameAs: [
|
|
26
|
+
"https://soundcloud.com/akinevz",
|
|
27
|
+
"https://youtube.com/@akinevz",
|
|
28
|
+
"https://x.com/akinevz",
|
|
29
|
+
"https://github.com/akinevz2",
|
|
30
|
+
],
|
|
31
|
+
track: [
|
|
32
|
+
{
|
|
33
|
+
"@type": "MusicRecording",
|
|
34
|
+
name: "latest-track",
|
|
35
|
+
url: "https://soundcloud.com/akinevz/latest-track",
|
|
36
|
+
byArtist: {
|
|
37
|
+
"@type": "MusicGroup",
|
|
38
|
+
name: "KINE",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("buildMusicGroupSchema falls back to an empty track set", () => {
|
|
46
|
+
const schema = buildMusicGroupSchema();
|
|
47
|
+
|
|
48
|
+
assert.deepEqual(schema.track, []);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("serializeJsonLd escapes closing angle brackets", () => {
|
|
52
|
+
assert.equal(serializeJsonLd({ value: "<script>" }), '{"value":"\\u003cscript>"}');
|
|
53
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"moduleResolution": "Bundler",
|
|
9
|
+
"allowImportingTsExtensions": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"noEmit": true,
|
|
12
|
+
"jsx": "react-jsx",
|
|
13
|
+
"strict": true,
|
|
14
|
+
"types": ["vite/client", "node"],
|
|
15
|
+
"noUnusedLocals": true,
|
|
16
|
+
"noUnusedParameters": true,
|
|
17
|
+
"exactOptionalPropertyTypes": true,
|
|
18
|
+
"noUncheckedIndexedAccess": true,
|
|
19
|
+
"noImplicitReturns": true,
|
|
20
|
+
"verbatimModuleSyntax": true,
|
|
21
|
+
"isolatedModules": true,
|
|
22
|
+
"forceConsistentCasingInFileNames": true,
|
|
23
|
+
"incremental": true
|
|
24
|
+
},
|
|
25
|
+
// "include": ["src", "vite.config.ts"]
|
|
26
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
|
|
3
|
+
import { defineConfig } from "vite";
|
|
4
|
+
import react from "@vitejs/plugin-react";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import process from "node:process";
|
|
8
|
+
import { buildMusicGroupSchema, serializeJsonLd } from "./src/lib/musicSchema";
|
|
9
|
+
|
|
10
|
+
const pagesJsonPath = new URL("./src/pages.json", import.meta.url);
|
|
11
|
+
|
|
12
|
+
type PageDefinition = {
|
|
13
|
+
path: string;
|
|
14
|
+
title: string;
|
|
15
|
+
description: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type SoundCloudTrack = {
|
|
19
|
+
title: string;
|
|
20
|
+
url: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type SoundCloudPayload = {
|
|
24
|
+
tracks?: SoundCloudTrack[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const pages = JSON.parse(fs.readFileSync(pagesJsonPath, "utf-8")) as PageDefinition[];
|
|
28
|
+
const SITE_ORIGIN = process.env.SITE_ORIGIN || "https://akinevz.com";
|
|
29
|
+
const DEV_HOST = process.env.VITE_DEV_HOST || "127.0.0.1";
|
|
30
|
+
const DEV_PORT = Number(process.env.VITE_DEV_PORT || "8086");
|
|
31
|
+
|
|
32
|
+
const SECURITY_HEADERS = {
|
|
33
|
+
"X-Content-Type-Options": "nosniff",
|
|
34
|
+
"X-Frame-Options": "SAMEORIGIN",
|
|
35
|
+
"Content-Security-Policy": "frame-ancestors 'self'",
|
|
36
|
+
"Referrer-Policy": "no-referrer",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function normalizeRoute(routePath: string): string {
|
|
40
|
+
if (!routePath || routePath === "/") {
|
|
41
|
+
return "/";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return `/${routePath.replace(/^\/+|\/+$/g, "")}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getRouteUrl(routePath: string): string {
|
|
48
|
+
return new URL(normalizeRoute(routePath), SITE_ORIGIN).toString();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getSitemapLastMod(): string {
|
|
52
|
+
return new Date().toISOString().slice(0, 10);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getSitemapPriority(routePath: string): string {
|
|
56
|
+
switch (normalizeRoute(routePath)) {
|
|
57
|
+
case "/":
|
|
58
|
+
return "1.0";
|
|
59
|
+
case "/sitemap":
|
|
60
|
+
return "0.9";
|
|
61
|
+
case "/music":
|
|
62
|
+
case "/blog":
|
|
63
|
+
return "0.8";
|
|
64
|
+
default:
|
|
65
|
+
return "0.7";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getSitemapChangeFreq(routePath: string): string {
|
|
70
|
+
switch (normalizeRoute(routePath)) {
|
|
71
|
+
case "/":
|
|
72
|
+
case "/music":
|
|
73
|
+
return "weekly";
|
|
74
|
+
case "/blog":
|
|
75
|
+
return "monthly";
|
|
76
|
+
case "/sitemap":
|
|
77
|
+
return "monthly";
|
|
78
|
+
default:
|
|
79
|
+
return "yearly";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getSoundCloudTracks(): SoundCloudTrack[] {
|
|
84
|
+
const soundcloudJsonPath = path.resolve(process.cwd(), "public/soundcloud.json");
|
|
85
|
+
|
|
86
|
+
if (!fs.existsSync(soundcloudJsonPath)) {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const payload = JSON.parse(fs.readFileSync(soundcloudJsonPath, "utf-8")) as SoundCloudPayload;
|
|
92
|
+
return Array.isArray(payload.tracks) ? payload.tracks : [];
|
|
93
|
+
} catch {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function withStructuredData(indexHtml: string, tracks: SoundCloudTrack[]): string {
|
|
99
|
+
const structuredData = `<script type="application/ld+json" id="homepage-music-structured-data">${serializeJsonLd(
|
|
100
|
+
buildMusicGroupSchema(tracks),
|
|
101
|
+
)}</script>`;
|
|
102
|
+
|
|
103
|
+
return indexHtml.replace("</head>", `${structuredData}\n </head>`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function escapeHtml(value: string): string {
|
|
107
|
+
return value
|
|
108
|
+
.replace(/&/g, "&")
|
|
109
|
+
.replace(/</g, "<")
|
|
110
|
+
.replace(/>/g, ">")
|
|
111
|
+
.replace(/"/g, """)
|
|
112
|
+
.replace(/'/g, "'");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function replaceTitleByDataMeta(html: string, title: string): string {
|
|
116
|
+
const pattern = /<title[^>]*data-route-meta=["']title["'][^>]*>[\s\S]*?<\/title>/i;
|
|
117
|
+
return html.replace(pattern, `<title data-route-meta="title">${title}</title>`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function replaceSelfClosingTagByDataMeta(
|
|
121
|
+
html: string,
|
|
122
|
+
key: string,
|
|
123
|
+
replacement: string,
|
|
124
|
+
): string {
|
|
125
|
+
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
126
|
+
const pattern = new RegExp(`<[^>]*data-route-meta=["']${escapedKey}["'][^>]*\\/?\\s*>`, "i");
|
|
127
|
+
return html.replace(pattern, replacement);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function withRouteMeta(indexHtml: string, page: PageDefinition): string {
|
|
131
|
+
const title = escapeHtml(page.title);
|
|
132
|
+
const description = escapeHtml(page.description);
|
|
133
|
+
const url = escapeHtml(getRouteUrl(page.path));
|
|
134
|
+
const socialImageUrl = escapeHtml(new URL("/avatar.png", SITE_ORIGIN).toString());
|
|
135
|
+
|
|
136
|
+
let nextHtml = indexHtml;
|
|
137
|
+
nextHtml = replaceTitleByDataMeta(nextHtml, title);
|
|
138
|
+
nextHtml = replaceSelfClosingTagByDataMeta(
|
|
139
|
+
nextHtml,
|
|
140
|
+
"description",
|
|
141
|
+
`<meta name="description" content="${description}" data-route-meta="description" />`,
|
|
142
|
+
);
|
|
143
|
+
nextHtml = replaceSelfClosingTagByDataMeta(
|
|
144
|
+
nextHtml,
|
|
145
|
+
"canonical",
|
|
146
|
+
`<link rel="canonical" href="${url}" data-route-meta="canonical" />`,
|
|
147
|
+
);
|
|
148
|
+
nextHtml = replaceSelfClosingTagByDataMeta(
|
|
149
|
+
nextHtml,
|
|
150
|
+
"og:title",
|
|
151
|
+
`<meta property="og:title" content="${title}" data-route-meta="og:title" />`,
|
|
152
|
+
);
|
|
153
|
+
nextHtml = replaceSelfClosingTagByDataMeta(
|
|
154
|
+
nextHtml,
|
|
155
|
+
"og:description",
|
|
156
|
+
`<meta property="og:description" content="${description}" data-route-meta="og:description" />`,
|
|
157
|
+
);
|
|
158
|
+
nextHtml = replaceSelfClosingTagByDataMeta(
|
|
159
|
+
nextHtml,
|
|
160
|
+
"og:url",
|
|
161
|
+
`<meta property="og:url" content="${url}" data-route-meta="og:url" />`,
|
|
162
|
+
);
|
|
163
|
+
nextHtml = replaceSelfClosingTagByDataMeta(
|
|
164
|
+
nextHtml,
|
|
165
|
+
"og:image",
|
|
166
|
+
`<meta property="og:image" content="${socialImageUrl}" data-route-meta="og:image" />`,
|
|
167
|
+
);
|
|
168
|
+
nextHtml = replaceSelfClosingTagByDataMeta(
|
|
169
|
+
nextHtml,
|
|
170
|
+
"twitter:card",
|
|
171
|
+
'<meta name="twitter:card" content="summary" data-route-meta="twitter:card" />',
|
|
172
|
+
);
|
|
173
|
+
nextHtml = replaceSelfClosingTagByDataMeta(
|
|
174
|
+
nextHtml,
|
|
175
|
+
"twitter:image",
|
|
176
|
+
`<meta name="twitter:image" content="${socialImageUrl}" data-route-meta="twitter:image" />`,
|
|
177
|
+
);
|
|
178
|
+
nextHtml = replaceSelfClosingTagByDataMeta(
|
|
179
|
+
nextHtml,
|
|
180
|
+
"twitter:title",
|
|
181
|
+
`<meta name="twitter:title" content="${title}" data-route-meta="twitter:title" />`,
|
|
182
|
+
);
|
|
183
|
+
nextHtml = replaceSelfClosingTagByDataMeta(
|
|
184
|
+
nextHtml,
|
|
185
|
+
"twitter:description",
|
|
186
|
+
`<meta name="twitter:description" content="${description}" data-route-meta="twitter:description" />`,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return nextHtml;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function generateSitemapXml(): string {
|
|
193
|
+
const urls = pages
|
|
194
|
+
.map(
|
|
195
|
+
(page) =>
|
|
196
|
+
[
|
|
197
|
+
" <url>",
|
|
198
|
+
` <loc>${escapeHtml(getRouteUrl(page.path))}</loc>`,
|
|
199
|
+
` <lastmod>${getSitemapLastMod()}</lastmod>`,
|
|
200
|
+
` <changefreq>${getSitemapChangeFreq(page.path)}</changefreq>`,
|
|
201
|
+
` <priority>${getSitemapPriority(page.path)}</priority>`,
|
|
202
|
+
" </url>",
|
|
203
|
+
].join("\n"),
|
|
204
|
+
)
|
|
205
|
+
.join("\n");
|
|
206
|
+
|
|
207
|
+
return [
|
|
208
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
209
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
210
|
+
urls,
|
|
211
|
+
"</urlset>",
|
|
212
|
+
].join("\n");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function routeSkeletonPlugin() {
|
|
216
|
+
return {
|
|
217
|
+
name: "vite-plugin-route-skeletons",
|
|
218
|
+
apply: "build" as const,
|
|
219
|
+
closeBundle() {
|
|
220
|
+
const outDir = path.resolve(process.cwd(), "dist");
|
|
221
|
+
const indexPath = path.join(outDir, "index.html");
|
|
222
|
+
if (!fs.existsSync(indexPath)) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const indexHtml = fs.readFileSync(indexPath, "utf-8");
|
|
227
|
+
const soundCloudTracks = getSoundCloudTracks();
|
|
228
|
+
fs.writeFileSync(path.join(outDir, "sitemap.xml"), generateSitemapXml(), "utf-8");
|
|
229
|
+
|
|
230
|
+
for (const page of pages) {
|
|
231
|
+
const routeHtml =
|
|
232
|
+
normalizeRoute(page.path || "/") === "/"
|
|
233
|
+
? withStructuredData(withRouteMeta(indexHtml, page), soundCloudTracks)
|
|
234
|
+
: withRouteMeta(indexHtml, page);
|
|
235
|
+
const normalizedRoutePath = normalizeRoute(page.path || "/");
|
|
236
|
+
|
|
237
|
+
if (normalizedRoutePath === "/") {
|
|
238
|
+
fs.writeFileSync(indexPath, routeHtml, "utf-8");
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const normalizedRoute = normalizedRoutePath.replace(/^\/+|\/+$/g, "");
|
|
243
|
+
const routeDir = path.join(outDir, normalizedRoute);
|
|
244
|
+
fs.mkdirSync(routeDir, { recursive: true });
|
|
245
|
+
fs.writeFileSync(path.join(routeDir, "index.html"), routeHtml, "utf-8");
|
|
246
|
+
fs.writeFileSync(path.join(outDir, `${normalizedRoute}.html`), routeHtml, "utf-8");
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export default defineConfig({
|
|
253
|
+
publicDir: "public",
|
|
254
|
+
plugins: [react(), routeSkeletonPlugin()],
|
|
255
|
+
build: {
|
|
256
|
+
rollupOptions: {
|
|
257
|
+
output: {
|
|
258
|
+
manualChunks(id) {
|
|
259
|
+
if (!id.includes("node_modules")) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (id.includes("firebase")) {
|
|
264
|
+
return "firebase";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (id.includes("react") || id.includes("scheduler")) {
|
|
268
|
+
return "react-core";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (id.includes("react-markdown") || id.includes("rehype-raw")) {
|
|
272
|
+
return "markdown";
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (id.includes("react-toastify")) {
|
|
276
|
+
return "toastify";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (id.includes("xp.css")) {
|
|
280
|
+
return "xpcss";
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return "vendor";
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
server: {
|
|
289
|
+
host: DEV_HOST,
|
|
290
|
+
port: DEV_PORT,
|
|
291
|
+
strictPort: true,
|
|
292
|
+
cors: false,
|
|
293
|
+
headers: SECURITY_HEADERS,
|
|
294
|
+
watch: {
|
|
295
|
+
usePolling: true,
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
preview: {
|
|
299
|
+
host: DEV_HOST,
|
|
300
|
+
port: DEV_PORT,
|
|
301
|
+
strictPort: true,
|
|
302
|
+
headers: SECURITY_HEADERS,
|
|
303
|
+
},
|
|
304
|
+
});
|