add wip html parser

This commit is contained in:
sam 2023-12-20 22:26:25 +01:00
parent 364e6a1e2e
commit 882c2471e8
16 changed files with 1082 additions and 1257 deletions

View file

@ -13,10 +13,13 @@
"format": "prettier --write ." "format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@fontsource/fira-sans": "^5.0.18",
"@tabler/icons-vue": "^2.44.0", "@tabler/icons-vue": "^2.44.0",
"axios": "^1.6.2", "axios": "^1.6.2",
"domhandler": "^5.0.3",
"flowbite": "^2.2.1", "flowbite": "^2.2.1",
"flowbite-vue": "^0.1.2", "flowbite-vue": "^0.1.2",
"htmlparser2": "^9.0.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"sass": "^1.69.5", "sass": "^1.69.5",
"swrv": "^1.0.4", "swrv": "^1.0.4",
@ -30,6 +33,7 @@
"@tsconfig/node18": "^18.2.2", "@tsconfig/node18": "^18.2.2",
"@types/node": "^18.19.3", "@types/node": "^18.19.3",
"@vitejs/plugin-vue": "^4.5.2", "@vitejs/plugin-vue": "^4.5.2",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"@vue/eslint-config-prettier": "^8.0.0", "@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^12.0.0", "@vue/eslint-config-typescript": "^12.0.0",
"@vue/tsconfig": "^0.5.0", "@vue/tsconfig": "^0.5.0",

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import "@fontsource/fira-sans/400.css";
import "@fontsource/fira-sans/700.css";
import { RouterView } from "vue-router"; import { RouterView } from "vue-router";
</script> </script>

View file

@ -0,0 +1,42 @@
import * as htmlparser2 from "htmlparser2";
import type { Mention, Tag } from "@/lib/api/entities/activity";
import type { CustomEmoji } from "@/lib/api/entities/custom_emoji";
import { Text, type ChildNode, Element } from "domhandler";
interface Props {
content: string;
emoji: CustomEmoji[];
tags?: Tag[];
mentions?: Mention[];
}
const parser = new DOMParser();
export default function HTMLContent(props: Props) {
let { content } = props;
// emoji are always in their shortcode form
props.emoji.forEach((emoji) => {
content = content.replace(new RegExp(`:${emoji.shortcode}:`, "g"), customEmojiElement(emoji));
});
const dom = htmlparser2.parseDocument(content);
const elements = parseElement(dom.children);
console.log(dom);
return elements;
}
const customEmojiElement = (emoji: CustomEmoji) => {
const elem = document.createElement("x-emoji");
elem.setAttribute("emoji", emoji.shortcode);
return elem.outerHTML;
};
function parseElement(elems: ChildNode[]) {
const out = [];
for (const elem of elems) {
if (elem instanceof Text) out.push(<>{elem.data}</>);
}
}

View file

@ -0,0 +1,9 @@
<script setup lang="ts">
import type { CustomEmoji } from "@/lib/api/entities/custom_emoji";
defineProps<{ emoji: CustomEmoji }>();
</script>
<template>
<img class="inline-emoji" :src="emoji.url" :alt="emoji.shortcode" />
</template>

View file

@ -1,71 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from "vue-i18n";
import type Activity from "@/lib/api/entities/activity"; import type Activity from "@/lib/api/entities/activity";
import { IconWorld, IconHome, IconLock, IconMail } from "@tabler/icons-vue"; import VulStatusInfo from "./VulStatusInfo.vue";
import VulStatusContent from "./VulStatusContent.vue";
import VulStatusButtons from "./VulStatusButtons.vue";
const { t } = useI18n();
defineProps<{ status: Activity }>(); defineProps<{ status: Activity }>();
const statusScopeKey = ({ visibility }: Activity) => {
switch (visibility) {
case "direct":
return "status.visibility.directMessage";
case "private":
return "status.visibility.followersOnly";
case "unlisted":
return "status.visibility.unlisted";
case "public":
return "status.visibility.public";
}
};
const statusScopeIcon = ({ visibility }: Activity) => {
switch (visibility) {
case "direct":
return IconMail
case "private":
return IconLock
case "unlisted":
return IconHome
case "public":
return IconWorld
}
}
</script> </script>
<template> <template>
<div class="flex flex-row"> <div class="flex flex-col m-2">
<div class="flex flex-row flex-grow"> <VulStatusInfo :status="status" />
<RouterLink :to="`/@${status.account.acct}`"> <VulStatusContent :status="status" />
<img <VulStatusButtons :status="status" />
class="rounded-full m-2"
:src="status.account.avatar"
width="64"
:alt="t('status.avatar', { name: status.account.acct })"
:title="t('status.avatar', { name: status.account.acct })"
/>
</RouterLink>
<div class="flex flex-col my-1">
<h2 className="text-lg font-bold">
<RouterLink :to="`/@${status.account.acct}`">
{{ status.account.display_name }}
</RouterLink>
</h2>
<h3 className="text-base font-light">
<RouterLink :to="`/@${status.account.acct}`"> @{{ status.account.acct }} </RouterLink>
</h3>
</div>
</div>
<div class="my-1 flex flex-row">
<span>
<span :title="t(statusScopeKey(status))" aria-hidden="true">
<component :is="statusScopeIcon(status)" />
</span>
<span class="sr-only">{{ t(statusScopeKey(status)) }}</span>
</span>
<RouterLink :to="`/@${status.account.acct}/statuses/${status.id}`">
{humanizeDuration(time)}
</RouterLink>
</div>
</div> </div>
</template> </template>

