Compare commits

..

2 Commits

Author SHA1 Message Date
Valentin Popov 118112f0b7 refactor: simplify Analytics component and update BaseLayout
- Removed props from the Analytics component to streamline its usage.
- Updated BaseLayout to call Analytics without passing the title prop, ensuring default values are used.
2025-06-10 20:09:34 +00:00
Valentin Popov 47a0acab13 style: enhance Header component and update SCSS imports
- Improved the Header component by adding a site title with styling.
- Wrapped navigation links in a div for better structure.
- Updated SCSS imports across multiple components for consistency.
2025-06-10 18:36:35 +00:00
90 changed files with 2191 additions and 3657 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"image": "mcr.microsoft.com/devcontainers/javascript-node:24",
"image": "mcr.microsoft.com/devcontainers/javascript-node:22",
"forwardPorts": [4321],
"portsAttributes": {
"4321": {
+2
View File
@@ -0,0 +1,2 @@
DEFAULT_TITLE=Valentin Popovs Blog
DEFAULT_DESCRIPTION=Tech insights and coding best practices from an OpenSource enthusiast and ethical hacker.
-28
View File
@@ -1,28 +0,0 @@
name: RenovateBot
on:
schedule:
- cron: "@daily"
push:
branches:
- master
jobs:
renovate:
container: ghcr.io/renovatebot/renovate:43
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Run renovate
run: |
renovate
env:
GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_TOKEN }}
LOG_LEVEL: ${{ vars.RENOVATE_LOG_LEVEL }}
RENOVATE_CONFIG_FILE: renovate.config.cjs
RENOVATE_LOG_LEVEL: ${{ vars.RENOVATE_LOG_LEVEL }}
RENOVATE_REPOSITORIES: ${{ gitea.repository }}
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
-16
View File
@@ -1,16 +0,0 @@
name: Test
on: [push, pull_request]
jobs:
test:
name: npm test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
- run: npm ci
- run: npm run check
- run: npm run typecheck
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", ":disableDependencyDashboard"],
"assignees": ["valentineus"],
"labels": ["dependencies", "automated"],
"packageRules": [
{
"description": "Group patch & minor updates together",
"groupName": "all digest updates",
"groupSlug": "all-digest",
"matchUpdateTypes": ["minor", "patch", "pin", "digest"],
"matchPackageNames": ["*"],
"automerge": true
}
]
}
+26
View File
@@ -0,0 +1,26 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
permissions:
contents: read
jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
run: npm ci
- name: Run checks
run: npm run check
+1 -1
View File
@@ -30,7 +30,7 @@ export default {
},
],
plugins: ["prettier-plugin-astro"],
printWidth: 256,
printWidth: 120,
proseWrap: "never",
quoteProps: "consistent",
requirePragma: false,
-30
View File
@@ -1,30 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":disableDependencyDashboard"
],
"assignees": [
"valentineus"
],
"labels": [
"dependencies",
"automated"
],
"packageRules": [
{
"groupName": "all digest updates",
"groupSlug": "all-digest",
"matchUpdateTypes": [
"minor",
"patch",
"pin",
"digest"
],
"matchPackageNames": [
"*"
],
"automerge": true
}
]
}
+3 -3
View File
@@ -1,6 +1,6 @@
# popov.link
[![512KB club](https://512kb.club/assets/images/green-team.svg)](https://512kb.club)
[![CI](https://github.com/valentineus/popov.link/actions/workflows/ci.yml/badge.svg)](https://github.com/valentineus/popov.link/actions/workflows/ci.yml)
Personal website source code built with [Astro](https://astro.build/).
@@ -41,9 +41,9 @@ npm run preview
## Project Info
- Issues: [GitHub](https://github.com/valentineus/popov.link/issues)
- Read-only mirror: [git.popov.link](https://git.popov.link/popov.link/)
- Maintained by [Valentin Popov](mailto:valentin@popov.link)
- Issues: [GitHub](https://github.com/valentineus/popov.link/issues)
- Read-only mirror: [code.popov.link](https://code.popov.link/valentineus/popov.link)
## Comments
+2 -40
View File
@@ -1,54 +1,16 @@
import { defineConfig } from "astro/config";
import { globbySync } from "globby";
import fs from "node:fs";
import matter from "gray-matter";
import path from "node:path";
import rehypeExternalLinks from "rehype-external-links";
import sitemap from "@astrojs/sitemap";
import { remarkReadingTime } from "./src/plugins/remarkReadingTime";
import ogImages from "./src/integrations/ogImages";
import rehypeLazyImages from "./src/plugins/rehypeLazyImages";
const blogDir = path.resolve("./src/content/blog");
const lastmodBySlug = Object.fromEntries(
globbySync("*.md", { cwd: blogDir }).map((file) => {
const slug = file.replace(/\.md$/, "");
const { data } = matter(fs.readFileSync(path.join(blogDir, file), "utf8"));
const date = data.dateModified ?? data.datePublished;
return [slug, new Date(date).toISOString()];
})
);
const buildLastmod = new Date().toISOString();
import sitemap from "@astrojs/sitemap";
export default defineConfig({
site: "https://popov.link",
output: "static",
integrations: [
sitemap({
serialize(item) {
const url = new URL(item.url);
const match = url.pathname.match(/^\/blog\/([^/]+)\/?$/);
if (match && lastmodBySlug[match[1]]) {
item.lastmod = lastmodBySlug[match[1]];
} else {
item.lastmod = buildLastmod;
}
return item;
},
}),
ogImages(),
],
integrations: [sitemap()],
build: {
inlineStylesheets: "always",
},
markdown: {
remarkPlugins: [remarkReadingTime],
rehypePlugins: [[rehypeExternalLinks, { target: "_blank", rel: ["noopener", "noreferrer"] }], rehypeLazyImages],
shikiConfig: {
theme: "vitesse-dark",
},
+1930 -2673
View File
File diff suppressed because it is too large Load Diff
+7 -21
View File
@@ -3,15 +3,14 @@
"type": "module",
"version": "2025.01.24",
"private": true,
"license": "MIT",
"packageManager": "npm@11.15.0",
"packageManager": "npm@11.4.1",
"browserslist": [
">0.2%",
"not dead"
"not dead",
"IE 11"
],
"scripts": {
"format": "prettier --write .",
"typecheck": "tsc --noEmit",
"dev": "astro dev",
"start": "astro dev",
"check": "astro check",
@@ -23,30 +22,17 @@
"@astrojs/check": "^0.9.4",
"@astrojs/rss": "^4.0.12",
"@astrojs/sitemap": "^3.4.1",
"@resvg/resvg-js": "^2.6.2",
"astro": "^6.0.0",
"astro": "^5.9.0",
"autoprefixer": "^10.4.21",
"cssnano": "^8.0.0",
"cssnano-preset-advanced": "^8.0.0",
"cssnano": "^7.0.7",
"cssnano-preset-advanced": "^7.0.7",
"dayjs": "^1.11.13",
"globby": "^16.0.0",
"gray-matter": "^4.0.3",
"markdown-it": "^14.1.1",
"mdast-util-to-string": "^4.0.0",
"reading-time": "^1.5.0",
"rehype-external-links": "^3.0.0",
"sanitize-html": "^2.17.3",
"sass": "^1.89.1",
"satori": "^0.26.0",
"satori-html": "^0.3.2",
"schema-dts": "^2.0.0",
"sharp": "^0.34.2",
"typescript": "^5",
"unist-util-visit": "^5.1.0"
"typescript": "^5"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@types/sanitize-html": "^2.16.1",
"prettier": "^3.5.3",
"prettier-plugin-astro": "^0.14.1"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 KiB

-2
View File
@@ -1,2 +0,0 @@
*
!.gitignore
+3 -3
View File
@@ -18,7 +18,7 @@
"type": "image/png"
}
],
"background_color": "#181818",
"theme_color": "#181818",
"display": "standalone"
"background_color": "#ffffff",
"theme_color": "#ffffff",
"display": "fullscreen"
}
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
endpoint: "https://code.popov.link",
gitAuthor: "renovate[bot] <renovatebot@noreply.localhost>",
optimizeForDisabled: true,
platform: "gitea",
};
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2 -1
View File
@@ -1 +1,2 @@
<script is:inline defer src="https://appmetrix.com/pixel/T5X0z12SoASBV8Dv"></script>
<!-- AppMetrix -->
<script is:inline src="https://appmetrix.com/pixel/T5X0z12SoASBV8Dv"></script>
-1
View File
@@ -15,7 +15,6 @@ const theme = "transparent_dark";
<script
is:inline
defer
src="https://giscus.app/client.js"
data-category-id={categoryId}
data-category={category}
+8 -56
View File
@@ -1,78 +1,30 @@
---
import type { Thing } from "schema-dts";
import { config } from "../config";
import JsonLd from "./JsonLd.astro";
type Props = {
readonly description: string;
readonly lang: string;
readonly modifiedTime?: string;
readonly ogType?: "website" | "article";
readonly preview: string;
readonly publishedTime?: string;
readonly robots?: string;
readonly schema: Thing[];
readonly title: string;
};
const { description, lang, modifiedTime, ogType = "website", preview, publishedTime, robots = "index, follow", schema, title } = Astro.props;
const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
const previewUrl = new URL(preview, Astro.site);
const ogLocale = lang === "ru" ? "ru_RU" : "en_US";
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const { description, title } = Astro.props;
---
<head>
<!-- Meta Tags -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<meta name="description" content={description} />
<meta name="robots" content={robots} />
<meta name="author" content={config.author.name} />
<meta name="robots" content="index, follow" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link href="/feed.xml" rel="alternate" title="RSS" type="application/atom+xml" />
<link href="/sitemap-index.xml" rel="sitemap" />
<link href={canonicalUrl} rel="canonical" />
<link href={config.author.url} rel="author" />
<!-- hreflang -->
<link rel="alternate" hreflang={lang} href={canonicalUrl} />
<link rel="alternate" hreflang="x-default" href={canonicalUrl} />
<link href={canonicalURL} rel="canonical" />
<title>{title}</title>
<!-- Icons -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#181818" />
<!-- Open Graph -->
<meta property="og:type" content={ogType} />
<meta property="og:site_name" content={config.og.website} />
<meta property="og:locale" content={ogLocale} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:image" content={previewUrl} />
<meta property="og:image:width" content={String(config.og.dimensions.width)} />
<meta property="og:image:height" content={String(config.og.dimensions.height)} />
<meta property="og:image:alt" content={title} />
{ogType === "article" && publishedTime && <meta property="article:published_time" content={publishedTime} />}
{ogType === "article" && modifiedTime && <meta property="article:modified_time" content={modifiedTime} />}
{ogType === "article" && <meta property="article:author" content={config.author.url} />}
<!-- Twitter Cards -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@valyaha" />
<meta name="twitter:creator" content="@valyaha" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={previewUrl} />
<meta name="twitter:image:alt" content={title} />
<JsonLd schema={schema} />
<meta name="theme-color" content="#ffffff" />
</head>
+26 -4
View File
@@ -1,6 +1,27 @@
<style lang="scss">
@use "../scss/variables" as *;
header {
padding-bottom: 1rem;
position: relative;
}
.site-title {
color: $colorText;
font-weight: bold;
left: 0;
position: absolute;
text-decoration: none;
top: 0;
}
.nav-links {
text-align: right;
}
a {
margin-right: 1.5rem;
text-decoration: none;
&:last-child {
margin-right: 0;
@@ -9,8 +30,9 @@
</style>
<header>
<nav aria-label="Navigation">
<a href="/" lang="en">Home</a>
<a href="/blog/" lang="en">Blog</a>
</nav>
<a class="site-title" href="/">{import.meta.env.DEFAULT_TITLE}</a>
<div class="nav-links">
<a href="/">Home</a>
<a href="/blog/">Blog</a>
</div>
</header>
-20
View File
@@ -1,20 +0,0 @@
<style lang="scss">
@use "../../scss/variables" as *;
a {
color: $colorText;
display: inline-block;
margin: 0 0.5rem;
}
svg {
vertical-align: middle;
}
</style>
<a href="mailto:valentin@popov.link" title="E-Mail" rel="noopener" target="_blank">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-label="E-Mail" aria-hidden="true">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
</a>
-22
View File
@@ -1,22 +0,0 @@
<style lang="scss">
@use "../../scss/variables" as *;
a {
color: $colorText;
display: inline-block;
margin: 0 0.5rem;
}
svg {
vertical-align: middle;
}
</style>
<a href="https://github.com/valentineus" title="GitHub" rel="noopener" target="_blank">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-label="GitHub" aria-hidden="true">
<path
d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"
>
</path>
</svg>
</a>
-21
View File
@@ -1,21 +0,0 @@
<style lang="scss">
@use "../../scss/variables" as *;
a {
color: $colorText;
display: inline-block;
margin: 0 0.5rem;
}
svg {
vertical-align: middle;
}
</style>
<a href="https://www.linkedin.com/in/valentineus/" title="LinkedIn" rel="noopener" target="_blank">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-label="LinkedIn" aria-hidden="true">
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"></path>
<rect x="2" y="9" width="4" height="12"></rect>
<circle cx="4" cy="4" r="2"></circle>
</svg>
</a>
-17
View File
@@ -1,17 +0,0 @@
<style lang="scss">
a {
display: inline-block;
}
svg {
vertical-align: middle;
}
</style>
<a href="/feed.xml" title="RSS Feed" rel="noopener" target="_blank">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-label="RSS Feed" aria-hidden="true">
<path d="M4 11a9 9 0 0 1 9 9"></path>
<path d="M4 4a16 16 0 0 1 16 16"></path>
<circle cx="5" cy="19" r="1"></circle>
</svg>
</a>
-19
View File
@@ -1,19 +0,0 @@
---
import type { Thing } from "schema-dts";
type Props = {
readonly schema: Thing[];
};
const { schema } = Astro.props;
const payload = {
"@context": "https://schema.org",
"@graph": schema,
};
const json = JSON.stringify(payload);
---
<!-- JSON-LD -->
<script is:inline type="application/ld+json" set:html={json} />
+11 -19
View File
@@ -1,5 +1,5 @@
---
import { type CollectionEntry, render } from "astro:content";
import { type CollectionEntry } from "astro:content";
import dayjs from "dayjs";
type Props = {
@@ -7,19 +7,13 @@ type Props = {
};
const { post } = Astro.props;
const { remarkPluginFrontmatter } = await render(post);
const formattedDate = dayjs(post.data.datePublished.toString()).format("MMMM DD, YYYY");
const datePublished = post.data.datePublished.toISOString();
const { remarkPluginFrontmatter } = await post.render();
const formattedDate = dayjs(post.data.pubDate.toString()).format("MMMM DD, YYYY");
---
<style lang="scss">
@use "../scss/variables" as *;
a {
color: $colorText;
}
small {
font-size: $fontSizeBase * 0.75;
opacity: 0.5;
@@ -27,14 +21,12 @@ const datePublished = post.data.datePublished.toISOString();
</style>
<li>
<article>
<a href={`/blog/${post.id}`} lang={post.data.lang}>{post.data.title}</a>
<div>
<small>
<time datetime={datePublished} lang={post.data.lang}>{formattedDate}</time>
<span></span>
<span>{remarkPluginFrontmatter.minutesRead}</span>
</small>
</div>
</article>
<a href={`/blog/${post.slug}`}>{post.data.title}</a>
<div>
<small>
<time datetime={post.data.pubDate.toISOString()}>{formattedDate}</time>
<span>•</span>
<span>{remarkPluginFrontmatter.minutesRead}</span>
</small>
</div>
</li>
+49
View File
@@ -0,0 +1,49 @@
---
import { type CollectionEntry } from "astro:content";
import dayjs from "dayjs";
type Props = {
readonly post: CollectionEntry<"blog">;
};
const { post } = Astro.props;
const { remarkPluginFrontmatter } = await post.render();
const formattedDate = dayjs(post.data.pubDate.toString()).format("MMMM DD, YYYY");
---
<style lang="scss">
@use "../scss/variables" as *;
a {
color: $colorText;
display: block;
padding-bottom: 3rem;
&:visited {
color: $colorText;
}
}
h2 {
color: $colorBlossom;
font-size: 1.25em;
margin: 0.5em 0;
}
div {
font-size: $fontSizeBase * 0.75;
opacity: 0.5;
}
</style>
<a href={`/blog/${post.slug}`}>
<article>
<div>
<time datetime={post.data.pubDate.toISOString()}>{formattedDate}</time>
<span>•</span>
<span>{remarkPluginFrontmatter.minutesRead}</span>
</div>
<h2>{post.data.title}</h2>
<p>{post.data.description}</p>
</article>
</a>
-43
View File
@@ -1,43 +0,0 @@
---
import { getCollection } from "astro:content";
import dayjs from "dayjs";
import RSSIcon from "../Icons/RSS.astro";
const posts = await getCollection("blog", ({ data }) => {
return data.draft !== true;
});
posts.sort((a, b) => b.data.datePublished.getTime() - a.data.datePublished.getTime());
const latestPosts = posts.slice(0, 5);
---
<style lang="scss">
@use "../../scss/variables" as *;
small {
font-size: $fontSizeBase * 0.75;
opacity: 0.5;
}
</style>
<section>
<h2>Latest posts <RSSIcon /></h2>
<ul>
{
latestPosts.map((post) => (
<li>
<a href={`/blog/${post.id}`} lang={post.data.lang}>
{post.data.title}
</a>
<small>
<time datetime={post.data.datePublished.toISOString()} lang={post.data.lang}>
{dayjs(post.data.datePublished.toString()).format("MMMM DD, YYYY")}
</time>
</small>
</li>
))
}
</ul>
</section>
-19
View File
@@ -1,19 +0,0 @@
---
import GitHubIcon from "../Icons/GitHub.astro";
import LinkedInIcon from "../Icons/LinkedIn.astro";
import EmailIcon from "../Icons/Email.astro";
---
<style lang="scss">
div {
margin-bottom: 2rem;
}
</style>
<section>
<div>
<GitHubIcon />
<LinkedInIcon />
<EmailIcon />
</div>
</section>
-7
View File
@@ -1,7 +0,0 @@
<section>
<div>
<h1>Hi, I'm Valentin 👋</h1>
<p>I'm a professional software developer currently working as a project manager and team lead. On my personal website, I share thoughts on tech, leadership, and digital life.</p>
<p>Welcome, and feel free to explore!</p>
</div>
</section>
-29
View File
@@ -1,29 +0,0 @@
export const config = {
author: {
name: "Valentin Popov",
email: "valentin@popov.link",
url: "https://popov.link/",
sameAs: ["https://www.linkedin.com/in/valentineus/", "https://github.com/valentineus"],
},
// Open Graph
og: {
color: {
bg: "#181818",
bgCode: "#3b3d42",
blossom: "#6da13f",
text: "#dee2e6",
},
defaultPreview: "/images/photo.png",
dimensions: {
height: 630,
width: 1200,
},
fonts: {
bold: "./src/assets/JetBrainsMono/JetBrainsMono-Bold.ttf",
regular: "./src/assets/JetBrainsMono/JetBrainsMono-Regular.ttf",
},
photo: "./public/images/photo.png",
website: "popov.link",
},
};
-18
View File
@@ -1,18 +0,0 @@
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";
const blog = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/content/blog" }),
schema: z.object({
basedOn: z.optional(z.string()),
dateModified: z.coerce.date(),
datePublished: z.coerce.date(),
description: z.string(),
draft: z.optional(z.boolean()),
lang: z.string(),
title: z.string(),
}),
});
export const collections = { blog };
+4 -6
View File
@@ -1,10 +1,8 @@
---
basedOn: "https://adrianhenke.wordpress.com/2008/12/05/create-lib-file-from-dll/"
title: "Create .lib file from .dll (archive)"
description: "Quick guide to create a .lib from a .dll on Windows: list exports with dumpbin, make a .def file, then generate the import library with lib."
datePublished: "2023-05-04"
dateModified: "2023-05-04"
lang: "en"
title: 'Create ".lib" file from ".dll" (archive)'
author: "Adrian Henke"
pubDate: "2023-05-04"
description: "Learn how to generate a *.lib file from a *.dll with this comprehensive guide. Using the Visual Studio Command Prompt and Microsoft's recommended tools, this article walks you through the steps for a seamless process. Perfect for developers working with 3rd party win dll's."
---
> This's a copy of a non-my post. The original article [is here](https://adrianhenke.wordpress.com/2008/12/05/create-lib-file-from-dll/) ([archive](https://web.archive.org/web/20161118122539/https://adrianhenke.wordpress.com/2008/12/05/create-lib-file-from-dll/)).
+3 -4
View File
@@ -1,9 +1,8 @@
---
title: "Горячая перезагрузка ElectronJS приложения"
description: "Горячая перезагрузка ElectronJS: перезапуск main через nodemon и автообновление renderer с HMR/chokidar. Пошагово, без electron-reload и с Webpack."
datePublished: "2019-08-15"
dateModified: "2019-08-15"
lang: "ru"
author: "Valentin Popov"
pubDate: "2019-08-15"
description: "Руководство по автоматической перезагрузке приложений на Electron с помощью пакетов electron-reload и electron-webpack. Обход проблем с совместимостью и использование HMR для renderer процесса."
---
## Main процесс
+2 -3
View File
@@ -1,9 +1,8 @@
---
title: "Example Content"
author: "Example User"
pubDate: "2018-01-01"
description: "Howdy! This is an example blog post that shows several types of HTML content supported in this theme."
datePublished: "2018-01-01"
dateModified: "2018-01-01"
lang: "en"
draft: true
---
@@ -1,9 +1,8 @@
---
title: 'Получение исходного кода "Chromium Projects"'
description: "Как получить и подготовить исходники Chromium на Windows: Visual Studio, Cygwin, depot_tools, команды gclient. Краткая пошаговая инструкция."
datePublished: "2012-01-30"
dateModified: "2012-01-30"
lang: "ru"
author: "Valentin Popov"
pubDate: "2012-01-30"
description: "Изучение исходных кодов Chromium: подготовка системы и установка необходимых программных компонентов. Руководство для начинающих разработчиков. Получите инструкции по установке Microsoft Visual Studio, Cygwin, Python и других инструментов. Действительно на январь-февраль 2012 года."
---
> Перенос [оригинальной статьи](https://adeptus-mechanicus.blogspot.com/2012/01/chromium-projects.html) 2012 года из моего [старого блога](https://adeptus-mechanicus.blogspot.com/) ([зеркало](https://web.archive.org/web/20160217052148/http://adeptus-mechanicus.blogspot.com/)).
@@ -1,9 +1,8 @@
---
title: "Установка Moodle в Fedora"
description: "Установка Moodle в Fedora: как исправить зависание инсталлятора и cURL error из-за SELinux. Правильные setsebool и chcon для доступа к сети и каталогам."
datePublished: "2018-07-23"
dateModified: "2018-07-23"
lang: "ru"
author: "Valentin Popov"
pubDate: "2018-07-23"
description: "Решение проблем установки Moodle из-за SELinux: как настроить правила доступа для устранения ошибок в веб-интерфейсе и при работе с cURL. Практические советы и команды."
---
Во время установки Moodle, сталкиваешься со следующими проблемами:
+3 -4
View File
@@ -1,9 +1,8 @@
---
title: "Компиляция Rust на TL-MR3020"
description: "Кросс-компиляция Rust для OpenWrt на TL-MR3020 (MIPS): rustup, cross-rs, Docker/Podman, UPX, пример TCP-сервера и сжатие бинарника."
datePublished: "2023-05-01"
dateModified: "2023-05-01"
lang: "ru"
author: "Valentin Popov"
pubDate: "2023-05-01"
description: 'Как настроить и оптимизировать проект Rust для кросс-компиляции на TP-Link TL-MR3020 с использованием Fedora Linux 38 и OpenWrt 22.03.4. Шаг за шагом от базового "Hello, World!" до асинхронного TCP сервера.'
---
Информация в статье актуальна для дистрибутива [Fedora Linux 38](https://docs.fedoraproject.org/en-US/releases/f38/), прошивки [OpenWrt 22.03.4](https://openwrt.org/releases/22.03/notes-22.03.4) и устройства [TP-Link TL-MR3020](https://www.tp-link.com/en/home-networking/3g-4g-router/tl-mr3020/) ревизии v3.20.
+14
View File
@@ -0,0 +1,14 @@
import { defineCollection, z } from "astro:content";
const blog = defineCollection({
type: "content",
schema: z.object({
author: z.string(),
description: z.string(),
draft: z.optional(z.boolean()),
pubDate: z.coerce.date(),
title: z.string(),
}),
});
export const collections = { blog };
+8
View File
@@ -1 +1,9 @@
/// <reference path="../.astro/types.d.ts" />
interface ImportMetaEnv {
readonly DEFAULT_TITLE: string;
readonly DEFAULT_DESCRIPTION: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
-47
View File
@@ -1,47 +0,0 @@
import type { AstroIntegration } from "astro";
import { createOgImage } from "../utils/createOgImage";
import { globby } from "globby";
import fs from "fs/promises";
import matter from "gray-matter";
import path from "path";
const postsDir = path.resolve("./src/content/blog");
const outDir = path.resolve("./public/images/preview");
export default function ogImageGenerator(): AstroIntegration {
return {
name: "og-images",
hooks: {
"astro:build:setup": async ({ logger }) => {
await fs.mkdir(outDir, { recursive: true });
const mdFiles = await globby("*.md", { cwd: postsDir });
logger.info(`${mdFiles.length} posts found`);
const results = await Promise.allSettled(
mdFiles.map(async (file) => {
const slug = file.replace(/\.md$/, "");
const content = await fs.readFile(path.join(postsDir, file), "utf-8");
const { data } = matter(content);
const png = await createOgImage(data.title, data.datePublished);
const outPath = path.join(outDir, `${slug}.png`);
await fs.writeFile(outPath, png);
logger.info(`OG image created: ${slug}`);
})
);
results.forEach((r) => {
if (r.status === "rejected") {
logger.error(`Error for ${r.reason.slug}: ${r.reason.message}`);
}
});
const failures = results.filter((r) => r.status === "rejected");
if (failures.length) {
throw new Error(`Failed to generate OG images for ${failures.length} posts`);
}
},
},
};
}
+8 -13
View File
@@ -1,27 +1,22 @@
---
import type { Thing } from "schema-dts";
import Analytics from "../components/Analytics.astro";
import Head from "../components/Head.astro";
import Header from "../components/Header.astro";
import "../scss/global.scss";
type Props = {
readonly description: string;
readonly lang: string;
readonly modifiedTime?: string;
readonly ogType?: "website" | "article";
readonly preview: string;
readonly publishedTime?: string;
readonly robots?: string;
readonly schema: Thing[];
readonly title: string;
readonly description?: string;
readonly title?: string;
};
const { description, lang, modifiedTime, ogType, preview, publishedTime, robots, schema, title } = Astro.props;
const { description, title } = Astro.props;
---
<html lang={lang}>
<Head title={title} description={description} preview={preview} robots={robots} schema={schema} lang={lang} ogType={ogType} publishedTime={publishedTime} modifiedTime={modifiedTime} />
<html lang="ru">
<Head
title={title ?? import.meta.env.DEFAULT_TITLE}
description={description ?? import.meta.env.DEFAULT_DESCRIPTION}
/>
<body>
<main>
+5 -22
View File
@@ -1,34 +1,17 @@
---
import { config } from "../config";
import Layout from "../layouts/BaseLayout.astro";
import pageSchema from "../utils/schemas/pageSchema";
const title = "404 — Page Not Found | Valentin Popov";
const description = "The page you're looking for doesn't exist!";
const preview = config.og.defaultPreview;
const lang = "en";
const siteUrl = new URL("/", Astro.site).toString();
const schema = [
pageSchema({
siteUrl,
page: "/404",
title,
description,
lang,
}),
];
---
<Layout title={title} description={description} preview={preview} lang={lang} robots="noindex, follow" schema={schema}>
<div style={{ "text-align": "center" }}>
<Layout>
<div style="text-align:center;">
<h1>404</h1>
<p><strong>Page not found</strong></p>
<p>
<small>
If you see this message, please
<a href=`mailto:valentin@popov.link?subject=${encodeURIComponent('I found a broken page')}`>let me know</a>
<a href=`mailto:valentin@popov.link?subject=${encodeURIComponent('I found a broken page')}`>
let me know
</a>
</small>
</p>
</div>
+13 -53
View File
@@ -1,13 +1,8 @@
---
import { type CollectionEntry, getCollection, render } from "astro:content";
import dayjs from "dayjs";
import blogPostSchema from "../../utils/schemas/blogPostSchema";
import breadcrumbSchema from "../../utils/schemas/breadcrumbSchema";
import { type CollectionEntry, getCollection } from "astro:content";
import Comments from "../../components/Comments.astro";
import Layout from "../../layouts/BaseLayout.astro";
import personSchema from "../../utils/schemas/personSchema";
import websiteSchema from "../../utils/schemas/websiteSchema";
import { config } from "../../config";
import dayjs from "dayjs";
type Props = CollectionEntry<"blog">;
@@ -17,52 +12,14 @@ export async function getStaticPaths() {
});
return posts.map((post) => ({
params: { slug: post.id },
params: { slug: post.slug },
props: post,
}));
}
const post = Astro.props;
const { Content, remarkPluginFrontmatter } = await render(post);
const description = post.data.description;
const isBasedOn = post.data.basedOn;
const lang = post.data.lang;
const preview = `/images/preview/${post.id}.png`;
const slug = post.id;
const headline = post.data.title;
const title = `${post.data.title} | Valentin Popov`;
const dateModified = (post.data.dateModified ?? post.data.datePublished).toISOString();
const datePublished = post.data.datePublished.toISOString();
const formattedDate = dayjs(post.data.datePublished.toString()).format("MMMM DD, YYYY");
const siteUrl = new URL("/", Astro.site).toString();
const schema = [
websiteSchema({ siteUrl, name: config.og.website, description, lang }),
personSchema({ siteUrl }),
blogPostSchema({
siteUrl,
dateModified,
datePublished,
description,
isBasedOn,
lang,
preview,
slug,
title: headline,
}),
breadcrumbSchema({
siteUrl,
items: [
{ name: "Home", url: "/" },
{ name: "Blog", url: "/blog/" },
{ name: headline, url: `/blog/${slug}` },
],
}),
];
const { Content, remarkPluginFrontmatter } = await post.render();
const formattedDate = dayjs(post.data.pubDate.toString()).format("MMMM DD, YYYY");
---
<style lang="scss">
@@ -73,20 +30,23 @@ const schema = [
}
</style>
<Layout title={title} description={description} preview={preview} lang={lang} schema={schema} ogType="article" publishedTime={datePublished} modifiedTime={dateModified}>
<Layout description={post.data.description} title={post.data.title}>
<article>
<header>
<h1>{headline}</h1>
<section>
<h1>{post.data.title}</h1>
</section>
<section>
<p>
<small>
Posted
<time datetime={datePublished} lang="en">{formattedDate}</time>
<time datetime={post.data.pubDate.toISOString()}>{formattedDate}</time>
by&nbsp;{post.data.author}
<span>&nbsp;•&nbsp;</span>
<span>{remarkPluginFrontmatter.minutesRead}</span>
</small>
</p>
</header>
</section>
<section>
<Content />
+6 -58
View File
@@ -1,71 +1,19 @@
---
import type { CollectionEntry } from "astro:content";
import { config } from "../../config";
import { getCollection } from "astro:content";
import blogSchema from "../../utils/schemas/blogSchema";
import breadcrumbSchema from "../../utils/schemas/breadcrumbSchema";
import Layout from "../../layouts/BaseLayout.astro";
import PostElement from "../../components/PostElement.astro";
import RSSIcon from "../../components/Icons/RSS.astro";
import websiteSchema from "../../utils/schemas/websiteSchema";
const posts = await getCollection("blog", ({ data }) => {
return data.draft !== true;
});
posts.sort((a, b) => b.data.datePublished.getTime() - a.data.datePublished.getTime());
const postsByYear = posts.reduce<Record<string, CollectionEntry<"blog">[]>>((acc, post) => {
const year = post.data.datePublished.getFullYear().toString();
if (!acc[year]) {
acc[year] = [];
}
acc[year].push(post);
return acc;
}, {});
const years = Object.keys(postsByYear).sort((a, b) => Number(b) - Number(a));
const title = "Valentin Popov's Blog | Software Development, Leadership & Open-Source";
const description = "Explore Valentin Popov's blog on software development, tech leadership, and open-source experiments. Stay updated with in-depth tutorials and expert insights.";
const preview = config.og.defaultPreview;
const lang = "en";
const siteUrl = new URL("/", Astro.site).toString();
const schema = [
websiteSchema({ siteUrl, name: config.og.website, description, lang }),
blogSchema({ siteUrl, title, description, lang, posts }),
breadcrumbSchema({
siteUrl,
items: [
{ name: "Home", url: "/" },
{ name: "Blog", url: "/blog/" },
],
}),
];
posts.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
---
<Layout title={title} description={description} preview={preview} lang={lang} schema={schema}>
<section>
<h1>
Blog posts
<RSSIcon />
</h1>
</section>
<section>
{
years.map((year) => (
<div>
<h2>{year}</h2>
<ul>
{postsByYear[year].map((post) => (
<PostElement post={post} />
))}
</ul>
</div>
))
}
<Layout>
<section style={{ "margin-top": "3rem" }}>
<ul>
{posts.map((post) => <PostElement post={post} />)}
</ul>
</section>
</Layout>
+22
View File
@@ -0,0 +1,22 @@
import { getCollection } from "astro:content";
import rss from "@astrojs/rss";
export async function GET(context) {
const posts = await getCollection("blog", ({ data }) => {
return data.draft !== true;
});
return rss({
customData: `<language>ru-ru</language>`,
description: import.meta.env.DEFAULT_DESCRIPTION,
items: posts.map((post) => ({
customData: post.data.customData,
description: post.data.description,
link: `/blog/${post.slug}`,
pubDate: post.data.pubDate,
title: post.data.title,
})),
site: context.site,
title: import.meta.env.DEFAULT_TITLE,
});
}
-48
View File
@@ -1,48 +0,0 @@
import type { APIContext } from "astro";
import { getCollection } from "astro:content";
import MarkdownIt from "markdown-it";
import rss from "@astrojs/rss";
import sanitizeHtml from "sanitize-html";
import { config } from "../config";
const parser = new MarkdownIt({ html: false, linkify: true });
export async function GET(context: APIContext) {
const title = "RSS Feed | Valentin Popov Blog";
const description = "Follow the latest posts from Valentin Popov via RSS.";
const posts = (await getCollection("blog", ({ data }) => data.draft !== true)).sort((a, b) => b.data.datePublished.getTime() - a.data.datePublished.getTime());
const feedUrl = new URL("/feed.xml", context.site).toString();
return rss({
title,
description,
site: context.site ?? config.author.url,
xmlns: {
atom: "http://www.w3.org/2005/Atom",
content: "http://purl.org/rss/1.0/modules/content/",
dc: "http://purl.org/dc/elements/1.1/",
},
customData: `<atom:link href="${feedUrl}" rel="self" type="application/rss+xml"/>`,
items: posts.map((post) => ({
title: post.data.title,
description: post.data.description,
link: `/blog/${post.id}/`,
pubDate: post.data.datePublished,
author: `${config.author.email} (${config.author.name})`,
content: sanitizeHtml(parser.render(post.body ?? ""), {
allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img", "pre", "code", "span"]),
allowedAttributes: {
...sanitizeHtml.defaults.allowedAttributes,
img: ["src", "alt", "title", "loading", "decoding"],
code: ["class"],
span: ["class"],
pre: ["class"],
a: ["href", "name", "target", "rel"],
},
}),
customData: `<dc:language>${post.data.lang}</dc:language>`,
})),
});
}
+10 -18
View File
@@ -1,25 +1,17 @@
---
import { config } from "../config";
import LatestPostsSection from "../components/Sections/LatestPosts.astro";
import { getCollection } from "astro:content";
import Layout from "../layouts/BaseLayout.astro";
import pageSchema from "../utils/schemas/pageSchema";
import personSchema from "../utils/schemas/personSchema";
import SocialLinksSection from "../components/Sections/SocialLinks.astro";
import WelcomeSection from "../components/Sections/Welcome.astro";
import websiteSchema from "../utils/schemas/websiteSchema";
import PostSummary from "../components/PostSummary.astro";
const title = "Valentin Popov Software Developer & Team Lead | Tech Insights";
const description = "Blog by Valentin Popov — software developer and team lead writing about code, side projects, digital tools, and fun experiments.";
const preview = config.og.defaultPreview;
const lang = "en";
const posts = await getCollection("blog", ({ data }) => {
return data.draft !== true;
});
const siteUrl = new URL("/", Astro.site).toString();
const schema = [websiteSchema({ siteUrl, name: config.og.website, description, lang }), personSchema({ siteUrl }), pageSchema({ siteUrl, page: "/", title, description, lang, type: "ProfilePage" })];
posts.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
---
<Layout title={title} description={description} preview={preview} lang={lang} schema={schema}>
<WelcomeSection />
<SocialLinksSection />
<LatestPostsSection />
<Layout>
<section style={{ "margin-top": "3rem" }}>
{posts.map((post) => <PostSummary post={post} />)}
</section>
</Layout>
-13
View File
@@ -1,13 +0,0 @@
import type { Element, Root } from "hast";
import { visit } from "unist-util-visit";
export default function rehypeLazyImages() {
return (tree: Root): void => {
visit(tree, "element", (node: Element) => {
if (node.tagName !== "img") return;
node.properties ??= {};
node.properties.loading ??= "lazy";
node.properties.decoding ??= "async";
});
};
}
-52
View File
@@ -1,52 +0,0 @@
import { config } from "../config";
import { html } from "satori-html";
import { resources } from "./ogResources";
import { Resvg } from "@resvg/resvg-js";
import dayjs from "dayjs";
import satori from "satori";
export async function createOgImage(title: string, datePublished: Date): Promise<Uint8Array> {
const formattedDate = dayjs(datePublished).format("MMMM DD, YYYY");
const markup = await satori(
html(`
<div tw="flex flex-col w-full h-full" style="background-color: ${config.og.color.bg}">
<div tw="flex flex-col w-full h-4/5 p-10 justify-center">
<div tw="text-2xl mb-6" style="color: ${config.og.color.text}">${formattedDate}</div>
<div tw="flex text-6xl w-full font-bold" style="color: ${config.og.color.text}">${title}</div>
</div>
<div tw="w-full h-1/5 flex p-10 items-center justify-between text-2xl" style="border-top: 1px solid ${config.og.color.bgCode}">
<div tw="flex items-center">
<span tw="ml-3" style="color: ${config.og.color.text}">${config.og.website.toLocaleUpperCase()}</span>
</div>
<div tw="flex items-center">
<img src="${resources.photoBase64}" tw="w-15 h-15 rounded-full" />
<div tw="flex flex-col ml-4">
<span style="color: ${config.og.color.text}">${config.author.name}</span>
<span style="color: ${config.og.color.blossom}">${config.author.email}</span>
</div>
</div>
</div>
</div>
`),
{
width: config.og.dimensions.width,
height: config.og.dimensions.height,
fonts: [
{
name: "Inter",
data: resources.fonts.regular,
weight: 400,
},
{
name: "Inter",
data: resources.fonts.bold,
weight: 700,
},
],
}
);
const image = new Resvg(markup, { fitTo: { mode: "width", value: config.og.dimensions.width } });
return image.render().asPng();
}
-15
View File
@@ -1,15 +0,0 @@
import { config } from "../config";
import fs from "fs/promises";
import path from "path";
import sharp from "sharp";
export const resources = {
fonts: {
regular: await fs.readFile(path.resolve(config.og.fonts.regular)),
bold: await fs.readFile(path.resolve(config.og.fonts.bold)),
},
photoBase64: await (async () => {
const buf = await fs.readFile(path.resolve(config.og.photo));
return "data:image/png;base64," + (await sharp(buf).resize(120, 120).png({ quality: 95 }).toBuffer()).toString("base64");
})(),
};
-38
View File
@@ -1,38 +0,0 @@
import type { BlogPosting } from "schema-dts";
import { personId, websiteId } from "./ids";
export type BlogPostSchemaParams = {
readonly dateModified: string;
readonly datePublished: string;
readonly description: string;
readonly isBasedOn?: string;
readonly lang: string;
readonly preview: string;
readonly siteUrl: string;
readonly slug: string;
readonly title: string;
};
export default ({ siteUrl, slug, title, description, preview, datePublished, dateModified, lang, isBasedOn }: BlogPostSchemaParams): BlogPosting => {
const url = new URL(`/blog/${slug}`, siteUrl).toString();
return {
"@type": "BlogPosting",
"@id": url,
"url": url,
"headline": title,
"description": description,
"image": new URL(preview, siteUrl).toString(),
"datePublished": datePublished,
"dateModified": dateModified,
"inLanguage": lang,
"author": { "@id": personId(siteUrl) },
"publisher": { "@id": personId(siteUrl) },
"isPartOf": { "@id": websiteId(siteUrl) },
"mainEntityOfPage": {
"@type": "WebPage",
"@id": url,
},
...(isBasedOn && { isBasedOn: isBasedOn }),
};
};
-36
View File
@@ -1,36 +0,0 @@
import type { CollectionPage } from "schema-dts";
import type { CollectionEntry } from "astro:content";
import { websiteId } from "./ids";
export type BlogSchemaParams = {
readonly description: string;
readonly lang: string;
readonly posts: CollectionEntry<"blog">[];
readonly siteUrl: string;
readonly title: string;
};
export default ({ siteUrl, title, description, lang, posts }: BlogSchemaParams): CollectionPage => {
const url = new URL("/blog/", siteUrl).toString();
return {
"@type": "CollectionPage",
"@id": url,
"url": url,
"name": title,
"description": description,
"inLanguage": lang,
"isPartOf": { "@id": websiteId(siteUrl) },
"mainEntity": {
"@type": "ItemList",
"itemListOrder": "https://schema.org/ItemListOrderDescending",
"numberOfItems": posts.length,
"itemListElement": posts.map((post, index) => ({
"@type": "ListItem",
"position": index + 1,
"url": new URL(`/blog/${post.id}`, siteUrl).toString(),
"name": post.data.title,
})),
},
};
};
-21
View File
@@ -1,21 +0,0 @@
import type { BreadcrumbList } from "schema-dts";
export type BreadcrumbItem = {
readonly name: string;
readonly url: string;
};
export type BreadcrumbSchemaParams = {
readonly items: BreadcrumbItem[];
readonly siteUrl: string;
};
export default ({ items, siteUrl }: BreadcrumbSchemaParams): BreadcrumbList => ({
"@type": "BreadcrumbList",
"itemListElement": items.map((item, index) => ({
"@type": "ListItem",
"position": index + 1,
"name": item.name,
"item": new URL(item.url, siteUrl).toString(),
})),
});
-3
View File
@@ -1,3 +0,0 @@
export const websiteId = (siteUrl: string): string => new URL("#website", siteUrl).toString();
export const personId = (siteUrl: string): string => new URL("#person", siteUrl).toString();
-35
View File
@@ -1,35 +0,0 @@
import type { ProfilePage, WebPage } from "schema-dts";
import { personId, websiteId } from "./ids";
export type WebsiteSchemaParams = {
readonly description: string;
readonly lang: string;
readonly page: string;
readonly siteUrl: string;
readonly title: string;
readonly type?: "WebPage" | "ProfilePage";
};
export default ({ siteUrl, page, title, description, lang, type = "WebPage" }: WebsiteSchemaParams): WebPage | ProfilePage => {
const url = new URL(page, siteUrl).toString();
const base = {
"@type": type,
"@id": url,
"url": url,
"name": title,
"description": description,
"inLanguage": lang,
"isPartOf": { "@id": websiteId(siteUrl) },
} as const;
if (type === "ProfilePage") {
return {
...base,
"@type": "ProfilePage",
"mainEntity": { "@id": personId(siteUrl) },
};
}
return base;
};
-17
View File
@@ -1,17 +0,0 @@
import type { Person } from "schema-dts";
import { config } from "../../config";
import { personId } from "./ids";
export type PersonSchemaParams = {
readonly siteUrl: string;
};
export default ({ siteUrl }: PersonSchemaParams): Person => ({
"@type": "Person",
"@id": personId(siteUrl),
"name": config.author.name,
"url": config.author.url,
"email": config.author.email,
"image": new URL(config.og.defaultPreview, siteUrl).toString(),
"sameAs": config.author.sameAs,
});
-23
View File
@@ -1,23 +0,0 @@
import type { WebSite } from "schema-dts";
import { config } from "../../config";
import { personId, websiteId } from "./ids";
export type WebsiteSchemaParams = {
readonly description: string;
readonly lang: string;
readonly name: string;
readonly siteUrl: string;
};
export default ({ siteUrl, name, description, lang }: WebsiteSchemaParams): WebSite => ({
"@type": "WebSite",
"@id": websiteId(siteUrl),
"url": siteUrl,
"name": name,
"description": description,
"inLanguage": lang,
"publisher": { "@id": personId(siteUrl) },
"author": { "@id": personId(siteUrl) },
"copyrightHolder": { "@id": personId(siteUrl) },
"sameAs": config.author.sameAs,
});