mirror of
https://github.com/LukeHagar/unicorn-utterances.git
synced 2025-12-06 04:21:55 +00:00
Merge branch 'partial-uwu' into uwu-search-page
# Conflicts: # astro.config.ts # package-lock.json # package.json # src/types/plausible.d.ts # src/utils/debounce.ts
This commit is contained in:
@@ -40,7 +40,7 @@ async function generateEpubHTML(slug: string, content: string) {
|
||||
createRehypePlugins({
|
||||
format: "epub",
|
||||
path: resolve(process.cwd(), `content/blog/${slug}/`),
|
||||
})
|
||||
}),
|
||||
)
|
||||
// Voids: [] is required for epub generation, and causes little/no harm for non-epub usage
|
||||
.use(rehypeStringify, { allowDangerousHtml: true, voids: [] });
|
||||
@@ -55,7 +55,7 @@ type EpubOptions = ConstructorParameters<typeof EPub>[0];
|
||||
async function generateCollectionEPub(
|
||||
collection: RawCollectionInfo & Pick<CollectionInfo, "coverImgMeta">,
|
||||
collectionPosts: ExtendedPostInfo[],
|
||||
fileLocation: string
|
||||
fileLocation: string,
|
||||
) {
|
||||
const authors = collection.authors.map((id) => {
|
||||
return unicorns.find((u) => u.id === id).name;
|
||||
@@ -115,10 +115,10 @@ async function generateCollectionEPub(
|
||||
collectionPosts.map(async (post) => ({
|
||||
title: post.title,
|
||||
data: await generateEpubHTML(post.slug, post.contentMeta),
|
||||
}))
|
||||
})),
|
||||
),
|
||||
} as Partial<EpubOptions> as EpubOptions,
|
||||
fileLocation
|
||||
fileLocation,
|
||||
);
|
||||
|
||||
await epub.render();
|
||||
@@ -128,12 +128,12 @@ const posts = [...getAllExtendedPosts("en")];
|
||||
|
||||
for (const collection of collections) {
|
||||
const collectionPosts = posts.filter(
|
||||
(post) => post.collection === collection.slug
|
||||
(post) => post.collection === collection.slug,
|
||||
);
|
||||
|
||||
generateCollectionEPub(
|
||||
collection,
|
||||
collectionPosts,
|
||||
resolve(process.cwd(), `public/${collection.slug}.epub`)
|
||||
resolve(process.cwd(), `public/${collection.slug}.epub`),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ const createPostIndex = () => {
|
||||
{ name: "description", weight: 1.2 },
|
||||
{ name: "excerpt", weight: 1.2 },
|
||||
],
|
||||
posts
|
||||
posts,
|
||||
).toJSON();
|
||||
};
|
||||
|
||||
@@ -74,7 +74,7 @@ const createCollectionIndex = () => {
|
||||
weight: 1.2,
|
||||
},
|
||||
],
|
||||
collections
|
||||
collections,
|
||||
).toJSON();
|
||||
};
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ const page = await context.newPage();
|
||||
async function renderPostImage(
|
||||
layout: Layout,
|
||||
post: ExtendedPostInfo,
|
||||
path: string
|
||||
path: string,
|
||||
) {
|
||||
const label = `${post.slug} (${layout.name})`;
|
||||
console.time(label);
|
||||
@@ -97,7 +97,7 @@ for (const post of getAllExtendedPosts("en")) {
|
||||
await renderPostImage(
|
||||
twitterPreview,
|
||||
post,
|
||||
resolve(outDir, `.${post.socialImg}`)
|
||||
resolve(outDir, `.${post.socialImg}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import * as React from 'preact';
|
||||
import { readFileAsBase64 } from '../utils';
|
||||
import { ComponentProps, Layout } from '../base';
|
||||
import style from './banner-css';
|
||||
import classnames from 'classnames';
|
||||
import * as React from "preact";
|
||||
import { readFileAsBase64 } from "../utils";
|
||||
import { ComponentProps, Layout } from "../base";
|
||||
import style from "./banner-css";
|
||||
import classnames from "classnames";
|
||||
|
||||
const tagStickers: Record<string, string> = {
|
||||
"default": readFileAsBase64("public/stickers/role_devops.png"),
|
||||
default: readFileAsBase64("public/stickers/role_devops.png"),
|
||||
"html,webdev": readFileAsBase64("public/stickers/html.png"),
|
||||
"vue": readFileAsBase64("public/stickers/vue.png"),
|
||||
vue: readFileAsBase64("public/stickers/vue.png"),
|
||||
"documentation,opinion": readFileAsBase64("public/stickers/role_author.png"),
|
||||
'computer science,bash,javascript': readFileAsBase64("public/stickers/role_developer.png"),
|
||||
"design": readFileAsBase64("public/stickers/role_designer.png"),
|
||||
"rust": readFileAsBase64("public/stickers/ferris.png"),
|
||||
"git": readFileAsBase64("public/stickers/git.png"),
|
||||
"computer science,bash,javascript": readFileAsBase64(
|
||||
"public/stickers/role_developer.png",
|
||||
),
|
||||
design: readFileAsBase64("public/stickers/role_designer.png"),
|
||||
rust: readFileAsBase64("public/stickers/ferris.png"),
|
||||
git: readFileAsBase64("public/stickers/git.png"),
|
||||
};
|
||||
|
||||
function BannerCodeScreen({
|
||||
@@ -20,16 +22,18 @@ function BannerCodeScreen({
|
||||
postHtml,
|
||||
blur,
|
||||
}: {
|
||||
post: ComponentProps['post'],
|
||||
postHtml: string,
|
||||
blur?: boolean,
|
||||
post: ComponentProps["post"];
|
||||
postHtml: string;
|
||||
blur?: boolean;
|
||||
}) {
|
||||
const rotX = (post.description.length % 20) - 10;
|
||||
const rotY = ((post.title.length * 3) % 20);
|
||||
const rotY = (post.title.length * 3) % 20;
|
||||
|
||||
let tagImg = tagStickers["default"];
|
||||
for (const tag of post.tags) {
|
||||
const key = Object.keys(tagStickers).find(k => k.split(",").includes(tag));
|
||||
const key = Object.keys(tagStickers).find((k) =>
|
||||
k.split(",").includes(tag),
|
||||
);
|
||||
if (key) {
|
||||
tagImg = tagStickers[key];
|
||||
break;
|
||||
@@ -38,38 +42,45 @@ function BannerCodeScreen({
|
||||
|
||||
const theme = post.title.length % 3;
|
||||
|
||||
return <>
|
||||
<div class={classnames("absoluteFill", "codeScreenBg", blur && "blur", "theme-" + theme)} style={`--rotX: ${rotX}deg; --rotY: ${rotY}deg; --left: ${rotY}%;`}>
|
||||
<div class="codeScreen">
|
||||
<pre dangerouslySetInnerHTML={{ __html: postHtml }} />
|
||||
<div class="tags">
|
||||
{
|
||||
post.tags.map((tag) => (
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
class={classnames(
|
||||
"absoluteFill",
|
||||
"codeScreenBg",
|
||||
blur && "blur",
|
||||
"theme-" + theme,
|
||||
)}
|
||||
style={`--rotX: ${rotX}deg; --rotY: ${rotY}deg; --left: ${rotY}%;`}
|
||||
>
|
||||
<div class="codeScreen">
|
||||
<pre dangerouslySetInnerHTML={{ __html: postHtml }} />
|
||||
<div class="tags">
|
||||
{post.tags.map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))
|
||||
}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rect" style="--z: 60px; --x: -80px; --y: -150px;">
|
||||
<img src={tagImg} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="rect" style="--z: 60px; --x: -80px; --y: -150px;">
|
||||
<img src={tagImg} />
|
||||
</div>
|
||||
</div>
|
||||
</>;
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Banner({
|
||||
post,
|
||||
postHtml,
|
||||
}: ComponentProps) {
|
||||
return <>
|
||||
<BannerCodeScreen post={post} postHtml={postHtml} />
|
||||
<div
|
||||
className="absoluteFill codeScreenOverlay"
|
||||
style={{
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
</>;
|
||||
function Banner({ post, postHtml }: ComponentProps) {
|
||||
return (
|
||||
<>
|
||||
<BannerCodeScreen post={post} postHtml={postHtml} />
|
||||
<div
|
||||
className="absoluteFill codeScreenOverlay"
|
||||
style={{
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
import * as React from 'preact';
|
||||
import * as React from "preact";
|
||||
import { render } from "@testing-library/preact";
|
||||
import { MockPost } from "__mocks__/data/mock-post";
|
||||
import TwitterLargeCard, { splitSentence } from "./twitter-preview";
|
||||
|
||||
test("Social previews splitSentence", () => {
|
||||
// doesn't split at start/end of short titles
|
||||
expect(splitSentence("Topic: Topic")).toStrictEqual(["Topic: Topic", ""]);
|
||||
// doesn't split at start/end of short titles
|
||||
expect(splitSentence("Topic: Topic")).toStrictEqual(["Topic: Topic", ""]);
|
||||
|
||||
// splits by colon (including the colon char)
|
||||
expect(splitSentence("A Topic: an Attribute")).toStrictEqual([
|
||||
"A Topic",
|
||||
": an Attribute",
|
||||
]);
|
||||
// splits by colon (including the colon char)
|
||||
expect(splitSentence("A Topic: an Attribute")).toStrictEqual([
|
||||
"A Topic",
|
||||
": an Attribute",
|
||||
]);
|
||||
|
||||
// splits by commas
|
||||
expect(
|
||||
splitSentence("An Attribute of Topic, Topic, and Topic")
|
||||
).toStrictEqual(["An Attribute of ", "Topic, Topic, and Topic"]);
|
||||
// splits by commas
|
||||
expect(
|
||||
splitSentence("An Attribute of Topic, Topic, and Topic"),
|
||||
).toStrictEqual(["An Attribute of ", "Topic, Topic, and Topic"]);
|
||||
|
||||
// splits by apostrophe
|
||||
expect(splitSentence("A Topic's Attribute")).toStrictEqual([
|
||||
"A Topic's",
|
||||
" Attribute",
|
||||
]);
|
||||
// splits by apostrophe
|
||||
expect(splitSentence("A Topic's Attribute")).toStrictEqual([
|
||||
"A Topic's",
|
||||
" Attribute",
|
||||
]);
|
||||
|
||||
// splits by apostrophe (plural)
|
||||
expect(splitSentence("Some Topics' Attributes")).toStrictEqual([
|
||||
"Some Topics'",
|
||||
" Attributes",
|
||||
]);
|
||||
// splits by apostrophe (plural)
|
||||
expect(splitSentence("Some Topics' Attributes")).toStrictEqual([
|
||||
"Some Topics'",
|
||||
" Attributes",
|
||||
]);
|
||||
|
||||
// splits by lowercase words
|
||||
expect(splitSentence("An Attribute in a Topic")).toStrictEqual([
|
||||
"An Attribute in ",
|
||||
"a Topic",
|
||||
]);
|
||||
// splits by lowercase words
|
||||
expect(splitSentence("An Attribute in a Topic")).toStrictEqual([
|
||||
"An Attribute in ",
|
||||
"a Topic",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Social preview renders", async () => {
|
||||
const post = MockPost;
|
||||
const { baseElement, findByText } = render(
|
||||
<TwitterLargeCard.Component
|
||||
post={post}
|
||||
postHtml="<code>test();</code>"
|
||||
width={1280}
|
||||
height={640}
|
||||
authorImageMap={{ [post.authors[0]]: "test.jpg" }}
|
||||
/>
|
||||
);
|
||||
const post = MockPost;
|
||||
const { baseElement, findByText } = render(
|
||||
<TwitterLargeCard.Component
|
||||
post={post}
|
||||
postHtml="<code>test();</code>"
|
||||
width={1280}
|
||||
height={640}
|
||||
authorImageMap={{ [post.authors[0]]: "test.jpg" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(baseElement).toBeInTheDocument();
|
||||
expect(await findByText(post.title)).toBeInTheDocument();
|
||||
expect(baseElement).toBeInTheDocument();
|
||||
expect(await findByText(post.title)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,130 +1,134 @@
|
||||
import * as React from 'preact';
|
||||
import { readFileAsBase64 } from '../utils';
|
||||
import { ComponentProps, Layout } from '../base';
|
||||
import style from './twitter-preview-css';
|
||||
import * as React from "preact";
|
||||
import { readFileAsBase64 } from "../utils";
|
||||
import { ComponentProps, Layout } from "../base";
|
||||
import style from "./twitter-preview-css";
|
||||
|
||||
export function splitSentence(str: string): [string, string] {
|
||||
const splitStr = str.split(" ");
|
||||
const splitBy = (
|
||||
regex: RegExp,
|
||||
matchLast: boolean = true
|
||||
): [string, string] | null => {
|
||||
const matches = splitStr.map((word, i) => ({ reg: regex.exec(word), i }));
|
||||
const match = (matchLast ? matches.reverse() : matches)
|
||||
.slice(1, -1)
|
||||
.find(({ reg }) => !!reg);
|
||||
const splitStr = str.split(" ");
|
||||
const splitBy = (
|
||||
regex: RegExp,
|
||||
matchLast: boolean = true,
|
||||
): [string, string] | null => {
|
||||
const matches = splitStr.map((word, i) => ({ reg: regex.exec(word), i }));
|
||||
const match = (matchLast ? matches.reverse() : matches)
|
||||
.slice(1, -1)
|
||||
.find(({ reg }) => !!reg);
|
||||
|
||||
// if match is not found, fail
|
||||
if (!match || !match.reg) return null;
|
||||
// if match is not found, fail
|
||||
if (!match || !match.reg) return null;
|
||||
|
||||
const firstHalf = [
|
||||
...splitStr.slice(0, match.i),
|
||||
match.reg.input.substring(0, match.reg.index),
|
||||
].join(" ");
|
||||
const secondHalf = [match.reg[0], ...splitStr.slice(match.i + 1)].join(" ");
|
||||
return [firstHalf, secondHalf];
|
||||
};
|
||||
const firstHalf = [
|
||||
...splitStr.slice(0, match.i),
|
||||
match.reg.input.substring(0, match.reg.index),
|
||||
].join(" ");
|
||||
const secondHalf = [match.reg[0], ...splitStr.slice(match.i + 1)].join(" ");
|
||||
return [firstHalf, secondHalf];
|
||||
};
|
||||
|
||||
let ret;
|
||||
// try to split by "Topic[: Attribute]" or "Topic [- Attribute]" (hyphens/colons)
|
||||
if ((ret = splitBy(/(?<=^\w+):$|^[-—]$/))) return ret;
|
||||
// try to split by "Attribute in [Topic, Topic, and Topic]" (commas)
|
||||
if ((ret = splitBy(/^\w+,$/, false))) return ret;
|
||||
// try to split by "Topic['s Attribute]" (apostrophe)
|
||||
if ((ret = splitBy(/(?<=^\w+\'s?)$/))) return ret;
|
||||
// try to split by "Attribute [in Topic]" (lowercase words)
|
||||
if ((ret = splitBy(/^[a-z][A-Za-z]*$/))) return ret;
|
||||
// otherwise, don't split the string
|
||||
return [str, ""];
|
||||
let ret;
|
||||
// try to split by "Topic[: Attribute]" or "Topic [- Attribute]" (hyphens/colons)
|
||||
if ((ret = splitBy(/(?<=^\w+):$|^[-—]$/))) return ret;
|
||||
// try to split by "Attribute in [Topic, Topic, and Topic]" (commas)
|
||||
if ((ret = splitBy(/^\w+,$/, false))) return ret;
|
||||
// try to split by "Topic['s Attribute]" (apostrophe)
|
||||
if ((ret = splitBy(/(?<=^\w+\'s?)$/))) return ret;
|
||||
// try to split by "Attribute [in Topic]" (lowercase words)
|
||||
if ((ret = splitBy(/^[a-z][A-Za-z]*$/))) return ret;
|
||||
// otherwise, don't split the string
|
||||
return [str, ""];
|
||||
}
|
||||
|
||||
const unicornUtterancesHead = readFileAsBase64("src/assets/unicorn_head_1024.png");
|
||||
const unicornUtterancesHead = readFileAsBase64(
|
||||
"src/assets/unicorn_head_1024.png",
|
||||
);
|
||||
|
||||
interface TwitterCodeScreenProps {
|
||||
title: string;
|
||||
html: string;
|
||||
blur: boolean;
|
||||
title: string;
|
||||
html: string;
|
||||
blur: boolean;
|
||||
}
|
||||
|
||||
const TwitterCodeScreen = ({ title, html, blur }: TwitterCodeScreenProps) => {
|
||||
const rotations = [
|
||||
"rotateX(-17deg) rotateY(32deg) rotateZ(-3deg) translate(16%, 0%)",
|
||||
"rotateX(5deg) rotateY(35deg) rotateZ(345deg) translate(18%, 0)",
|
||||
"rotateX(15deg) rotateY(25deg) rotateZ(12deg) translate(3%, -15%)",
|
||||
];
|
||||
const rotations = [
|
||||
"rotateX(-17deg) rotateY(32deg) rotateZ(-3deg) translate(16%, 0%)",
|
||||
"rotateX(5deg) rotateY(35deg) rotateZ(345deg) translate(18%, 0)",
|
||||
"rotateX(15deg) rotateY(25deg) rotateZ(12deg) translate(3%, -15%)",
|
||||
];
|
||||
|
||||
// use second char of title as "deterministic" random value
|
||||
const transform = rotations[title.charCodeAt(1) % rotations.length];
|
||||
// use second char of title as "deterministic" random value
|
||||
const transform = rotations[title.charCodeAt(1) % rotations.length];
|
||||
|
||||
return (
|
||||
<div className={`absoluteFill codeScreenBg ${blur ? "blur" : ""}`}>
|
||||
<div
|
||||
className="absoluteFill codeScreen"
|
||||
style={`transform: ${transform};`}
|
||||
>
|
||||
<div className="absoluteFill">
|
||||
<pre dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={`absoluteFill codeScreenBg ${blur ? "blur" : ""}`}>
|
||||
<div
|
||||
className="absoluteFill codeScreen"
|
||||
style={`transform: ${transform};`}
|
||||
>
|
||||
<div className="absoluteFill">
|
||||
<pre dangerouslySetInnerHTML={{ __html: html }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const TwitterLargeCard = ({
|
||||
post,
|
||||
postHtml,
|
||||
width,
|
||||
authorImageMap,
|
||||
post,
|
||||
postHtml,
|
||||
width,
|
||||
authorImageMap,
|
||||
}: ComponentProps) => {
|
||||
const title = post.title;
|
||||
const [firstHalfTitle, secondHalfTitle] = splitSentence(title);
|
||||
const title = post.title;
|
||||
const [firstHalfTitle, secondHalfTitle] = splitSentence(title);
|
||||
|
||||
return <>
|
||||
<TwitterCodeScreen title={post.title} html={postHtml} blur={true} />
|
||||
<TwitterCodeScreen title={post.title} html={postHtml} blur={false} />
|
||||
<div className="absoluteFill codeScreenOverlay" />
|
||||
<div className="absoluteFill centerAll">
|
||||
<h1
|
||||
style={{
|
||||
maxWidth: "90%",
|
||||
textAlign: "center",
|
||||
fontSize: `clamp(300%, 4.5rem, ${
|
||||
Math.round(width / title.length) * 3
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
{firstHalfTitle}
|
||||
<span className="secondHalfTitle">{secondHalfTitle}</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div
|
||||
className="absoluteFill backgroundColor"
|
||||
style={{
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
<div className="bottomContainer">
|
||||
<div className="bottomImagesContainer centerAll">
|
||||
{post.authors.map((author) => (
|
||||
<img
|
||||
key={author}
|
||||
src={authorImageMap[author]}
|
||||
alt=""
|
||||
className="bottomProfImg"
|
||||
height={80}
|
||||
width={80}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="bottomImagesContainer centerAll">
|
||||
<p>unicorn-utterances.com</p>
|
||||
<img src={unicornUtterancesHead} alt="" height={80} width={80} />
|
||||
</div>
|
||||
</div>
|
||||
</>;
|
||||
return (
|
||||
<>
|
||||
<TwitterCodeScreen title={post.title} html={postHtml} blur={true} />
|
||||
<TwitterCodeScreen title={post.title} html={postHtml} blur={false} />
|
||||
<div className="absoluteFill codeScreenOverlay" />
|
||||
<div className="absoluteFill centerAll">
|
||||
<h1
|
||||
style={{
|
||||
maxWidth: "90%",
|
||||
textAlign: "center",
|
||||
fontSize: `clamp(300%, 4.5rem, ${
|
||||
Math.round(width / title.length) * 3
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
{firstHalfTitle}
|
||||
<span className="secondHalfTitle">{secondHalfTitle}</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div
|
||||
className="absoluteFill backgroundColor"
|
||||
style={{
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
<div className="bottomContainer">
|
||||
<div className="bottomImagesContainer centerAll">
|
||||
{post.authors.map((author) => (
|
||||
<img
|
||||
key={author}
|
||||
src={authorImageMap[author]}
|
||||
alt=""
|
||||
className="bottomProfImg"
|
||||
height={80}
|
||||
width={80}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="bottomImagesContainer centerAll">
|
||||
<p>unicorn-utterances.com</p>
|
||||
<img src={unicornUtterancesHead} alt="" height={80} width={80} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "twitter-preview",
|
||||
css: style,
|
||||
Component: TwitterLargeCard,
|
||||
name: "twitter-preview",
|
||||
css: style,
|
||||
Component: TwitterLargeCard,
|
||||
} as Layout;
|
||||
|
||||
Reference in New Issue
Block a user