View file

@ -0,0 +1,34 @@
<script setup lang="ts">
import { IconDotsVertical, IconMessage, IconPlus, IconQuote, IconRepeat, IconStar } from "@tabler/icons-vue";
import type Activity from "@/lib/api/entities/activity";
defineProps<{ status: Activity }>();
</script>
<template>
<div class="flex">
<ul class="flex space-x-4">
<li>
<span v-if="status.replies_count">{{ status.replies_count }}</span>
<IconMessage class="inline" />
</li>
<li>
<span v-if="status.reblogs_count">{{ status.reblogs_count }}</span>
<IconRepeat class="inline" />
</li>
<li>
<IconQuote class="inline" />
</li>
<li>
<span v-if="status.favourites_count">{{ status.favourites_count }}</span>
<IconStar class="inline" />
</li>
<li>
<IconPlus class="inline" />
</li>
<li>
<IconDotsVertical class="inline" />
</li>
</ul>
</div>
</template>

View file

@ -0,0 +1,36 @@
<script setup lang="ts">
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import type Activity from "@/lib/api/entities/activity";
import { FwbButton } from "flowbite-vue";
import HTMLContent from "../HTMLContent";
const { status } = defineProps<{ status: Activity }>();
const { t } = useI18n();
const isCollapsed = ref(!!status.spoiler_text);
</script>
<template>
<div class="flex flex-col">
<div v-if="status.spoiler_text">
<strong v-html="status.spoiler_text"></strong>
<FwbButton class="mx-2" color="alternative" size="sm" @click="() => (isCollapsed = !isCollapsed)">
{{
isCollapsed
? t("status.showContent", { count: status.content?.length || 0 })
: t("status.hideContent", { count: status.content?.length || 0 })
}}
</FwbButton>
</div>
<div v-if="!isCollapsed" v-html="status.content || 'N/A'"></div>
<HTMLContent
v-if="!isCollapsed && status.content"
:content="status.content"
:emoji="status.emojis"
:tags="status.tags"
:mentions="status.mentions"
/>
</div>
</template>

View file

@ -0,0 +1,69 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import type Activity from "@/lib/api/entities/activity";
import { IconWorld, IconHome, IconLock, IconMail } from "@tabler/icons-vue";
const { t } = useI18n();
defineProps<{ status: Activity }>();
const statusScopeKey = ({ visibility }: Activity) => {
switch (visibility) {
case "direct":
return "status.visibility.directMessage";
case "private":
return "status.visibility.followersOnly";
case "unlisted":
return "status.visibility.unlisted";
case "public":
return "status.visibility.public";
}
};
const statusScopeIcon = ({ visibility }: Activity) => {
switch (visibility) {
case "direct":
return IconMail;
case "private":
return IconLock;
case "unlisted":
return IconHome;
case "public":
return IconWorld;
}
};
</script>
<template>
<div class="flex flex-row">
<div class="flex flex-row flex-grow">
<RouterLink :to="`/@${status.account.acct}`">
<img
class="rounded-full m-2"
:src="status.account.avatar"
width="64"
:alt="t('status.avatar', { name: status.account.acct })"
:title="t('status.avatar', { name: status.account.acct })"
/>
</RouterLink>
<div class="flex flex-col my-1">
<h2 className="text-lg font-bold">
<RouterLink :to="`/@${status.account.acct}`">
{{ status.account.display_name }}
</RouterLink>
</h2>
<h3 className="text-base font-light">
<RouterLink :to="`/@${status.account.acct}`"> @{{ status.account.acct }} </RouterLink>
</h3>
</div>
</div>
<div class="my-1 flex flex-row">
<span>
<span :title="t(statusScopeKey(status))" aria-hidden="true">
<component :is="statusScopeIcon(status)" />
</span>
<span class="sr-only">{{ t(statusScopeKey(status)) }}</span>
</span>
<RouterLink :to="`/@${status.account.acct}/statuses/${status.id}`"> {humanizeDuration(time)} </RouterLink>
</div>
</div>
</template>

