Merge branch 'release/1.0.0'

This commit is contained in:
Sergey Yarkov 2022-08-27 18:59:24 +03:00
commit 264eabdce2
80 changed files with 1350 additions and 1067 deletions

4
.gitignore vendored
View File

@ -10,10 +10,6 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
.idea
.vscode
# environment variables # environment variables
.env .env
.env.production .env.production

7
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"files.eol": "\n",
"prettier.documentSelectors": ["**/*.astro"],
"[astro]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

View File

@ -1,7 +1,7 @@
# yarkov.tech # yarkov.tech
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Netlify Status](https://api.netlify.com/api/v1/badges/ccd0460a-9725-4e35-8263-1608c4186d9c/deploy-status)](https://app.netlify.com/sites/jade-froyo-876dfe/deploys) [![Netlify Status](https://api.netlify.com/api/v1/badges/ccd0460a-9725-4e35-8263-1608c4186d9c/deploy-status)](https://app.netlify.com/sites/yarkov/deploys)
Personal website created with Astro. Personal website created with Astro.
@ -15,7 +15,7 @@ Personal website created with Astro.
- [x] Form handling via [Netlify Forms](https://www.netlify.com/products/forms/) - [x] Form handling via [Netlify Forms](https://www.netlify.com/products/forms/)
- [x] Comment system via [utterances](https://utteranc.es/) - [x] Comment system via [utterances](https://utteranc.es/)
- [x] RSS Feed for articles - [x] RSS Feed for articles
- [ ] SEO optimized - [x] SEO optimized
## License ## License

View File

@ -1,14 +1,26 @@
import solid from "@astrojs/solid-js";
// import image from "@astrojs/image";
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
// import compress from "astro-compress";
import { defineConfig } from "astro/config"; import { defineConfig } from "astro/config";
import { h } from "hastscript"; import { h } from "hastscript";
import { toString } from "hast-util-to-string"; import { toString } from "hast-util-to-string";
import { escape } from "html-escaper"; import { escape } from "html-escaper";
/**
* Integrations
*/
import solid from "@astrojs/solid-js";
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
import image from "@astrojs/image";
import compress from "astro-compress";
/**
* Remark plugins
*/
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import remarkSmartypants from "remark-smartypants"; import remarkSmartypants from "remark-smartypants";
/**
* Rehype plugins
*/
import rehypeSlug from "rehype-slug"; import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings"; import rehypeAutolinkHeadings from "rehype-autolink-headings";
@ -27,12 +39,13 @@ export default defineConfig({
i18n: { i18n: {
defaultLocale: "ru", defaultLocale: "ru",
locales: { locales: {
ru: "ru-RU", ru: "ru",
en: "en-US", en: "en",
}, },
}, },
}), }),
// image(), image(),
compress(),
mdx({ mdx({
remarkPlugins: [remarkGfm, remarkSmartypants], remarkPlugins: [remarkGfm, remarkSmartypants],
rehypePlugins: [ rehypePlugins: [
@ -77,7 +90,7 @@ export default defineConfig({
], ],
}), }),
], ],
trailingSlash: "always", trailingSlash: "ignore",
markdown: { markdown: {
syntaxHighlight: "prism", syntaxHighlight: "prism",
}, },
@ -86,7 +99,6 @@ export default defineConfig({
external: ["svgo"], external: ["svgo"],
}, },
build: { build: {
minify: "esbuild",
rollupOptions: { rollupOptions: {
output: { output: {
entryFileNames: `[name].[hash].js`, entryFileNames: `[name].[hash].js`,

@ -1 +1 @@
Subproject commit 702becf6781d2b2a5496b185f2bf3bf18b06cec5 Subproject commit 50ba0addfe113ad1d531d62fe0b516e1cdfa589c

939
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "yarkov.tech", "name": "yarkov.tech",
"version": "0.0.1", "version": "1.0.0",
"private": true, "private": true,
"description": "Personal website created with Astro.", "description": "Personal website created with Astro.",
"author": { "author": {
@ -15,33 +15,35 @@
"start": "astro dev", "start": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview --host", "preview": "astro preview --host",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json}\"", "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md,mdx}\"",
"lint": "eslint '**/*.{js,mjs,ts,tsx}'" "lint": "eslint '**/*.{js,mjs,ts,tsx}'"
}, },
"devDependencies": { "devDependencies": {
"@astrojs/image": "^0.3.4", "@astrojs/image": "^0.3.6",
"@astrojs/mdx": "^0.10.0", "@astrojs/mdx": "^0.10.2",
"@astrojs/rss": "^1.0.0", "@astrojs/rss": "^1.0.0",
"@astrojs/sitemap": "^1.0.0", "@astrojs/sitemap": "^1.0.0",
"@astrojs/solid-js": "^1.0.0", "@astrojs/solid-js": "^1.1.0",
"@types/mdx": "^2.0.2", "@types/mdx": "^2.0.2",
"@typescript-eslint/eslint-plugin": "^5.33.1", "@typescript-eslint/eslint-plugin": "^5.35.1",
"@typescript-eslint/parser": "^5.33.1", "@typescript-eslint/parser": "^5.35.1",
"astro": "^1.0.6", "astro": "^1.1.1",
"astro-compress": "^1.0.1", "astro-compress": "^1.0.5",
"astro-icon": "^0.7.3", "astro-icon": "^0.7.3",
"eslint": "^8.22.0", "eslint": "^8.23.0",
"eslint-plugin-solid": "^0.7.1", "eslint-plugin-solid": "^0.7.1",
"hast-util-to-string": "^2.0.0", "hast-util-to-string": "^2.0.0",
"hastscript": "^7.0.2", "hastscript": "^7.0.2",
"html-escaper": "^3.0.3", "html-escaper": "^3.0.3",
"mime-types": "^2.1.35",
"netlify-plugin-minify-html": "^0.3.1",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"prettier-plugin-astro": "^0.5.0", "prettier-plugin-astro": "^0.5.3",
"rehype-autolink-headings": "^6.1.1", "rehype-autolink-headings": "^6.1.1",
"rehype-slug": "^5.0.1", "rehype-slug": "^5.0.1",
"remark-gfm": "^3.0.1", "remark-gfm": "^3.0.1",
"remark-smartypants": "^2.0.0", "remark-smartypants": "^2.0.0",
"sass": "^1.54.4", "sass": "^1.54.5",
"solid-js": "^1.4.8" "solid-js": "^1.5.1"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

View File

@ -1,6 +1,4 @@
# Disallow all at the time of development
User-agent: * User-agent: *
Disallow: / Allow: /
Sitemap: https://yarkov.tech/sitemap-index.xml Sitemap: https://yarkov.tech/sitemap-index.xml

View File

@ -1,5 +1,4 @@
--- ---
import { UTTERANCES_CONFIG } from "@root/config";
import UIString from "../UIString"; import UIString from "../UIString";
--- ---
@ -8,21 +7,22 @@ import UIString from "../UIString";
<div id="comments"></div> <div id="comments"></div>
</footer> </footer>
<script define:vars={{ UTTERANCES_CONFIG }}> <script>
const $commentsWrapper = document.getElementById("comments"); const $commentsWrapper = document.getElementById("comments");
const $commentsScript = document.createElement("script"); const $commentsScript = document.createElement("script");
$commentsScript.async = true; $commentsScript.async = true;
$commentsScript.src = "https://utteranc.es/client.js"; $commentsScript.src = "https://utteranc.es/client.js";
$commentsScript.setAttribute("repo", UTTERANCES_CONFIG.repo); $commentsScript.setAttribute("repo", "sergeyyarkov/comments");
$commentsScript.setAttribute("issue-term", UTTERANCES_CONFIG.issueTerm); $commentsScript.setAttribute("issue-term", "title");
$commentsScript.setAttribute("label", "Comment");
$commentsScript.setAttribute("id", "utterances"); $commentsScript.setAttribute("id", "utterances");
$commentsScript.setAttribute("crossorigin", "anonymous"); $commentsScript.setAttribute("crossorigin", "anonymous");
if (window.theme) { if (window.theme) {
$commentsScript.setAttribute("theme", theme === "dark" ? "dark-blue" : "github-light"); $commentsScript.setAttribute("theme", theme === "dark" ? "dark-blue" : "github-light");
} else { } else {
$commentsScript.setAttribute("theme", UTTERANCES_CONFIG.theme); $commentsScript.setAttribute("theme", "github-light");
} }
if ($commentsWrapper) $commentsWrapper.appendChild($commentsScript); if ($commentsWrapper) $commentsWrapper.appendChild($commentsScript);

View File

@ -32,7 +32,7 @@
& q { & q {
font-style: italic; font-style: italic;
quotes: "«" "»"; quotes: "« " " »";
} }
& a { & a {

View File

@ -14,7 +14,7 @@ export const ArticleItem: Component<ArticleItemProps> = (props) => {
const hrefLangPrefix = props.data.lang && props.data.lang !== DEFAULT_LANGUAGE ? "/en" : ""; const hrefLangPrefix = props.data.lang && props.data.lang !== DEFAULT_LANGUAGE ? "/en" : "";
return ( return (
<article class="article-item"> <article class="article-item">
<a href={`/blog/${new Date(props.data.published_at).toLocaleDateString("en-CA")}${hrefLangPrefix}/${props.data.slug}/`}> <a href={`${hrefLangPrefix}/blog/${new Date(props.data.published_at).toLocaleDateString("en-CA")}/${props.data.slug}/`}>
<div class="flex"> <div class="flex">
<h3>{props.data.title}</h3> <h3>{props.data.title}</h3>
{isNonDefaultLanguage && <sup>{props.data.lang.toLocaleUpperCase()}</sup>} {isNonDefaultLanguage && <sup>{props.data.lang.toLocaleUpperCase()}</sup>}

View File

@ -1,6 +1,5 @@
--- ---
// import { Image } from "@astrojs/image/components"; import { Image, Picture } from "@astrojs/image/components";
// import * as utils from "@utils/string";
import { createDateFormatter } from "@root/utils"; import { createDateFormatter } from "@root/utils";
import ArticleComments from "@components/ArticleComments"; import ArticleComments from "@components/ArticleComments";
import "./ArticlePost.scss"; import "./ArticlePost.scss";
@ -39,13 +38,15 @@ const { title, coverImage, tags, author, published_at } = data;
coverImage && ( coverImage && (
<div> <div>
<figure> <figure>
{coverImage && <img src={coverImage} width='700' height='400' alt={title} />} <Picture
{/* Render remote or local image */} itemprop="image"
{/* utils.isURL(coverImage) ? ( src={coverImage}
<Image itemprop="image" src={coverImage} width={700} height={400} aspectRatio={"16:9"} format={"webp"} alt={data.title} /> sizes="(max-width: 500px) 500px, 700px"
) : ( widths={[500, 700]}
<Image itemprop="image" src={cover.default.src} width={700} height={400} aspectRatio={"16:9"} format={"webp"} alt={data.title} /> formats={["webp"]}
)*/} aspectRatio={"16:9"}
alt={title}
/>
</figure> </figure>
</div> </div>
) )

View File

@ -5,10 +5,11 @@ import { useTranslation } from "@i18n/utils";
export interface Props { export interface Props {
title?: string | undefined; title?: string | undefined;
description?: string | undefined; description?: string | undefined;
canonicalUrl?: string | undefined;
} }
const t = useTranslation(Astro); const t = useTranslation(Astro);
const { title = SITE_METADATA.title, description } = Astro.props as Props; const { title = SITE_METADATA.title, description, canonicalUrl } = Astro.props as Props;
--- ---
<head> <head>
@ -16,13 +17,16 @@ const { title = SITE_METADATA.title, description } = Astro.props as Props;
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Mono&display=swap" href="https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Mono&display=swap"
rel="preload" as="style" onload="this.rel='stylesheet'" rel="preload"
as="style"
onload="this.rel='stylesheet'"
/> />
{canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title> <title>{title}</title>
<meta name="title" content={title} /> <meta name="title" content={title} />
<meta name="generator" content={Astro.generator} />
<meta name="description" content={description || t("site.description")} /> <meta name="description" content={description || t("site.description")} />
<meta name="theme-color" content={SITE_METADATA.themeColor} /> <meta name="theme-color" content={SITE_METADATA.themeColor} />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />

View File

@ -2,12 +2,12 @@
import { IS_SITE_UNDER_CONSTRUCTION } from "@root/config"; import { IS_SITE_UNDER_CONSTRUCTION } from "@root/config";
import Header from "@components/Header"; import Header from "@components/Header";
import Footer from "@components/Footer"; import Footer from "@components/Footer";
import SiteContent from "@components/SiteContent";
import HeaderBanner from "@components/HeaderBanner"; import HeaderBanner from "@components/HeaderBanner";
import UIString from "@components/UIString"; import UIString from "@components/UIString";
import "./BaseLayout.scss";
--- ---
<body> <body data-is-site-under-construction={IS_SITE_UNDER_CONSTRUCTION && "true"}>
{ {
IS_SITE_UNDER_CONSTRUCTION && ( IS_SITE_UNDER_CONSTRUCTION && (
<HeaderBanner> <HeaderBanner>
@ -18,8 +18,8 @@ import "./BaseLayout.scss";
) )
} }
<Header /> <Header />
<main class="site-content"> <SiteContent>
<slot /> <slot />
</main> </SiteContent>
<Footer /> <Footer />
</body> </body>

View File

@ -1,11 +0,0 @@
.site-content {
flex: 1 0 auto;
width: 100%;
margin-top: 5rem;
}
@media screen and (max-width: 768px) {
.site-content {
margin-top: 3rem;
}
}

View File

@ -0,0 +1,27 @@
---
export type Props = astroHTML.JSX.ButtonHTMLAttributes;
const attrs = Astro.props as Props;
---
<button {...attrs}>
<slot />
</button>
<style lang="scss">
button {
display: inline-flex;
column-gap: 7px;
font-family: var(--font-family-base);
font-weight: 500;
font-size: var(--font-size-sm);
&[disabled] {
opacity: 0.5;
cursor: not-allowed;
&:hover {
border: 1px solid var(--border-color);
}
}
}
</style>

View File

@ -1,28 +0,0 @@
.btn {
cursor: pointer;
display: inline-flex;
column-gap: 7px;
align-items: center;
font-family: var(--font-family-base);
font-weight: 500;
font-size: var(--font-size-md);
color: var(--font-color);
padding: 5px 16px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
background: none;
font-size: var(--font-size-sm);
&:hover {
border: 1px solid var(--border-color-hover);
}
&[disabled] {
opacity: 0.5;
&:hover {
border: 1px solid var(--border-color);
}
}
&[data-loading="true"] {
user-select: none;
cursor: not-allowed;
}
}

View File

@ -1,22 +0,0 @@
import type { ParentComponent, JSX } from "solid-js";
import { splitProps } from "solid-js";
import Spinner from "@components/Spinner";
import "./Button.scss";
type ButtonProps = {
isLoading?: boolean | undefined;
loadingText?: string | undefined;
} & JSX.ButtonHTMLAttributes<HTMLButtonElement>;
const Button: ParentComponent<ButtonProps> = (props) => {
const [local, attrs] = splitProps(props, ["children", "isLoading", "loadingText"]);
return (
<button disabled={local.isLoading} class={`btn`} data-loading={local.isLoading} {...attrs}>
{local.isLoading && <Spinner />}
{local.isLoading ? local.loadingText || "Loading..." : local.children}
</button>
);
};
export default Button;

View File

@ -1 +1 @@
export { default } from "./Button"; export { default } from "./Button.astro";

View File

@ -0,0 +1,58 @@
---
import Button from "@components/Button";
import { useTranslation } from "@i18n/utils";
const t = useTranslation(Astro);
---
<form name="contact" method="post" netlify-honeypot="bot-field" data-netlify="true">
<div hidden>
Don't fill this out if you're human
<input name="bot-field" />
</div>
<div class="flex">
<div>
<input name="name" placeholder={t("input.nameField")} type="text" maxlength="20" autocomplete="off" required />
</div>
<div>
<input name="email" placeholder="example@email.com" autocomplete="off" type="email" required />
</div>
</div>
<input name="subject" placeholder={t("input.subjectField")} type="text" autocomplete="off" required />
<textarea name="message" placeholder={t("input.messageField")} id="message" autocomplete="off" cols={30} rows={10} required></textarea>
<div class="footer">
<Button type="submit">{t("btn.submit")}</Button>
</div>
</form>
<style lang="scss">
form {
display: flex;
flex-direction: column;
row-gap: 7px;
margin-bottom: 3.125rem;
& .flex {
column-gap: 10px;
& div {
width: 100%;
}
}
& input.error-input,
& textarea.error-input {
border: 1px solid var(--color-error);
}
& .footer {
text-align: center;
}
}
@media screen and (max-width: 560px) {
.contact-form .flex {
flex-direction: column;
row-gap: 7px;
}
}
</style>

View File

@ -1,140 +0,0 @@
import type { Component, JSX } from "solid-js";
import { createSignal } from "solid-js";
import { createStore } from "solid-js/store";
import { useForm } from "@hooks/index";
import { encode } from "@utils/url";
import Button from "@components/Button";
import validations from "./validations";
import Submitted from "./Submitted";
import Error from "./Error";
import "./ContactForm.scss";
type UiStringsType = {
"form.contactSuccess": string;
"form.contactError": string;
"input.nameField": string;
"input.subjectField": string;
"input.messageField": string;
"btn.send": string;
"btn.sending": string;
"error.invalidEmail": string;
};
const ErrorMessage: Component<{ message: string }> = (props) => <span class="error-message">{props.message}</span>;
const ContactForm: Component<{ i18n: UiStringsType }> = (props) => {
const { formSubmit, validate, errors } = useForm({ errorClass: "error-input" });
const [isSubmitted, setIsSubmitted] = createSignal<boolean>(false);
const [isLoading, setIsLoading] = createSignal<boolean>(false);
const [isError, setIsError] = createSignal<boolean>(false);
const [state, setState] = createStore<ContactFieldsType>({ name: "", email: "", subject: "", message: "", "bot-field": "" });
const { validateEmail } = validations;
const reset = (form: HTMLFormElement) => {
for (const key in state) {
form.elements[key].value = "";
setState({ [key]: "" });
}
};
const sendMessage = async (form: HTMLFormElement) => {
try {
setIsLoading(true);
fetch("/", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: encode({
"form-name": "contact",
...state,
}),
});
} catch (error) {
setIsError(true);
console.error(error);
} finally {
setIsLoading(false);
setIsSubmitted(true);
reset(form);
}
};
const onChange: JSX.EventHandler<HTMLInputElement, InputEvent> = (e) => {
const key = e.currentTarget.name;
const value = e.currentTarget.value;
if (!Object.hasOwn(state, key)) return;
setState({ [key]: value });
};
return (
<form use:formSubmit={sendMessage} class="contact-form" name="contact" method="post" data-netlify="true">
<div hidden>
Don't fill this out if you're human
<input onInput={onChange} name="bot-field" />
</div>
<div class="flex">
<div>
<input
onInput={onChange}
use:validate
name="name"
placeholder={props.i18n["input.nameField"]}
type="text"
maxLength="20"
autocomplete="off"
required
/>
{errors.name && <ErrorMessage message={errors.name} />}
</div>
<div>
<input
onInput={onChange}
use:validate={[validateEmail(props.i18n["error.invalidEmail"])]}
name="email"
placeholder="example@email.com"
autocomplete="off"
type="text"
required
/>
{errors.email && <ErrorMessage message={errors.email} />}
</div>
</div>
<input
value={state.subject}
onInput={onChange}
use:validate
name="subject"
placeholder={props.i18n["input.subjectField"]}
type="text"
autocomplete="off"
required
/>
{errors.subject && <ErrorMessage message={errors.subject} />}
<textarea
onInput={onChange}
use:validate
name="message"
placeholder={props.i18n["input.messageField"]}
id="message"
autocomplete="off"
cols={30}
rows={10}
required
/>
{errors.message && <ErrorMessage message={errors.message} />}
<div class="form-footer">
{!isError() && !isSubmitted() && (
<Button isLoading={isLoading()} loadingText={props.i18n["btn.sending"]} type="submit">
{props.i18n["btn.send"]}
</Button>
)}
{!isError() && isSubmitted() && <Submitted text={props.i18n["form.contactSuccess"]} />}
{isError() && isSubmitted() && <Error text={props.i18n["form.contactError"]} />}
</div>
</form>
);
};
export default ContactForm;

View File

@ -1,7 +0,0 @@
import type { Component } from "solid-js";
const Submitted: Component<{ text: string }> = (props) => {
return <div class="form-result">&#10060; {props.text}</div>;
};
export default Submitted;

View File

@ -1,7 +0,0 @@
import type { Component } from "solid-js";
const Submitted: Component<{ text: string }> = (props) => {
return <div class="form-result">&#10004;&#65039; {props.text}</div>;
};
export default Submitted;

View File

@ -1 +1 @@
export { default } from "./ContactForm"; export { default } from "./ContactForm.astro";

View File

@ -1,7 +0,0 @@
import { isEmail } from "@utils/string";
export default {
validateEmail: (error: string) => {
return (field: HTMLInputElement) => (isEmail(field.value) ? false : error);
},
};

View File

@ -1,24 +1,24 @@
--- ---
import { SITE_METADATA } from '@root/config'; import { SITE_METADATA } from "@root/config";
import { Icon } from 'astro-icon'; import { Icon } from "astro-icon";
import './ContactsList.scss'; import "./ContactsList.scss";
const { const {
contacts: { email, githubUrl, telegramUrl }, contacts: { email, githubUrl, telegramUrl },
} = SITE_METADATA; } = SITE_METADATA;
--- ---
<div class='contacts-list'> <div class="contacts-list">
<a href={'mailto:' + email} <a href={"mailto:" + email}
>Email >Email
<Icon name='arrow-up-right' width='15' height='15' data-filter='none' /> <Icon name="arrow-up-right" width="15" height="15" data-filter="none" />
</a> </a>
<a href={githubUrl} target='_blank' <a href={githubUrl} target="_blank"
>Github >Github
<Icon name='arrow-up-right' width='15' height='15' data-filter='none' /> <Icon name="arrow-up-right" width="15" height="15" data-filter="none" />
</a> </a>
<a href={telegramUrl} target='_blank' <a href={telegramUrl} target="_blank"
>Telegram >Telegram
<Icon name='arrow-up-right' width='15' height='15' data-filter='none' /> <Icon name="arrow-up-right" width="15" height="15" data-filter="none" />
</a> </a>
</div> </div>

View File

@ -1,14 +0,0 @@
.counter {
margin: 2rem 0;
display: flex;
flex-direction: column;
align-items: center;
row-gap: 15px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
padding: 15px 5px;
& div {
display: flex;
column-gap: 3px;
}
}

View File

@ -1,8 +1,6 @@
import type { Component } from "solid-js"; import type { Component } from "solid-js";
import { createSignal, createEffect } from "solid-js"; import { createSignal, createEffect } from "solid-js";
import Button from "../Button";
import "./Counter.scss";
const Counter: Component<{ tick?: number }> = (props) => { const Counter: Component<{ tick?: number }> = (props) => {
const [isRunned, setIsRunned] = createSignal<boolean>(false); const [isRunned, setIsRunned] = createSignal<boolean>(false);
@ -22,21 +20,37 @@ const Counter: Component<{ tick?: number }> = (props) => {
}); });
return ( return (
<div class="counter"> <div
style={{
margin: "2rem 0",
display: "flex",
"flex-direction": "column",
"align-items": "center",
"row-gap": "15px",
border: "1px solid var(--border-color)",
"border-radius": "var(--border-radius-md)",
padding: "15px 5px",
}}
>
<span>{count()}</span> <span>{count()}</span>
<div> <div
<Button disabled={isRunned()} onClick={run}> style={{
display: "flex",
"column-gap": "3px",
}}
>
<button class="btn" disabled={isRunned()} onClick={run}>
Run Run
</Button> </button>
<Button disabled={isRunned()} onClick={inc}> <button class="btn" disabled={isRunned()} onClick={inc}>
+ +
</Button> </button>
<Button disabled={isRunned()} onClick={dec}> <button class="btn" disabled={isRunned()} onClick={dec}>
- -
</Button> </button>
<Button disabled={!isRunned()} onClick={stop}> <button class="btn" disabled={!isRunned()} onClick={stop}>
Stop Stop
</Button> </button>
</div> </div>
</div> </div>
); );

View File

@ -1,24 +1,58 @@
--- ---
import './Footer.scss'; import { Icon } from "astro-icon";
import { Icon } from 'astro-icon'
--- ---
<footer class='footer'> <footer>
<div class='container'> <div class="container">
<ul class='footer__links'> <ul>
<li> <li>
<a href='https://astro.build/' target='_blank'>Astro</a> <a href="https://astro.build/" target="_blank">Astro</a>
<Icon name='astro' width='20' height='20' /> <Icon name="astro" width="20" height="20" />
</li> </li>
<li> <li>
<a href='https://www.netlify.com/' target='_blank'>Netlify</a> <a href="https://www.netlify.com/" target="_blank">Netlify</a>
<Icon name='netlify' width='20' height='20' /> <Icon name="netlify" width="20" height="20" />
</li> </li>
<li> <li>
<a href='/rss.xml' target='_blank'>RSS</a> <a href="/rss.xml" target="_blank">RSS</a>
<Icon name='rss' width='20' height='20' /> <Icon name="rss" width="20" height="20" />
</li> </li>
</ul> </ul>
<p class='footer__copy'>© Sergey Yarkov, 2022</p> <p>© Sergey Yarkov, 2022</p>
</div> </div>
</footer> </footer>
<style lang="scss">
footer {
user-select: none;
text-align: center;
font-weight: 500;
margin-top: 5rem;
& .container {
padding: 25px 0 10px 0;
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
align-items: center;
row-gap: 15px;
}
& ul {
display: flex;
align-items: center;
column-gap: 25px;
& li {
display: flex;
align-items: center;
column-gap: 7px;
& a {
color: var(--font-color);
font-size: var(--font-size-sm);
}
}
}
& p {
text-align: center;
font-size: var(--font-size-xs);
}
}
</style>

View File

@ -1,34 +0,0 @@
.footer {
& .container {
margin-top: 5rem;
padding: 25px 0 10px 0;
font-weight: 500;
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
align-items: center;
row-gap: 15px;
user-select: none;
}
&__links {
display: flex;
align-items: center;
column-gap: 25px;
list-style: none;
margin: 0;
padding: 0;
& li {
display: flex;
align-items: center;
column-gap: 7px;
& a {
color: var(--font-color);
font-size: var(--font-size-sm);
}
}
}
&__copy {
text-align: center;
font-size: var(--font-size-xs);
}
}

View File

@ -1,37 +1,37 @@
--- ---
import { Icon } from "astro-icon"; import { Icon } from "astro-icon";
import { IS_SITE_UNDER_CONSTRUCTION, SITE_METADATA } from "@root/config"; import { IS_SITE_UNDER_CONSTRUCTION, SITE_METADATA } from "@root/config";
import { removeSlashes } from "@utils/string";
import ThemeSwitcher from "@components/ThemeSwticher"; import ThemeSwitcher from "@components/ThemeSwticher";
import UIString from "@components/UIString"; import UIString from "@components/UIString";
import TranslatedLink from "@components/TranslatedLink"; import TranslatedLink from "@components/TranslatedLink";
import LanguageSelect from "@components/LanguageSelect"; import LanguageSelect from "@components/LanguageSelect";
import NavToggle from "./NavToggle"; import NavToggle from "./NavToggle";
import "./Header.scss";
const { name, nav, contacts } = SITE_METADATA; const { name, nav, contacts } = SITE_METADATA;
const { pathname } = Astro.url; const { pathname } = Astro.url;
--- ---
<header class={`header${IS_SITE_UNDER_CONSTRUCTION ? " header-banner" : ""}`}> <header {...(IS_SITE_UNDER_CONSTRUCTION ? { style: 'top: 45px;' } : {})}>
<div class="container"> <div class="container">
<div class="header__logo"> <div class="logo">
<TranslatedLink to={"/"}> <TranslatedLink to={"/"}>
{name} {name}
</TranslatedLink> </TranslatedLink>
</div> </div>
<nav class="header__nav"> <nav>
<ul class="header__nav-links"> <ul>
{ {
nav.map((link) => ( nav.map((link) => (
<li> <li>
<TranslatedLink class={pathname.includes(link.url) && "active"} to={link.url}> <TranslatedLink class={pathname.includes(removeSlashes(link.url)) && pathname.split("/").length < 5 && "active"} to={link.url}>
<UIString key={link.key} /> <UIString key={link.key} />
</TranslatedLink> </TranslatedLink>
</li> </li>
)) ))
} }
</ul> </ul>
<div class="header__nav-contacts"> <div class="contacts">
<a href={"mailto:" + contacts.email}> <a href={"mailto:" + contacts.email}>
Email Email
<Icon name="email" width="18" height="18" /> <Icon name="email" width="18" height="18" />
@ -45,7 +45,7 @@ const { pathname } = Astro.url;
<Icon name="telegram" width="18" height="18" /> <Icon name="telegram" width="18" height="18" />
</a> </a>
</div> </div>
<div class="flex header__nav-settings"> <div class="flex settings">
<LanguageSelect /> <LanguageSelect />
<ThemeSwitcher client:visible /> <ThemeSwitcher client:visible />
</div> </div>
@ -53,3 +53,123 @@ const { pathname } = Astro.url;
<NavToggle client:visible /> <NavToggle client:visible />
</div> </div>
</header> </header>
<style lang="scss">
header {
position: sticky;
top: 0;
border-bottom: 1px solid var(--border-color);
background-color: var(--background-header-color);
padding-top: 13.5px;
padding-bottom: 13.5px;
user-select: none;
z-index: 9999;
& .container {
display: flex;
align-items: center;
justify-content: space-between;
}
& .logo a {
text-decoration: none;
font-weight: 600;
font-size: var(--font-size-lg);
color: var(--font-color);
}
& a:hover,
a.active {
color: var(--color-blue-500);
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 4px;
}
& nav {
display: flex;
align-items: center;
& ul {
display: flex;
list-style: none;
column-gap: 20px;
margin: 0;
padding: 0;
margin-right: 35px;
& li a {
display: inline-block;
font-weight: 500;
color: var(--font-color);
text-decoration: none;
text-transform: lowercase;
}
}
& .contacts {
display: flex;
align-items: center;
column-gap: 10px;
margin-right: 30px;
& a {
text-decoration: none;
font-size: 0;
&:hover {
opacity: var(--opacity-hover);
}
}
}
& .settings {
column-gap: 13.5px;
}
}
}
@media screen and (max-width: 768px) {
header {
padding: 10px 0;
& nav {
display: none;
}
}
body[data-is-mobile-open="true"] {
overflow-y: hidden;
touch-action: none;
& header {
padding: 15px 0;
background-color: var(--background-header-color);
position: fixed;
right: 0;
left: 0;
top: 0;
bottom: 0;
height: 100%;
z-index: 9999;
& .container {
flex-direction: column;
row-gap: 3rem;
}
& .logo {
align-self: flex-start;
}
& nav {
display: block;
& ul {
display: block;
text-align: center;
margin: 0;
& li {
margin-bottom: 30px;
& a {
font-size: var(--font-size-xl);
}
}
}
& .contacts {
margin-right: 0;
margin-top: 2rem;
justify-content: center;
}
}
}
}
}
</style>

View File

@ -1,144 +0,0 @@
.header {
position: sticky;
top: 0;
border-bottom: 1px solid var(--border-color);
background-color: var(--background-header-color);
padding-top: 13.5px;
padding-bottom: 13.5px;
user-select: none;
z-index: 9999;
& .container {
display: flex;
align-items: center;
justify-content: space-between;
}
&__logo a {
text-decoration: none;
font-weight: 600;
font-size: var(--font-size-lg);
color: var(--font-color);
&:hover {
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 5px;
}
}
&__nav {
display: flex;
align-items: center;
& ul {
display: flex;
list-style: none;
column-gap: 20px;
margin: 0;
padding: 0;
margin-right: 35px;
& li a {
display: inline-block;
font-weight: 500;
color: var(--font-color);
text-decoration: none;
text-transform: lowercase;
&:hover,
&.active {
color: var(--color-blue-500);
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 4px;
}
}
}
&-contacts {
display: flex;
align-items: center;
column-gap: 10px;
margin-right: 30px;
& a {
text-decoration: none;
font-size: 0;
&:hover {
opacity: var(--opacity-hover);
}
}
}
&-settings {
column-gap: 13.5px;
}
}
}
@media screen and (max-width: 768px) {
.header {
padding: 10px 0;
&__nav {
display: none;
}
}
body[data-is-mobile-open="true"] {
overflow-y: hidden;
touch-action: none;
& .header {
padding: 15px 0;
background-color: var(--background-header-color);
position: fixed;
right: 0;
left: 0;
top: 0;
bottom: 0;
height: 100%;
z-index: 9999;
& .container {
flex-direction: column;
row-gap: 3rem;
}
&__logo {
align-self: flex-start;
}
&__nav {
display: block;
&-links {
display: block;
text-align: center;
margin: 0;
& li {
margin-bottom: 30px;
& a {
font-size: var(--font-size-xl);
}
}
}
&-contacts {
margin-right: 0;
margin-top: 2rem;
justify-content: center;
}
}
& .nav-toggle {
position: absolute;
right: var(--content-padding);
}
& .theme-switcher {
display: block;
position: absolute;
bottom: 45px;
right: 2.2rem;
& svg {
width: 36px;
height: 36px;
}
}
& .language-select {
position: absolute;
bottom: 115px;
right: 2.2rem;
& svg,
& select {
height: 36px;
width: 36px;
}
}
}
}
}

View File

@ -13,3 +13,10 @@
display: none; display: none;
} }
} }
body[data-is-mobile-open="true"] {
.nav-toggle {
position: absolute;
right: var(--content-padding);
}
}

View File

@ -1,13 +1,9 @@
<div class="banner-wrapper"> <div>
<span><slot /></span> <span><slot /></span>
</div> </div>
<style lang="scss" is:global> <style lang="scss">
.header.header-banner { div {
top: 45px;
}
.banner-wrapper {
padding: 12px 0; padding: 12px 0;
min-height: 45px; min-height: 45px;
text-align: center; text-align: center;
@ -17,18 +13,4 @@
top: 0; top: 0;
z-index: 99999; z-index: 99999;
} }
@media screen and (max-width: 768px) {
body[data-is-mobile-open="true"] {
& .header.header-banner {
margin-top: 45px;
& .language-select {
bottom: 145px;
}
& .theme-switcher {
bottom: 80px;
}
}
}
}
</style> </style>

View File

@ -42,6 +42,27 @@ const lang = getLanguageFromURL(Astro.url.pathname);
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
cursor: pointer; cursor: pointer;
} }
body[data-is-mobile-open="true"] {
.language-select {
position: absolute;
bottom: 115px;
right: 2.2rem;
& svg,
& select {
height: 36px;
width: 36px;
}
}
}
@media screen and (max-width: 768px) {
body[data-is-site-under-construction] {
.language-select {
bottom: 145px;
}
}
}
</style> </style>
<script define:vars={{ lang, DEFAULT_LANGUAGE }}> <script define:vars={{ lang, DEFAULT_LANGUAGE }}>

View File

@ -1,6 +1,4 @@
--- ---
import "./PageHeading.scss";
export interface Props { export interface Props {
heading: string; heading: string;
description?: string | undefined; description?: string | undefined;
@ -10,8 +8,25 @@ const { heading, description } = Astro.props as Props;
--- ---
<div class="page-heading"> <div class="page-heading">
<h1 style={!description && "margin-bottom: 2.5rem;"}> <h1>
{heading} {heading}
</h1> </h1>
{description && <p>{description}</p>} {description && <p>{description}</p>}
</div> </div>
<style lang="scss">
div {
margin-bottom: 2.5rem;
& h1 {
font-size: var(--font-size-4xl);
text-align: center;
margin-bottom: 15px;
}
& p {
font-size: var(--font-size-lg);
text-align: center;
max-width: 650px;
margin: 0 auto;
}
}
</style>

View File

@ -1,14 +0,0 @@
.page-heading {
& h1 {
font-size: var(--font-size-4xl);
text-align: center;
margin-bottom: 15px;
}
& p {
font-size: var(--font-size-lg);
text-align: center;
max-width: 650px;
margin: 0 auto;
margin-bottom: 2.5rem;
}
}

View File

@ -1,7 +1,7 @@
--- ---
import './ProjectsList.scss'; import "./ProjectsList.scss";
--- ---
<div class='projects-list'> <div class="projects-list">
<slot></slot> <slot />
</div> </div>

View File

@ -48,7 +48,6 @@ const SearchInput: Component<SearchInputProps> = (props) => {
resetSearch(); resetSearch();
return; return;
} }
console.log(param);
setSearch(param); setSearch(param);
}); });

View File

@ -0,0 +1,17 @@
<main>
<slot />
</main>
<style>
main {
flex: 1 0 auto;
width: 100%;
margin-top: 5rem;
}
@media (max-width: 768px) {
main {
margin-top: 3rem;
}
}
</style>

View File

@ -0,0 +1 @@
export { default } from "./SiteContent.astro";

View File

@ -1,5 +1,5 @@
--- ---
import './SkillsList.scss'; import "./SkillsList.scss";
--- ---
<div class='skills-list'><slot></slot></div> <div class="skills-list"><slot /></div>

View File

@ -1,26 +0,0 @@
.spinner {
--spinner-size: 1em;
display: inline-block;
border-top: 1px solid var(--font-color);
border-right: 1px solid var(--font-color);
border-bottom-style: solid;
border-left-style: solid;
border-radius: 99999px;
border-bottom-width: 1px;
border-left-width: 1px;
border-bottom-color: transparent;
border-left-color: transparent;
animation: 0.45s linear 0s infinite normal none running spinner;
width: var(--spinner-size);
height: var(--spinner-size);
}
@keyframes spinner {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@ -1,11 +0,0 @@
import type { Component, JSX } from "solid-js";
import "./Spinner.scss";
type SpinnerProps = JSX.HTMLAttributes<HTMLDivElement>;
const Spinner: Component<SpinnerProps> = (props) => {
return <div class="spinner" {...props} />;
};
export default Spinner;

View File

@ -1 +0,0 @@
export { default } from "./Spinner";

View File

@ -6,7 +6,7 @@
header { header {
& :global(p) { & :global(p) {
font-size: var(--font-size-lg); font-size: var(--font-size-lg);
max-width: 570px; max-width: 520px;
line-height: 1.5; line-height: 1.5;
} }

View File

@ -16,3 +16,16 @@
transition: all 0.1s linear 0s; transition: all 0.1s linear 0s;
} }
} }
body[data-is-mobile-open="true"] {
.theme-switcher {
display: block;
position: absolute;
bottom: 45px;
right: 2.2rem;
& svg {
width: 36px;
height: 36px;
}
}
}

View File

@ -1,3 +1,4 @@
import { IS_SITE_UNDER_CONSTRUCTION } from "@root/config";
import type { Component, JSX } from "solid-js"; import type { Component, JSX } from "solid-js";
import { createSignal, onMount, Switch, Match } from "solid-js"; import { createSignal, onMount, Switch, Match } from "solid-js";
@ -50,7 +51,12 @@ const ThemeSwitcher: Component = () => {
}); });
return ( return (
<button onClick={toggleTheme} class={isReady() ? "theme-switcher ready" : "theme-switcher"} aria-label={theme()}> <button
onClick={toggleTheme}
class={isReady() ? "theme-switcher ready" : "theme-switcher"}
aria-label={theme()}
{...(IS_SITE_UNDER_CONSTRUCTION ? { style: "bottom:80px;" } : {})}
>
<Switch <Switch
children={ children={
<> <>

View File

@ -1,14 +1,7 @@
export const DEFAULT_LANGUAGE: LanguageKeys = "ru"; export const DEFAULT_LANGUAGE: LanguageKeys = "ru";
/* Show banner about website development */ /* Show banner about website development */
export const IS_SITE_UNDER_CONSTRUCTION = true; export const IS_SITE_UNDER_CONSTRUCTION = import.meta.env.IS_SITE_UNDER_CONSTRUCTION === "true" ? true : false;
export const UTTERANCES_CONFIG: UtterancesConfigType = {
repo: "sergeyyarkov/comments",
issueTerm: "title",
label: "Comments",
theme: "github-light",
};
export const SITE_METADATA: SiteMetadataType = { export const SITE_METADATA: SiteMetadataType = {
title: "Sergey Yarkov", title: "Sergey Yarkov",
@ -32,8 +25,8 @@ export const SITE_METADATA: SiteMetadataType = {
name: "yarkov.tech", name: "yarkov.tech",
year: "2022", year: "2022",
description: { description: {
ru: "Мой персональный веб-сайт где находятся информация обо мне и список различных статей, туториалов...", ru: "Мой персональный веб-сайт где находятся информация обо мне и список различных статей и туториалов...",
en: "My personal website where you can find information about me and a list of various articles, tutorials...", en: "My personal website where you can find information about me and a list of various articles and tutorials...",
}, },
sourceUrl: "https://github.com/sergeyyarkov/yarkov.tech", sourceUrl: "https://github.com/sergeyyarkov/yarkov.tech",
demoUrl: "https://yarkov.tech", demoUrl: "https://yarkov.tech",
@ -48,7 +41,7 @@ export const SITE_METADATA: SiteMetadataType = {
en: "Static website for publishing various articles, developed with Gatsby.js and NetlifyCMS", en: "Static website for publishing various articles, developed with Gatsby.js and NetlifyCMS",
}, },
sourceUrl: "https://github.com/sergeyyarkov/devthread.ru", sourceUrl: "https://github.com/sergeyyarkov/devthread.ru",
demoUrl: "https://devthread.netlify.app", demoUrl: "https://devthread.yarkov.tech",
articleUrl: undefined, articleUrl: undefined,
}, },
{ {
@ -60,7 +53,7 @@ export const SITE_METADATA: SiteMetadataType = {
en: "Learning Management System (LMS) to publish your courses, lectures, lessons", en: "Learning Management System (LMS) to publish your courses, lectures, lessons",
}, },
sourceUrl: "https://github.com/sergeyyarkov/educt", sourceUrl: "https://github.com/sergeyyarkov/educt",
demoUrl: "https://client-educt-lms.netlify.app", demoUrl: "https://educt.yarkov.tech",
articleUrl: undefined, articleUrl: undefined,
}, },
{ {

3
src/env.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
interface ImportMetaEnv {
readonly IS_SITE_UNDER_CONSTRUCTION: string;
}

View File

@ -1 +0,0 @@
export * from "./useForm";

View File

@ -1,80 +0,0 @@
import type { Accessor } from "solid-js";
import { createStore, SetStoreFunction } from "solid-js/store";
type HTMLElementsType = HTMLInputElement | HTMLTextAreaElement;
type CustomValidatorType = (element: HTMLElementsType) => string | false;
type FieldConfigType = { element: HTMLElementsType; validators: Array<CustomValidatorType> };
type FormSubmitFnType = (ref: HTMLFormElement, accessor: Accessor<(form: HTMLFormElement) => unknown>) => void;
type ValidateFnType = (ref: HTMLElementsType, accessor: Accessor<Array<CustomValidatorType>>) => void;
type СheckValidFnType = (config: FieldConfigType, setErrors: SetStoreFunction<Record<string, unknown>>, errorClass: string) => void;
const checkValid: СheckValidFnType = (fieldConfig, setErrors, errorClass) => {
const { element, validators = [] } = fieldConfig;
element.setCustomValidity("");
element.checkValidity();
let message = element.validationMessage;
/* Run custom validators */
if (Array.isArray(validators)) {
if (!message) {
for (const validator of validators) {
if (typeof validator === "function") {
const text = validator(element);
if (text) {
element.setCustomValidity(text);
break;
}
}
}
message = element.validationMessage;
}
}
/* Set errors if message exists and class to input element */
if (message) {
errorClass && element.classList.toggle(errorClass, true);
setErrors({ [element.name]: message });
}
};
export const useForm = ({ errorClass }) => {
const [errors, setErrors] = createStore<{ [key: string]: string | undefined }>({});
const fields = {};
const validate: ValidateFnType = (ref, accessor) => {
const validators = accessor();
let config: FieldConfigType;
fields[ref.name] = config = { element: ref, validators };
ref.onblur = () => checkValid(config, setErrors, errorClass);
ref.oninput = () => {
if (!errors[ref.name]) return;
setErrors({ [ref.name]: undefined });
errorClass && ref.classList.toggle(errorClass, false);
};
};
const formSubmit: FormSubmitFnType = (ref, accessor) => {
const callback = accessor();
ref.setAttribute("novalidate", "");
ref.onsubmit = (e) => {
e.preventDefault();
let isError = false;
/* Validate all fields of form */
for (const key in fields) {
const field = fields[key] as FieldConfigType;
checkValid(field, setErrors, errorClass);
if (!isError && field.element.validationMessage) {
field.element.focus();
isError = true;
}
}
if (typeof callback === "function") !isError && callback(ref);
};
};
return { validate, formSubmit, errors };
};

View File

@ -1,8 +1,9 @@
export default { export default {
"site.title": "yarkov.tech", "site.title": "yarkov.tech",
"site.description": "site.description":
"Here I publish articles and tutorials on various IT topics, as well as talk about my projects that I have done over the past few years.", "Here I publish articles and tutorials on various IT topics, and I also talk about my projects that I have done over the past few years.",
"site.underConstruction": "This web-site is under development...", "site.underConstruction": "This web-site is under development...",
"article.availableOn": "Available on",
"articles.empty": "No articles found...", "articles.empty": "No articles found...",
"articlePost.comments": "Comments", "articlePost.comments": "Comments",
"recentArticles.empty": "There is nothing here yet...", "recentArticles.empty": "There is nothing here yet...",
@ -13,18 +14,18 @@ export default {
"pageTitle.contacts": "Contacts", "pageTitle.contacts": "Contacts",
/* Page descriptions */ /* Page descriptions */
"pageDescription.articles": "Technical articles, tutorials and various reference materials related to IT topics can be found here.", "pageDescription.articles": "Technical articles, tutorials and various reference materials related to IT topics can be found here.",
"pageDescription.projects": "A few of my main projects that have been developed and made available to the public.", "pageDescription.projects": "A few of my main projects that have been developed and published in open access.",
"pageDescription.contacts": "Use the feedback form or write to me on social networks.", "pageDescription.contacts": "Use the feedback form or write to me on social networks.",
/* Navigation */ /* Navigation */
"navigation.articles": "Articles", "navigation.articles": "Articles",
"navigation.projects": "Projects", "navigation.projects": "Projects",
"navigation.about": "About", "navigation.about": "About me",
"navigation.contacts": "Contacts", "navigation.contacts": "Contacts",
/* Splash page */ /* Home page */
"splash.headline": "Hello, I'am Sergey", "splash.headline": "Hello, I'am Sergey",
"splash.subheadline": "Fullstack web-developer & freelancer", "splash.subheadline": "Fullstack web-developer & freelancer",
"splash.description": "splash.description":
"I develop websites and web applications of varying complexity, as well as publish various articles and tutorials on various IT topics.", "I develop websites and web applications of varying complexity, and also publish articles and tutorials on various IT topics.",
"splash.about": "about me", "splash.about": "about me",
"splash.contact": "write me", "splash.contact": "write me",
/* Recent articles */ /* Recent articles */
@ -34,8 +35,8 @@ export default {
"404.heading": "404", "404.heading": "404",
"404.description": "The page you are trying to find does not exist.", "404.description": "The page you are trying to find does not exist.",
/* Buttons */ /* Buttons */
"btn.send": "Submit", "btn.submit": "Submit",
"btn.sending": "Submitting...", "btn.submiting": "Submitting...",
"btn.sourceCode": "Source", "btn.sourceCode": "Source",
"btn.demo": "Demo", "btn.demo": "Demo",
"btn.article": "Article", "btn.article": "Article",

View File

@ -1,8 +1,9 @@
export default { export default {
"site.title": "yarkov.tech", "site.title": "yarkov.tech",
"site.description": "site.description":
"Здесь я публикую статьи и туториалы на различные IT темы, а также рассказываю о своих проектах которые я сделал за последние несколько лет.", "Здесь я публикую статьи и туториалы на разные IT темы, а также рассказываю о своих проектах которые я сделал за последние несколько лет.",
"site.underConstruction": "Этот веб-сайт находится в разработке...", "site.underConstruction": "Этот веб-сайт находится в разработке...",
"article.availableOn": "Доступно на",
"articles.empty": "Не найдено ни одной статьи...", "articles.empty": "Не найдено ни одной статьи...",
"articlePost.comments": "Комментарии", "articlePost.comments": "Комментарии",
"recentArticles.empty": "Пока что здесь ничего нет...", "recentArticles.empty": "Пока что здесь ничего нет...",
@ -20,11 +21,11 @@ export default {
"navigation.projects": "Проекты", "navigation.projects": "Проекты",
"navigation.about": "Обо мне", "navigation.about": "Обо мне",
"navigation.contacts": "Контакты", "navigation.contacts": "Контакты",
/* Splash page */ /* Home page */
"splash.headline": "Hello, I'am Sergey", "splash.headline": "Hello, I'am Sergey",
"splash.subheadline": "Fullstack web-developer & freelancer", "splash.subheadline": "Fullstack web-developer & freelancer",
"splash.description": "splash.description":
"Я занимаюсь разработкой веб-сайтов и веб-приложений разной сложности, а также публикую различные статьи и туториалы на разные IT темы.", "Я занимаюсь разработкой веб-сайтов и веб-приложений разной сложности, а также публикую статьи и туториалы на разные IT темы.",
"splash.about": "обо мне", "splash.about": "обо мне",
"splash.contact": "написать мне", "splash.contact": "написать мне",
/* Recent articles */ /* Recent articles */
@ -34,8 +35,8 @@ export default {
"404.heading": "404", "404.heading": "404",
"404.description": "Страницу которую вы пытаетесь найти не существует.", "404.description": "Страницу которую вы пытаетесь найти не существует.",
/* Buttons */ /* Buttons */
"btn.send": "Отправить", "btn.submit": "Отправить",
"btn.sending": "Отправка...", "btn.submitting": "Отправка...",
"btn.sourceCode": "Исходник", "btn.sourceCode": "Исходник",
"btn.demo": "Демо", "btn.demo": "Демо",
"btn.article": "Статья", "btn.article": "Статья",

View File

@ -12,40 +12,14 @@ export function formatArticlesByLangs(data: Record<string, MDXInstance<ArticleTy
const splitted = module.file.split("/"); const splitted = module.file.split("/");
const lang = splitted.at(-2); const lang = splitted.at(-2);
const name = splitted.at(-3); const name = splitted.at(-3);
if (!modules[name]) modules[name] = {}; if (!modules[name]) modules[name] = {};
modules[name][lang] = module; if (!module.frontmatter.draft) modules[name][lang] = module;
} }
return modules; return modules;
} }
export function getTranslatedArticles(Astro: Readonly<AstroGlobal>): MDXInstance<ArticleType>[] {
const data = import.meta.glob<MDXInstance<ArticleType>>("../../content/articles/**/*.mdx", { eager: true });
const modules = formatArticlesByLangs(data);
const siteLang = utils.getLanguageFromURL(Astro.url.pathname);
const articles: MDXInstance<ArticleType>[] = [];
for (const key of Object.keys(modules)) {
const langs = Object.keys(modules[key]);
for (const articleLang of langs) {
const isNonDefaultLanguage = !langs.includes(siteLang) && langs.length === 1;
const article = modules[key];
if (articleLang === siteLang) {
articles.push(article[siteLang]);
article[siteLang].frontmatter.lang = siteLang;
}
if (isNonDefaultLanguage) {
articles.push(article[articleLang]);
article[articleLang].frontmatter.lang = articleLang;
}
}
}
return articles;
}
export function getLanguageKeys(): Array<string> { export function getLanguageKeys(): Array<string> {
return Object.keys(languages); return Object.keys(languages);
} }

View File

@ -1,24 +1,42 @@
--- ---
import type { Props as BaseHeadProps } from "@root/components/BaseHead.astro"; import type { Props as BaseHeadProps } from "@root/components/BaseHead.astro";
import { extname } from "node:path";
import mime from "mime-types";
import BaseHead from "@components/BaseHead.astro"; import BaseHead from "@components/BaseHead.astro";
import BaseLayout from "@components/BaseLayout"; import BaseLayout from "@components/BaseLayout";
import ArticleContent from "@components/ArticleContent"; import ArticleContent from "@components/ArticleContent";
import ArticlePost from "@components/ArticlePost"; import ArticlePost from "@components/ArticlePost";
import { SITE_METADATA } from "@root/config";
import { isURL } from '@utils/string'
import "@styles/global.scss"; import "@styles/global.scss";
export interface Props extends BaseHeadProps { export interface Props extends BaseHeadProps {
lang?: string | undefined; lang?: string | undefined;
content: ArticleType; content: ArticleType;
canonicalUrl?: string | undefined;
} }
const { lang = "ru", content } = Astro.props as Props; const { lang = "ru", content, canonicalUrl } = Astro.props as Props;
--- ---
<html lang={lang}> <html lang={lang}>
<BaseHead title={content.title} description={content.description}> <BaseHead title={content.title} description={content.description} canonicalUrl={canonicalUrl}>
<meta property="og:title" content={content.title} />
<meta property="og:site_name" content={SITE_METADATA.name} />
<meta property="og:description" content={content.description} />
<meta property="og:type" content="article" />
<meta property="og:url" content={canonicalUrl} />
{content.coverImage && (
<meta property="og:image" content={isURL(content.coverImage) ? content.coverImage : `${Astro.url.origin}${content.coverImage}`} />
<meta property="og:image:width" content="700" />
<meta property="og:image:height" content="400" />
<meta property="og:image:type" content={`${mime.lookup(extname(content.coverImage))}`} />
)}
<script is:inline defer src="/assets/js/load-code-highlight.js"></script> <script is:inline defer src="/assets/js/load-code-highlight.js"></script>
</BaseHead> </BaseHead>
<BaseLayout> <BaseLayout>
<div class="container"> <div class="container">
<ArticlePost lang={lang} data={content}> <ArticlePost lang={lang} data={content}>

View File

@ -4,25 +4,23 @@ import type { Props as BaseHeadProps } from "@components/BaseHead.astro";
import { SITE_METADATA } from "@root/config"; import { SITE_METADATA } from "@root/config";
import BaseHead from "@components/BaseHead.astro"; import BaseHead from "@components/BaseHead.astro";
import BaseLayout from "@components/BaseLayout"; import BaseLayout from "@components/BaseLayout";
import PageHeading from "@components/PageHeading";
import "@styles/global.scss"; import "@styles/global.scss";
export interface Props extends BaseHeadProps { export interface Props extends BaseHeadProps {
lang?: string | undefined; lang?: string | undefined;
showPageHeading?: boolean | undefined; noIndex?: boolean | undefined;
noIndex?: boolean; canonicalUrl?: string | undefined;
} }
const { title, description, lang = "ru", showPageHeading = true, noIndex = false } = Astro.props as Props; const { title, description, lang = "ru", noIndex = false, canonicalUrl } = Astro.props as Props;
--- ---
<html lang={lang}> <html lang={lang}>
<BaseHead title={title ? `${title} | ${SITE_METADATA.title}` : SITE_METADATA.title} description={description}> <BaseHead title={title ? `${title} | ${SITE_METADATA.title}` : SITE_METADATA.title} description={description} canonicalUrl={canonicalUrl}>
{noIndex && <meta name="robots" content="noindex" />} {noIndex && <meta name="robots" content="noindex" />}
</BaseHead> </BaseHead>
<BaseLayout> <BaseLayout>
<div class="container"> <div class="container">
{showPageHeading && <PageHeading heading={title} description={description} />}
<slot /> <slot />
</div> </div>
</BaseLayout> </BaseLayout>

59
src/pages/[...slug].astro Normal file
View File

@ -0,0 +1,59 @@
---
import type { MDXInstance } from "astro";
import ArticleLayout from "@layouts/article.astro";
import { getLanguageFromURL } from "@root/utils";
import { DEFAULT_LANGUAGE } from "@root/config";
import { formatArticlesByLangs, useTranslation } from "@i18n/utils";
import languages from "@i18n/languages";
const t = useTranslation(Astro);
const siteLang = getLanguageFromURL(Astro.url.pathname);
const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
export interface Props {
article: MDXInstance<ArticleType>;
alternateLangs: LanguageKeys[];
}
export async function getStaticPaths() {
const modules = import.meta.glob<MDXInstance<ArticleType>>("../../content/articles/**/*.mdx", { eager: true });
const entries = Object.keys(modules).map((key) => [key, key.split("/").slice(6)[0]]);
const formatted = formatArticlesByLangs(modules);
return entries
.map(([key, lang]) => {
const article = modules[key];
const dirName = key.split("/").at(-3);
const published_at = new Date(article.frontmatter.published_at).toLocaleDateString("en-CA");
const slug = `${lang !== DEFAULT_LANGUAGE ? `${lang}/` : ""}blog/${published_at}/${article.frontmatter.slug}`;
return { params: { slug }, props: { article, alternateLangs: Object.keys(formatted[dirName]) } };
})
.filter(({ props: { article } }) => !article.frontmatter.draft);
}
const {
article: { Content, frontmatter },
alternateLangs,
} = Astro.props as Props;
const { pathname } = Astro.url;
---
<ArticleLayout lang={siteLang} content={frontmatter} canonicalUrl={canonicalUrl.href}>
{
alternateLangs.length > 1 && (
<div class="languages-available">
{t("article.availableOn")}:
{alternateLangs
.filter((lang) => lang !== siteLang)
.map((lang) => (
<a href={DEFAULT_LANGUAGE === lang ? `/${pathname.split("/").slice(2).join("/")}` : `/${lang}${pathname}`}>{languages[lang]}</a>
))
.reduce((prev, curr) => [prev, ", ", curr])}
</div>
)
}
<Content />
</ArticleLayout>

View File

@ -1,28 +0,0 @@
---
import type { MDXInstance } from "astro";
import ArticleLayout from "@layouts/article.astro";
import { getLanguageFromURL } from "@root/utils";
import { DEFAULT_LANGUAGE } from "@root/config";
const lang = getLanguageFromURL(Astro.url.pathname);
export async function getStaticPaths() {
const modules = import.meta.glob<MDXInstance<ArticleType>>("../../../content/articles/**/*.mdx", { eager: true });
const entries = Object.keys(modules).map((key) => [key, key.split("/").slice(7)[0]]);
return entries.map(([key, lang]) => {
const article = modules[key];
const published_at = new Date(article.frontmatter.published_at).toLocaleDateString("en-CA");
const slug = `${published_at}/${lang !== DEFAULT_LANGUAGE ? lang + "/" : ""}${article.frontmatter.slug}`;
return { params: { slug }, props: article };
});
}
const { Content, frontmatter } = Astro.props as MDXInstance<ArticleType>;
---
<ArticleLayout lang={lang} content={frontmatter}>
<Content />
</ArticleLayout>

View File

@ -14,9 +14,10 @@ for (const key of Object.keys(articles)) {
const { title, description, slug, published_at, coverImage } = article[lang].frontmatter; const { title, description, slug, published_at, coverImage } = article[lang].frontmatter;
items.push({ items.push({
link: link:
`${import.meta.env.SITE}blog/` + `${import.meta.env.SITE}` +
`${lang !== DEFAULT_LANGUAGE ? `${lang}/` : ""}` +
"blog/" +
new Date(published_at).toLocaleDateString("en-CA") + new Date(published_at).toLocaleDateString("en-CA") +
`${lang !== DEFAULT_LANGUAGE ? "/" + lang : ""}` +
`/${slug}/`, `/${slug}/`,
title, title,
description: `${description}${coverImage !== undefined ? `<img src="${coverImage}" alt="${title}">` : ""}`, description: `${description}${coverImage !== undefined ? `<img src="${coverImage}" alt="${title}">` : ""}`,

View File

@ -0,0 +1,31 @@
import type { AstroGlobal, MDXInstance } from "astro";
import { formatArticlesByLangs } from "@i18n/utils";
import { getLanguageFromURL } from "@utils/url";
class ArticleService {
public getTranslatedArticles(Astro: Readonly<AstroGlobal>): MDXInstance<ArticleType>[] {
const data = import.meta.glob<MDXInstance<ArticleType>>("../../content/articles/**/*.mdx", { eager: true });
const modules = formatArticlesByLangs(data);
const siteLang = getLanguageFromURL(Astro.url.pathname);
const articles: MDXInstance<ArticleType>[] = [];
const push = (article: MDXInstance<ArticleType>, lang: LanguageKeys) => {
articles.push(article);
article.frontmatter.lang = lang;
};
for (const key of Object.keys(modules)) {
const langs = Object.keys(modules[key]) as LanguageKeys[];
for (const lang of langs) {
const isNonDefaultLanguage = !langs.includes(siteLang) && langs.length === 1;
const article = modules[key];
if (lang === siteLang) push(article[siteLang], siteLang);
if (isNonDefaultLanguage) push(article[lang], lang);
}
}
return articles.filter((article) => !article.frontmatter.draft);
}
}
export default new ArticleService();

1
src/services/index.ts Normal file
View File

@ -0,0 +1 @@
export { default as ArticleService } from "./ArticleService";

View File

@ -130,6 +130,13 @@ a {
} }
button { button {
cursor: pointer;
background: none; background: none;
border: none; border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
padding: 5px 16px;
color: var(--font-color);
&:hover {
border: 1px solid var(--border-color-hover);
}
} }

View File

@ -138,8 +138,8 @@
--icon-secondary: var(--color-gray-100); --icon-secondary: var(--color-gray-100);
--icon-filter: invert(100%); --icon-filter: invert(100%);
& .header svg, & body > header svg,
& .site-content svg:not([data-filter="none"]) { & body > main svg:not([data-filter="none"]) {
filter: var(--icon-filter); filter: var(--icon-filter);
} }
} }

View File

@ -1,6 +1,7 @@
--- ---
import { SITE_METADATA } from "@root/config"; import { SITE_METADATA } from "@root/config";
import PageLayout from "@layouts/default.astro"; import PageLayout from "@layouts/default.astro";
import PageHeading from "@components/PageHeading";
import SkillsList from "@components/SkillsList"; import SkillsList from "@components/SkillsList";
import SkillsBlock from "@components/SkillsBlock"; import SkillsBlock from "@components/SkillsBlock";
@ -15,6 +16,7 @@ const { skills } = SITE_METADATA;
--- ---
<PageLayout noIndex lang={lang} title={t("pageTitle.about")}> <PageLayout noIndex lang={lang} title={t("pageTitle.about")}>
<PageHeading heading={t("pageTitle.about")} />
<section> <section>
<ArticleContent> <ArticleContent>
<Content /> <Content />

View File

@ -1,18 +1,22 @@
--- ---
import PageLayout from "@layouts/default.astro"; import PageLayout from "@layouts/default.astro";
import PageHeading from "@components/PageHeading";
import ArticlesList from "@components/ArticlesList"; import ArticlesList from "@components/ArticlesList";
import { transformArticles, formatToArticleBlocks } from "@utils/articles"; import { transformArticles, formatToArticleBlocks } from "@utils/articles";
import { getLanguageFromURL } from "@root/utils"; import { getLanguageFromURL } from "@root/utils";
import { useTranslation, getTranslatedArticles } from "@i18n/utils"; import { useTranslation } from "@i18n/utils";
import { ArticleService } from "@services/index";
const t = useTranslation(Astro); const t = useTranslation(Astro);
const data = getTranslatedArticles(Astro); const data = ArticleService.getTranslatedArticles(Astro);
const tags = new Set(data.map((article) => article.frontmatter.tags).flat()); const tags = new Set(data.map((article) => article.frontmatter.tags).flat());
const lang = getLanguageFromURL(Astro.url.pathname); const lang = getLanguageFromURL(Astro.url.pathname);
const articles = transformArticles(data); const articles = transformArticles(data);
const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
--- ---
<PageLayout lang={lang} title={t("pageTitle.articles")} description={t("pageDescription.articles")}> <PageLayout lang={lang} title={t("pageTitle.articles")} description={t("pageDescription.articles")} canonicalUrl={canonicalUrl.href}>
<PageHeading heading={t("pageTitle.articles")} description={t("pageDescription.articles")} />
<section> <section>
<ArticlesList <ArticlesList
client:load client:load

View File

@ -1,5 +1,6 @@
--- ---
import PageLayout from "@layouts/default.astro"; import PageLayout from "@layouts/default.astro";
import PageHeading from "@components/PageHeading";
import ContactForm from "@components/ContactForm"; import ContactForm from "@components/ContactForm";
import ContactsList from "@components/ContactsList"; import ContactsList from "@components/ContactsList";
import { getLanguageFromURL } from "@root/utils"; import { getLanguageFromURL } from "@root/utils";
@ -10,20 +11,9 @@ const lang = getLanguageFromURL(Astro.url.pathname);
--- ---
<PageLayout noIndex lang={lang} title={t("pageTitle.contacts")} description={t("pageDescription.contacts")}> <PageLayout noIndex lang={lang} title={t("pageTitle.contacts")} description={t("pageDescription.contacts")}>
<PageHeading heading={t("pageTitle.contacts")} description={t("pageDescription.contacts")} />
<section> <section>
<ContactForm <ContactForm />
i18n={{
"input.nameField": t("input.nameField"),
"input.subjectField": t("input.subjectField"),
"input.messageField": t("input.messageField"),
"btn.send": t("btn.send"),
"btn.sending": t("btn.sending"),
"error.invalidEmail": t("error.invalidEmail"),
"form.contactSuccess": t("form.contactSuccess"),
"form.contactError": t("form.contactError"),
}}
client:load
/>
<ContactsList /> <ContactsList />
</section> </section>
</PageLayout> </PageLayout>

View File

@ -4,16 +4,18 @@ import PageLayout from "@layouts/default.astro";
import RecentArticles from "@components/RecentArticles"; import RecentArticles from "@components/RecentArticles";
import { Splash, SplashHeading, SplashLinks, Link } from "@components/Splash"; import { Splash, SplashHeading, SplashLinks, Link } from "@components/Splash";
import { getLanguageFromURL } from "@root/utils"; import { getLanguageFromURL } from "@root/utils";
import { getTranslatedArticles, useTranslation } from "@i18n/utils"; import { useTranslation } from "@i18n/utils";
import { ArticleService } from "@services/index";
import UIString from "@components/UIString"; import UIString from "@components/UIString";
const data = getTranslatedArticles(Astro); const data = ArticleService.getTranslatedArticles(Astro);
const t = useTranslation(Astro); const t = useTranslation(Astro);
const lang = getLanguageFromURL(Astro.url.pathname); const lang = getLanguageFromURL(Astro.url.pathname);
const articles = utils.transformArticles(data); const articles = utils.transformArticles(data);
const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
--- ---
<PageLayout lang={lang} description={t("site.description")} showPageHeading={false}> <PageLayout lang={lang} description={t("site.description")} canonicalUrl={canonicalUrl.href}>
<Splash> <Splash>
<SplashHeading> <SplashHeading>
<hgroup> <hgroup>

View File

@ -7,7 +7,7 @@ const lang = getLanguageFromURL(Astro.url.pathname);
const t = useTranslation(Astro); const t = useTranslation(Astro);
--- ---
<PageLayout lang={lang} title="404" showPageHeading={false}> <PageLayout lang={lang} title="404">
<div class="container"> <div class="container">
<h1 style="font-size:var(--font-size-5xl);">404</h1> <h1 style="font-size:var(--font-size-5xl);">404</h1>
<p>{t("404.description")}</p> <p>{t("404.description")}</p>

View File

@ -3,6 +3,7 @@ import { SITE_METADATA } from "@root/config";
import PageLayout from "@layouts/default.astro"; import PageLayout from "@layouts/default.astro";
import ProjectsList from "@components/ProjectsList"; import ProjectsList from "@components/ProjectsList";
import ProjectItem from "@components/ProjectItem"; import ProjectItem from "@components/ProjectItem";
import PageHeading from "@components/PageHeading";
import { getLanguageFromURL } from "@root/utils"; import { getLanguageFromURL } from "@root/utils";
import { useTranslation } from "@i18n/utils"; import { useTranslation } from "@i18n/utils";
@ -12,6 +13,7 @@ const { projects } = SITE_METADATA;
--- ---
<PageLayout noIndex lang={lang} title={t("pageTitle.projects")} description={t("pageDescription.projects")}> <PageLayout noIndex lang={lang} title={t("pageTitle.projects")} description={t("pageDescription.projects")}>
<PageHeading heading={t("pageTitle.projects")} description={t("pageDescription.projects")} />
<section> <section>
<ProjectsList> <ProjectsList>
{projects.map((project) => <ProjectItem data={project} />)} {projects.map((project) => <ProjectItem data={project} />)}

8
src/types/main.d.ts vendored
View File

@ -15,13 +15,6 @@ declare global {
type LanguageKeys = keyof typeof languages; type LanguageKeys = keyof typeof languages;
type UIDictionaryKeys = keyof typeof uiKeys; type UIDictionaryKeys = keyof typeof uiKeys;
type UtterancesConfigType = {
repo: string;
label?: string;
issueTerm: string;
theme: string;
};
type SiteMetadataType = { type SiteMetadataType = {
title: string; title: string;
name: string; name: string;
@ -46,6 +39,7 @@ declare global {
tags: Array<string>; tags: Array<string>;
published_at: string; published_at: string;
lang?: string; lang?: string;
draft?: boolean;
}; };
type ProjectType = { type ProjectType = {

View File

@ -1,10 +1,3 @@
export const extendHistoryState = (state: object, url?: URL | string | undefined): void => {
const { history } = window;
const updated = Object.assign({}, history.state, state);
history.replaceState(updated, "", url);
};
export const deleteParamFromUrl = (param: string): string => { export const deleteParamFromUrl = (param: string): string => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
params.delete(param); params.delete(param);

View File

@ -9,9 +9,3 @@ export function isEmail(email: string): boolean {
export function isURL(url: string): boolean { export function isURL(url: string): boolean {
return new RegExp(/(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\\/~+#-]*[\w@?^=%&\\/~+#-])/).test(url); return new RegExp(/(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\\/~+#-]*[\w@?^=%&\\/~+#-])/).test(url);
} }
export function normalizeLangTag(tag: string) {
if (!tag.includes("-")) return tag.toLowerCase();
const [lang, region] = tag.split("-");
return lang.toLowerCase() + "-" + region.toUpperCase();
}

View File

@ -1,4 +1,5 @@
import { DEFAULT_LANGUAGE } from "@root/config"; import { DEFAULT_LANGUAGE } from "@root/config";
import languages from "@i18n/languages";
export const encode = (data: object) => { export const encode = (data: object) => {
return Object.keys(data) return Object.keys(data)
@ -6,7 +7,8 @@ export const encode = (data: object) => {
.join("&"); .join("&");
}; };
export const getLanguageFromURL = (pathname: string) => { export const getLanguageFromURL = (pathname: string): LanguageKeys => {
const langCodeMatch = pathname.match(/\/([a-z]{1}-?[a-z]{1})\//); const lang = pathname.split("/")[1] as LanguageKeys;
return langCodeMatch ? langCodeMatch[1] : DEFAULT_LANGUAGE; if (Object.hasOwn(languages, lang)) return lang;
return DEFAULT_LANGUAGE;
}; };

View File

@ -19,7 +19,8 @@
"@stores/*": ["./src/stores/*"], "@stores/*": ["./src/stores/*"],
"@hooks/*": ["./src/hooks/*"], "@hooks/*": ["./src/hooks/*"],
"@i18n/*": ["./src/i18n/*"], "@i18n/*": ["./src/i18n/*"],
"@images/*": ["./src/images/*"] "@images/*": ["./src/images/*"],
"@services/*": ["./src/services/*"]
}, },
"outDir": "./dist", "outDir": "./dist",
"typeRoots": ["./node_modules/@types", "./src/types"] "typeRoots": ["./node_modules/@types", "./src/types"]