View file

@ -1,4 +1,5 @@
import type { Account } from "./account"; import type { Account } from "./account";
import type { CustomEmoji } from "./custom_emoji";
export default interface Activity { export default interface Activity {
id: string; id: string;
@ -9,6 +10,9 @@ export default interface Activity {
content: string | null; content: string | null;
spoiler_text: string | null; spoiler_text: string | null;
visibility: "public" | "unlisted" | "private" | "direct"; visibility: "public" | "unlisted" | "private" | "direct";
mentions: Mention[];
tags: Tag[];
emojis: CustomEmoji[];
reblogs_count: number; reblogs_count: number;
favourites_count: number; favourites_count: number;
@ -23,3 +27,15 @@ export interface ActivityAkkomaSource {
content: string; content: string;
mediaType: string; mediaType: string;
} }
export interface Mention {
id: string;
username: string;
url: string;
acct: string;
}
export interface Tag {
name: string;
url: string;
}

View file

@ -1,8 +1,8 @@
status: status:
avatar: "{name}'s mugshot" avatar: "{name}'s mugshot"
characterCount: "{count} character | {count} characters" characterCount: "{count} character | {count} characters"
showContent: "Show treasure ({@:status.characterCount})" showContent: "Show treasure (@:status.characterCount)"
hideContent: "Hide treasure ({@:status.characterCount})" hideContent: "Hide treasure (@:status.characterCount)"
visibility: visibility:
directMessage: Direct bottle directMessage: Direct bottle
followersOnly: Only shown to their mateys followersOnly: Only shown to their mateys

View file

@ -1,8 +1,8 @@
status: status:
avatar: "{name}'s avatar" avatar: "{name}'s avatar"
characterCount: "{count} character | {count} characters" characterCount: "{count} character | {count} characters"
showContent: "Show content ({@:status.characterCount})" showContent: "Show content (@:status.characterCount)"
hideContent: "Hide content ({@:status.characterCount})" hideContent: "Hide content (@:status.characterCount)"
visibility: visibility:
directMessage: Direct message directMessage: Direct message
followersOnly: Followers only followersOnly: Followers only

View file

@ -1,3 +1,5 @@
import { theme } from "tailwindcss/defaultConfig";
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
darkMode: "class", darkMode: "class",
@ -8,7 +10,11 @@ export default {
"node_modules/flowbite/**/*.{js,jsx,ts,tsx}", "node_modules/flowbite/**/*.{js,jsx,ts,tsx}",
], ],
theme: { theme: {
extend: {}, extend: {
fontFamily: {
sans: ['"Fira Sans"', ...theme.fontFamily.sans],
},
},
}, },
plugins: [import("flowbite/plugin")], plugins: [import("flowbite/plugin")],
}; };

View file

@ -5,6 +5,7 @@
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"noEmit": true, "noEmit": true,
"jsxImportSource": "vue",
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]

View file

@ -1,12 +1,6 @@
{ {
"extends": "@tsconfig/node18/tsconfig.json", "extends": "@tsconfig/node18/tsconfig.json",
"include": [ "include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "nightwatch.conf.*", "playwright.config.*"],
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"noEmit": true, "noEmit": true,

View file

@ -2,6 +2,7 @@ import { fileURLToPath, URL } from "node:url";
import { loadEnv, defineConfig } from "vite"; import { loadEnv, defineConfig } from "vite";
import vue from "@vitejs/plugin-vue"; import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";
import yaml from "@modyfi/vite-plugin-yaml"; import yaml from "@modyfi/vite-plugin-yaml";
// https://vitejs.dev/config/ // https://vitejs.dev/config/
@ -9,7 +10,7 @@ export default defineConfig(({ mode }) => {
const { INSTANCE } = loadEnv(mode, process.cwd(), ""); const { INSTANCE } = loadEnv(mode, process.cwd(), "");
return { return {
plugins: [vue(), yaml()], plugins: [vue(), vueJsx(), yaml()],
resolve: { resolve: {
alias: { alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)), "@": fileURLToPath(new URL("./src", import.meta.url)),