Compare commits

...

95 Commits

Author SHA1 Message Date
97fa1a5b4e dockerfile 2026-07-17 07:32:10 +00:00
9f6527007b fix ver 2026-07-17 07:13:42 +00:00
d052221f24 fix ver 2026-07-17 07:12:37 +00:00
2af2d6a2c5 render splash info from db 2026-07-17 07:09:37 +00:00
180dfe0366 replaced disqus with remark42 2026-03-17 16:27:42 +03:00
04895f540a readme 2026-02-25 14:20:21 +03:00
4cd23b6064 readme 2026-02-25 14:19:00 +03:00
9611adb2e0 dockerized 2026-02-25 14:02:13 +03:00
adf3238fe3 routing fixed 2026-02-25 10:02:06 +03:00
f3e4b3b9d8 sitemap 2026-02-25 08:11:32 +03:00
eca47d8ce3 fix rss link in footer 2026-02-24 23:04:43 +03:00
80236b839a sitemap 2026-02-24 22:48:37 +03:00
b1b6b5eb15 upd sitemap 2026-02-24 20:50:26 +03:00
92dc8ae213 rss 2026-02-24 15:42:47 +03:00
9f7b9d4ec2 fixed types 2026-02-24 14:31:09 +03:00
c41a54072b upd 2026-02-23 21:03:12 +03:00
515302fed3 upd 2026-01-21 23:17:09 +03:00
d8d812a282 types fix + limit recent articles 2025-12-18 23:32:23 +03:00
0e951d2aca optimized fetch calls 2025-12-18 22:45:53 +03:00
5051a6a6ca anchor links + toc 2025-12-18 22:22:21 +03:00
00d32bb002 replaced with markdown-to 2025-12-18 18:34:56 +03:00
838a74c086 contacts form 2025-12-18 11:37:37 +03:00
26e6276b23 added website contacts from directus 2025-12-17 23:25:21 +03:00
c371e3dba4 allow html in markdown 2025-12-17 23:02:37 +03:00
4d9ec73f40 incrementing article views 2025-12-17 11:47:36 +03:00
2f2dea8d0b projects list render 2025-12-17 09:42:25 +03:00
ed743b23c5 about page & rss 2025-12-17 00:27:47 +03:00
5dcb790ff1 upd 2025-11-24 16:33:27 +03:00
e03cd19a78 graphql codegen 2025-11-23 21:27:00 +03:00
a4576db438 fix articles list component 2025-11-23 15:37:29 +03:00
c5d7b7a7ba upd 2025-11-20 16:28:31 +03:00
35806aa60d fixes 2025-11-18 22:49:03 +03:00
d40d8bb730 article page 2025-11-18 15:01:27 +03:00
6d9809f4e1 upd 2025-11-18 07:15:02 +03:00
eb3bb55fca query recent articles 2025-11-17 16:26:14 +03:00
d0bfccd42f relative article link 2025-11-16 21:55:01 +03:00
a9061822f0 directus setup 2025-11-16 21:09:06 +03:00
8b26bc8bbc added material design icons 2025-06-08 21:28:42 +03:00
6f4e0d4d39
Merge pull request #239 from sergeyyarkov/cms/projects/educt
Automatically generated. Merged on Decap CMS.
2025-06-06 21:54:12 +03:00
ff1cdb8537
Merge pull request #240 from sergeyyarkov/cms/projects/node-docker-example
Automatically generated. Merged on Decap CMS.
2025-06-06 21:54:06 +03:00
edb1c0e469
Merge pull request #238 from sergeyyarkov/cms/projects/digital-store
Automatically generated. Merged on Decap CMS.
2025-06-06 21:53:50 +03:00
180631a0b6 Update проект “node-docker-example” 2025-06-06 21:52:20 +03:00
b772ed63db Update проект “digital-store” 2025-06-06 21:51:55 +03:00
89796647b0 Update проект “educt” 2025-06-06 21:51:07 +03:00
28e7eb20a9 Update проект “node-docker-example” 2025-06-06 21:49:47 +03:00
8ee4eea5ca Update проект “educt” 2025-06-06 21:49:19 +03:00
e210613332 Update проект “digital-store” 2025-06-06 21:48:56 +03:00
89f9d2e017 Merge branch 'develop' 2025-06-02 15:30:54 +03:00
bbc5f0bb0b cms config 2025-06-02 15:30:33 +03:00
aad8afbf44 Merge branch 'develop' 2025-06-02 14:47:17 +03:00
bb619c3182 fix deps 2025-06-02 14:46:41 +03:00
661f450540 Merge branch 'main' of github.com:sergeyyarkov/yarkov.tech 2025-06-02 14:29:57 +03:00
2a5367619f decap cms 2025-06-02 14:28:43 +03:00
69473cef56 Update Настройки “siteconfig” 2025-06-02 14:26:38 +03:00
4f4f7fec95 Update Настройки “siteconfig” 2025-06-02 14:25:47 +03:00
63909d59ba upgrade astro-icon to v1 2025-06-02 13:58:42 +03:00
a496f44cc0 Merge remote-tracking branch 'origin/main' into develop 2025-06-02 11:51:24 +03:00
864c0aeb2f ts fixes 2025-06-02 11:51:18 +03:00
53ed8f60d5 Update автора “сергей-ярков” 2025-02-02 00:05:34 +03:00
15c5ff6dde Update автора “сергей-ярков” 2025-02-01 23:59:29 +03:00
36dce21d41 Update автора “сергей-ярков” 2025-02-01 23:53:36 +03:00
5a56ba5968 Update автора “сергей-ярков” 2025-02-01 23:48:53 +03:00
ff6b5db6d4 Update проект “web-serial-example” 2025-01-30 22:28:27 +03:00
454b222318 Update навык “языки” 2025-01-30 21:16:02 +03:00
4ab909447b Delete проект “devthread” 2024-12-17 23:17:12 +03:00
7b50e97718 Update публикацию “реализация-задержек-в-avr-микроконтроллере” 2023-10-25 17:52:47 +03:00
61001cbb38 Update публикацию “реализация-задержек-в-avr-микроконтроллере” 2023-10-15 21:30:59 +03:00
9e86500cda Update публикацию “реализация-задержек-в-avr-микроконтроллере” 2023-10-15 21:29:30 +03:00
a07ca36741 Create тэг “microcontrollers” 2023-10-15 21:29:02 +03:00
d47e00762f
upd 2023-10-15 20:41:50 +03:00
64a011b4cc Create публикацию “реализация-задержек-в-avr-микроконтроллере” 2023-10-15 19:32:55 +03:00
85a9f3698d Upload “public/media/screen-3.png” 2023-10-15 19:28:25 +03:00
6aa4c28272 Upload “public/media/screen-2.png” 2023-10-15 19:28:16 +03:00
b0ffbc9478 Upload “public/media/screen-1.png” 2023-10-15 19:28:10 +03:00
f57f1f8e07 Upload “public/media/mplabx-ide-sim-option.png” 2023-10-15 19:27:59 +03:00
e6e3a12567
redirect 2023-10-05 21:52:42 +03:00
47c3c1e451
Merge branch 'main' of https://github.com/sergeyyarkov/yarkov.tech into main 2023-10-05 21:50:08 +03:00
e37187c241
Merge branch 'develop' into main 2023-10-05 21:49:44 +03:00
f9f8188cc8
redirect 2023-10-05 21:49:36 +03:00
6283dcc3ea Create проект “thermometer” 2023-10-05 21:40:39 +03:00
0a247f7e8f
del 2023-10-05 21:29:13 +03:00
741e372954
Merge branch 'develop' into main 2023-10-05 21:24:09 +03:00
2d03b0c484
upd deps 2023-10-05 21:23:11 +03:00
9722886413 Create публикацию “test” 2023-09-29 15:43:33 +03:00
df1d7ea0be
tsconfig 2023-09-28 19:44:16 +03:00
7db6593ddb
upd deps & compress html 2023-05-29 08:01:52 +03:00
2ee0fd6904
Merge tag 'v2.1.0' into develop
Release v2.1.0
2023-04-16 22:28:26 +03:00
27078bcdc0
Merge branch 'release/2.1.0' into main 2023-04-16 22:27:51 +03:00
90e8b7434e
bump ver & readme 2023-04-16 22:27:40 +03:00
464b6c5c9c
feat: added table of contents 2023-04-16 22:26:01 +03:00
f9d9c7f611
fix sharp ver 2023-04-14 22:53:46 +03:00
56132501cd
upd deps + build toc 2023-04-14 22:47:33 +03:00
69af4e882d
upd deps 2023-03-24 22:18:53 +03:00
bd77867971
del console.log 2023-02-27 23:18:30 +03:00
2b0adea120
views counter fix 2023-02-27 23:18:13 +03:00
151 changed files with 24837 additions and 19344 deletions

14
.dockerignore Normal file
View File

@ -0,0 +1,14 @@
node_modules
dist
.astro
.env
.env.example
.git
.gitignore
dockerfile
.dockerignore
.vscode
.prettierrc
codegen.ts
schema.graphql
README.md

View File

@ -1,3 +1,4 @@
REDIS_HOST=redis-1234.p124.eu-central-5-5.ec2.cloud.redislabs.com
REDIS_PASSWORD=superpassword
REDIS_PORT=1234
ASTRO_TELEMETRY_DISABLED=1
DIRECTUS_URL=
DIRECTUS_URL_GRAPHQL=
DIRECTUS_TOKEN=

View File

@ -1,5 +1,5 @@
{
"printWidth": 100,
"printWidth": 120,
"semi": true,
"singleQuote": false,
"tabWidth": 2,

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

@ -0,0 +1,5 @@
{
"githubPullRequests.ignoredPullRequestBranches": ["develop"],
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}

22
Dockerfile Normal file
View File

@ -0,0 +1,22 @@
ARG NODE_IMAGE=node:24.0.0-alpine
FROM ${NODE_IMAGE} AS build
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --omit=dev
FROM ${NODE_IMAGE} AS production
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package*.json .
EXPOSE 9966
CMD [ "node", "./dist/server/entry.mjs"]

View File

@ -1,7 +1,6 @@
# yarkov.tech
[![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/yarkov/deploys)
Personal website created with Astro.
@ -11,10 +10,10 @@ Personal website created with Astro.
- [x] Search articles by title or description
- [x] Dark/Light Theme
- [x] Multilang web-site
- [x] Content management via [NetlifyCMS](https://www.netlifycms.org/)
- [x] Prefetch pages via [Quicklink](https://getquick.link/)
- [x] Form handling via [Netlify Forms](https://www.netlify.com/products/forms/)
- [x] Comment system via [Disqus](https://disqus.com/)
- [x] Table of contents
- [x] Content management via [Directus](https://directus.io/)
- [x] Form handling
- [x] Comment system via [Remark42](https://github.com/umputun/remark42)
- [x] RSS Feed for articles
- [x] SEO optimized
- [x] Views counter

View File

@ -1,444 +1,38 @@
import { defineConfig } from "astro/config";
import { defineConfig, envField } from "astro/config";
/**
* Integrations
*/
import sitemap from "@astrojs/sitemap";
import node from "@astrojs/node";
import solidJs from "@astrojs/solid-js";
import image from "@astrojs/image";
import netlifyCMS from "astro-netlify-cms";
/**
* Rehype plugins
*/
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
/**
* Other required modules
*/
import { h } from "hastscript";
import { toString } from "hast-util-to-string";
import { escape } from "html-escaper";
import languages from "./src/i18n/languages";
import { DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES } from "./src/constants";
const createAnchorLabel = (heading) => {
const node = h("span.anchor-label", escape(heading));
if (node.properties) node.properties["is:raw"] = true;
return node;
};
import icon from "astro-icon";
// https://astro.build/config
export default defineConfig({
site: "https://yarkov.tech",
integrations: [
sitemap({
i18n: {
defaultLocale: DEFAULT_LANGUAGE,
locales: Object.fromEntries(Object.keys(languages).map((key) => [key, key])),
compressHTML: true,
output: "server",
prefetch: true,
server: {
port: 9966,
host: true,
allowedHosts: ["showcase.yarkov.tech"],
},
adapter: node({
mode: "standalone",
}),
solidJs(),
netlifyCMS({
adminPath: "/cms",
config: {
editor: {
preview: false,
},
display_url: "https://yarkov.tech",
i18n: {
locales: SUPPORTED_LANGUAGES,
default_locale: DEFAULT_LANGUAGE,
structure: "multiple_folders",
},
locale: DEFAULT_LANGUAGE,
backend: {
name: "github",
repo: "sergeyyarkov/yarkov.tech",
branch: "main",
},
media_folder: "public/media",
public_folder: "/media",
collections: [
/**
* Articles
*/
{
name: "articles",
label: "Публикации",
label_singular: "публикацию",
description:
"Технические статьи, туториалы и различные справочные материалы которые связаны с IT тематикой",
folder: "src/content/blog",
slug: "{{slug}}",
create: true,
delete: true,
i18n: true,
fields: [
{
name: "coverImage",
widget: "image",
label: "Постер",
required: false,
i18n: true,
},
{
name: "draft",
label: "В черновике",
widget: "boolean",
hint: "Опубликована ли статья",
i18n: true,
required: false,
},
{
name: "author",
widget: "relation",
value_field: "{{firstName}} {{lastName}}",
search_fields: ["firstName", "lastName"],
collection: "authors",
label: "Автор",
i18n: true,
},
{
name: "title",
widget: "string",
label: "Заголовок",
i18n: true,
},
{
name: "description",
widget: "string",
label: "Описание",
i18n: true,
},
{
name: "tags",
widget: "relation",
label: "Тэги",
collection: "tags",
multiple: true,
value_field: "title",
search_fields: ["title"],
i18n: true,
},
{
name: "pubDate",
widget: "datetime",
label: "Дата публикации",
picker_utc: true,
i18n: true,
},
{
name: "updatedDate",
widget: "datetime",
label: "Дата обновления",
picker_utc: true,
i18n: true,
},
{
name: "body",
widget: "markdown",
label: "Содержание",
i18n: true,
},
],
},
/**
* Tags
*/
{
name: "tags",
label: "Тэги",
label_singular: "тэг",
description: "Используются для тегирования контента",
folder: "src/content/tags",
create: true,
delete: true,
fields: [
{
name: "title",
widget: "string",
label: "Название",
required: true,
},
],
},
/**
* Authors
*/
{
name: "authors",
label: "Авторы",
label_singular: "автора",
description: "На странице статьи отображается имя автора",
slug: "{{firstName}}-{{lastName}}",
folder: "src/content/authors",
create: true,
delete: true,
i18n: true,
fields: [
{
label: "Имя",
name: "firstName",
widget: "string",
required: true,
i18n: true,
},
{
label: "Фамилия",
name: "lastName",
widget: "string",
required: true,
i18n: true,
},
{
label: "Об авторе",
name: "body",
widget: "markdown",
required: false,
i18n: true,
},
],
},
/**
* Projects
*/
{
name: "projects",
label: "Проекты",
label_singular: "проект",
description: "Основные проекты, которые были разработаны в выложены в открытый доступ",
folder: "src/content/projects",
create: true,
delete: true,
i18n: true,
fields: [
{
name: "icon",
label: "Иконка",
widget: "string",
required: true,
i18n: true,
},
{
name: "title",
label: "Название",
widget: "string",
required: true,
i18n: true,
},
{
name: "year",
label: "В каком году разработал",
widget: "number",
required: true,
i18n: true,
},
{
name: "description",
label: "Описание",
widget: "string",
required: true,
i18n: true,
},
{
name: "sourceUrl",
label: "Ссылка на исходник",
widget: "string",
required: true,
i18n: true,
},
{
name: "demoUrl",
label: "Ссылка на демонстрацию",
widget: "string",
required: false,
i18n: true,
},
{
name: "articleUrl",
label: "Ссылка на статью",
widget: "string",
required: false,
i18n: true,
},
],
},
/**
* Skills
*/
{
name: "skills",
label: "Навыки",
label_singular: "навык",
folder: "src/content/skills",
create: true,
delete: true,
description: "Навыки отображаются на странице Обо мне",
i18n: true,
fields: [
{
name: "title",
label: "Категория",
widget: "string",
required: true,
i18n: true,
},
{
name: "list",
label: "Записи",
required: true,
widget: "list",
allow_add: true,
min: 1,
i18n: true,
fields: [
{ name: "icon", label: "Иконка", widget: "string", i18n: true, required: false },
{ name: "title", label: "Название", widget: "string", i18n: true },
],
},
],
},
/**
* Settings
*/
{
name: "settings",
label: "Настройки",
files: [
{
name: "siteconfig",
label: "Настройки веб-сайта",
file: "siteconfig.json",
fields: [
{
label: "Название сайта",
name: "name",
widget: "string",
hint: "Отображается в шапке.",
default: "yarkov.tech",
},
{
label: "Автор веб-сайта",
name: "author",
widget: "string",
hint: "Отображается в подвале.",
},
{
label: "Глобальный заголовок",
name: "globalTitle",
widget: "string",
hint: "Отображется во вкладке браузера.",
},
{
label: "В разработке",
name: "isSiteUnderConstruction",
widget: "boolean",
required: false,
default: false,
hint: "Отображает баннер на всех старницах о том, что веб-сайт находится в разработке",
},
{
label: "Счетчик Яндекс.Метрики",
name: "ymCounter",
widget: "number",
hint: "Например: 90034656",
required: false,
},
{
label: "Статьи",
name: "articles",
widget: "object",
fields: [
{
name: "recentLimit",
label: "Лимит на отображение",
widget: "number",
hint: "Кол-во публикаций на гл.странице",
default: 5,
},
],
},
{
label: "Контакты",
name: "contacts",
widget: "object",
required: false,
fields: [
{
label: "Telegram",
name: "telegram",
widget: "string",
required: false,
},
{
label: "Почта",
name: "email",
widget: "string",
required: false,
},
{
label: "Github",
name: "github",
widget: "string",
required: false,
},
],
},
],
},
],
},
],
},
env: {
schema: {
DIRECTUS_URL: envField.string({ context: "server", access: "secret", default: "" }),
DIRECTUS_URL_GRAPHQL: envField.string({
context: "server",
access: "secret",
default: "http://directus:8055/graphql",
}),
image({
serviceEntryPoint: "@astrojs/image/sharp",
}),
],
markdown: {
syntaxHighlight: "prism",
rehypePlugins: [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
properties: {
class: "anchor-link",
DIRECTUS_TOKEN: envField.string({ context: "server", access: "secret", default: "" }),
},
behavior: "after",
group: ({ tagName }) =>
h(`div.heading-wrapper.level-${tagName}`, {
tabIndex: -1,
}),
content: (heading) => [
h(
`span.anchor-icon`,
undefined,
h(
"svg",
{
width: 18,
height: 18,
version: 1.1,
viewBox: "0 0 16 16",
xlmns: "http://www.w3.org/2000/svg",
ariaHidden: "true",
"data-filter": "none",
},
h("path", {
fillRule: "evenodd",
fill: "currentcolor",
d: "M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z",
})
)
),
createAnchorLabel(toString(heading)),
],
},
],
],
},
integrations: [icon(), solidJs()],
trailingSlash: "ignore",
scopedStyleStrategy: "where",
});

31
codegen.ts Normal file
View File

@ -0,0 +1,31 @@
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: [
{
[`${process.env.DIRECTUS_URL}/graphql`]: {
headers: {
Authorization: `Bearer ${process.env.DIRECTUS_TOKEN}`,
},
},
},
],
documents: ["src/**/*.ts"],
ignoreNoDocuments: true,
generates: {
"./src/graphql/": {
preset: "client",
config: {
documentMode: "string",
},
},
"./schema.graphql": {
plugins: ["schema-ast"],
config: {
includeDirectives: true,
},
},
},
};
export default config;

17
compose.yml Normal file
View File

@ -0,0 +1,17 @@
services:
yarkov-tech-node:
container_name: yarkov-tech-node
restart: unless-stopped
image: sergeyyarkov/yarkovtech:latest
networks:
- nginx_net
env_file: .env
build:
context: ./
dockerfile: Dockerfile
ports:
- 9966
networks:
nginx_net:
external: true

View File

@ -1,16 +0,0 @@
[build]
command = "npm run build"
publish = "dist"
functions = "netlify/functions"
[[redirects]]
from = "/api/*" # simplify all calls to serverless functions
to = "/.netlify/functions/:splat" # all function calls will go to this path
status = 200 # ok code
force = true # ensure to always redirect
[[redirects]]
from = "/blog/2022-11-04/ispolzovanie-webserial-api-dlya-obshcheniya-s-mikrokontrollerom/"
to = "/blog/2022-11-04/ispolzovanie-web-serial-api-dlya-obscheniyas-mikrokontrollerom/"
status = 301
force = true

View File

@ -1,35 +0,0 @@
import { Handler } from "@netlify/functions";
import { redisClient } from "../../redis";
const getViewsBySlug = async (slug: string): Promise<number> => {
let views = 1;
const data = await redisClient.get(`views:${slug}`);
if (data) views = Number.parseInt(data, 10);
if (Number.isNaN(views)) views = 1;
return views;
};
const handler: Handler = async (event) => {
const slug = event.queryStringParameters?.slug;
switch (event.httpMethod) {
case "GET": {
if (!slug) return { statusCode: 400, body: "Bad request." };
const views = await getViewsBySlug(slug);
return { statusCode: 200, body: views.toString() };
}
case "POST": {
if (!slug) return { statusCode: 400, body: "Bad request." };
const views = await redisClient.incr(`views:${slug}`);
return { statusCode: 200, body: views.toString() };
}
default: {
return {
statusCode: 500,
body: "Unrecognized HTTP Method, must be one of GET/POST",
};
}
}
};
export { handler };

31579
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,45 +1,53 @@
{
"name": "yarkov.tech",
"private": false,
"version": "2.0.0",
"version": "3.0.1",
"description": "Personal website created with Astro.",
"author": {
"name": "Sergey Yarkov",
"email": "serzh.yarkov@gmail.com",
"email": "email@yarkov.tech",
"url": "https://yarkov.tech"
},
"type": "module",
"license": "MIT",
"type": "module",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"build": "astro check && astro build",
"start": "node --env-file=.env ./dist/server/entry.mjs",
"preview": "astro preview",
"astro": "astro"
"astro": "astro",
"telemetry-disable": "astro telemetry disable",
"codegen": "graphql-codegen --require dotenv/config --config codegen.ts --watch"
},
"dependencies": {
"@astrojs/image": "^0.15.0",
"@astrojs/rss": "^2.1.1",
"@astrojs/sitemap": "^1.0.1",
"@astrojs/solid-js": "^2.0.2",
"@netlify/functions": "^1.4.0",
"@sindresorhus/slugify": "^2.2.0",
"astro": "^2.0.14",
"astro-icon": "^0.8.0",
"astro-netlify-cms": "^0.5.3",
"hast-util-to-string": "^2.0.0",
"hastscript": "^7.2.0",
"html-escaper": "^3.0.3",
"ioredis": "^5.3.1",
"@astrojs/check": "^0.9.5",
"@astrojs/node": "^9.5.0",
"@astrojs/rss": "^4.0.13",
"@astrojs/solid-js": "^5.1.3",
"@iconify-json/mdi": "^1.2.3",
"@types/markdown-it": "^14.1.2",
"astro": "^5.15.8",
"astro-icon": "^1.1.5",
"graphql": "^16.12.0",
"hast-util-select": "^6.0.4",
"markdown-it": "^14.1.0",
"markdown-it-anchor": "^9.2.0",
"markdown-it-prism": "^3.0.1",
"markdown-it-table-of-contents": "^1.1.0",
"mime-types": "^2.1.35",
"quicklink": "^2.3.0",
"rehype-autolink-headings": "^6.1.1",
"rehype-slug": "^5.1.0",
"sharp": "^0.31.3",
"solid-js": "^1.6.11"
"sharp": "^0.32.6",
"solid-js": "^1.7.12"
},
"devDependencies": {
"@types/mime-types": "^2.1.1",
"sass": "^1.58.3"
"@0no-co/graphqlsp": "^1.15.1",
"@graphql-codegen/cli": "^6.1.0",
"@graphql-codegen/schema-ast": "^5.0.0",
"@parcel/watcher": "^2.5.1",
"@types/html-escaper": "^3.0.4",
"@types/mime-types": "^2.1.4",
"@types/remark-prism": "^1.3.7",
"dotenv": "^17.2.3",
"sass": "^1.68.0",
"typescript": "^5.9.3"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 690 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

View File

@ -1,4 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://yarkov.tech/sitemap-index.xml
Sitemap: https://yarkov.tech/sitemap.xml

View File

@ -1,9 +0,0 @@
import Redis from "ioredis";
const redisClient = new Redis({
host: process.env.REDIS_HOST,
port: Number.parseInt(process.env.REDIS_PORT || "6379", 10),
password: process.env.REDIS_PASSWORD,
});
export { redisClient };

2570
schema.graphql Normal file

File diff suppressed because it is too large Load Diff

42
src/actions/index.ts Normal file
View File

@ -0,0 +1,42 @@
import { ActionError, defineAction } from "astro:actions";
import { z } from "astro/zod";
import { createAppeal, getLatestAppealsByClientIp } from "../queries";
const CREATE_APPEAL_LIMIT_HRS = 1;
const APPEALS_LAST_HRS_LIMIT = 2;
export const server = {
appeal: defineAction({
accept: "form",
input: z.object({
name: z.string(),
email: z.string().email(),
subject: z.string(),
message: z.string(),
}),
handler: async (data, ctx) => {
const currentDate = new Date();
const latestAppeals = await getLatestAppealsByClientIp(ctx.clientAddress, 5);
const appealsLastHrs = latestAppeals.filter((a) => {
const hourDiff = (currentDate.getTime() - new Date(a.date_created).getTime()) / (1000 * 60 * 60);
return hourDiff <= CREATE_APPEAL_LIMIT_HRS;
});
if (appealsLastHrs.length >= APPEALS_LAST_HRS_LIMIT) {
throw new ActionError({
code: "TOO_MANY_REQUESTS",
message: "Appeal create limit exceeded.",
});
}
const appeal = await createAppeal({ ...data, client_ip: ctx.clientAddress });
if (appeal) return true;
throw new ActionError({
code: "INTERNAL_SERVER_ERROR",
message: "Unable to create appeal.",
});
},
}),
};

View File

@ -0,0 +1,12 @@
---
import { getLanguageFromURL } from "@i18n/utils";
import { getAboutMe } from "../queries/global";
import { markdownToHTML } from "../utils/markdown";
import ArticleContent from "@components/ArticleContent.astro";
const pageLang = getLanguageFromURL(Astro.url.pathname);
const data = await getAboutMe(pageLang);
const aboutMeHtmlContent = await markdownToHTML(data?.about || "");
---
<ArticleContent set:html={aboutMeHtmlContent} />

View File

@ -1,12 +1,10 @@
---
export interface Props {
counter: number;
}
import { getSiteSettings } from '@/src/queries';
const { counter } = Astro.props as Props
const siteSettings = await getSiteSettings();
---
<script type="text/javascript" define:vars={{ counter }} is:inline>
{siteSettings ? <script type="text/javascript" define:vars={{ counter: siteSettings.ym_counter }} is:inline>
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
var z = null;m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
@ -16,6 +14,6 @@ const { counter } = Astro.props as Props
ym(counter, "init", {
clickmap: true,
trackLinks: true,
accurateTrackBounce:true
accurateTrackBounce: true,
});
</script>
</script> : null}

View File

@ -1,47 +1,53 @@
---
import UIString from "./UIString.astro";
interface Props {
url: string;
id: string;
lang: LanguageKeys;
}
const { url, id } = Astro.props as Props;
const { url, id, lang } = Astro.props as Props;
---
<footer itemscope itemtype="http://schema.org/UserComments">
<div id="disqus_thread"></div>
<h2><UIString key={"articlePost.comments"} />:</h2>
<div id="remark42"></div>
</footer>
<script define:vars={{ url, id }}>
var disqus_config = function () {
this.page.url = url;
this.page.identifier = id;
<script is:inline define:vars={{ url, id, lang }}>
window.remark_config = {
host: "https://remark.yarkov.tech",
site_id: "remark",
theme: window.theme,
show_email_subscription: true,
url,
locale: lang,
no_footer: true,
};
var e = document.getElementById("disqus_thread");
var e = document.getElementById("remark42");
if ("IntersectionObserver" in window) {
var t = new IntersectionObserver((e) => {
e.forEach((e) => {
e.isIntersecting &&
((function () {
var e = document,
t = e.createElement("script");
(t.src = "https://yarkov-tech.disqus.com/embed.js"),
t.setAttribute("data-timestamp", +new Date()),
(e.head || e.body).appendChild(t);
})(),
t.unobserve(e.target));
if (e.isIntersecting) {
((e, n) => {
for (var o = 0; o < e.length; o++) {
var r = n.createElement("script"),
c = ".js",
d = n.head || n.body;
("noModule" in r ? ((r.type = "module"), (c = ".mjs")) : (r.async = !0),
(r.defer = !0),
(r.src = remark_config.host + "/web/" + e[o] + c),
d.appendChild(r));
}
})(remark_config.components || ["embed"], document);
t.unobserve(e.target);
}
});
});
t.observe(e);
}
window.addEventListener(
"onthemetoggled",
() =>
document.readyState == "complete" &&
setTimeout(
() => "DISQUS" in window && DISQUS.reset({ reload: true, config: disqus_config }),
100
)
);
window.addEventListener("onthemetoggled", (e) => window.REMARK42.changeTheme(e.detail));
</script>

View File

@ -1,7 +1,23 @@
<div itemprop="articleBody" class="article-content"><slot /></div>
---
import { useTranslation } from "../i18n/utils";
<style lang="scss" is:global>
const t = useTranslation(Astro);
const tocTitle = t("article.tableOfContents");
---
<div itemprop='articleBody' class='article-content'><slot /></div>
<script is:inline define:vars={{ tocTitle }}>
var $toc = document.querySelector(".toc");
if ($toc) {
$toc.querySelector("summary").textContent = tocTitle;
document.querySelector("body")?.addEventListener("click", () => $toc?.removeAttribute("open"));
}
</script>
<style lang='scss' is:global>
.article-content {
padding-top: 20px;
& h1,
h2,
h3,
@ -42,19 +58,6 @@
quotes: "« " " »";
}
& a {
color: var(--font-color);
text-decoration: underline;
text-decoration-color: var(--color-blue-500);
text-underline-offset: 2.2px;
text-decoration-thickness: 2px;
font-weight: 500;
&:hover {
color: var(--color-blue-500);
text-decoration-thickness: 2px;
}
}
& figure {
margin: 0 0 1em;
& figcaption {
@ -121,6 +124,7 @@
}
}
& ul {
& li {
list-style: disc;
& li {
@ -132,6 +136,7 @@
line-height: var(--line-height-none);
}
}
}
& blockquote {
margin: 1.5rem 0;
@ -210,6 +215,44 @@
content: ",";
}
}
.toc {
cursor: pointer;
display: block;
position: absolute;
& summary {
font-size: var(--font-size-lg);
}
& nav {
background-color: var(--background-card);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-lg);
margin-top: 10px;
padding: 10px 16px;
& ul {
margin: 6px 0;
padding-left: 0;
margin-left: 0;
& a {
display: block;
// margin-bottom: 10px;
color: var(--font-color);
text-decoration: none;
font-size: var(--font-size-md);
&:hover {
color: var(--color-blue-500);
}
}
& li {
list-style: none;
padding: 0;
}
& li ul {
margin-left: 15px;
}
}
}
}
}
@media screen and (max-width: 800px) {

View File

@ -1,5 +1,5 @@
---
import { Picture } from "@astrojs/image/components";
import { Image } from "astro:assets";
export interface Props {
src: string;
@ -10,18 +10,15 @@ const { src, alt } = Astro.props as Props;
---
<div class="cover">
<figure>
<Picture
<Image
itemprop="image"
src={src}
width={700}
height={400}
sizes="(max-width: 450px) 450px, 700px"
widths={[450, 700]}
formats={["webp"]}
aspectRatio={"16:9"}
format="webp"
alt={alt ? alt : ""}
background={"chocolate"}
/>
</figure>
</div>
<style lang="scss">

View File

@ -1,21 +1,22 @@
import type { Component } from "solid-js";
import { createDateFormatter, createRelativeArticleUrl } from "@root/utils";
import { URL_BLOG_PREFIX } from "@root/constants";
import { createRelativeArticleUrl, createDateFormatter } from "@root/utils";
import { DEFAULT_LANGUAGE, URL_BLOG_PREFIX } from "@root/constants";
import "./ArticleItem.scss";
import { Article_Translations } from "@/src/graphql/graphql";
export type ArticleItemProps = {
id: string;
title: string;
pubDate: Date;
title: Article_Translations["title"];
pub_date: Article_Translations["pub_date"];
slug: Article_Translations["slug"];
languages_code: Article_Translations["languages_code"];
pageLang: string;
};
const ArticleItem: Component<ArticleItemProps> = (props) => {
const articleLang = props.id.split("/")[0];
const href = createRelativeArticleUrl(
{ id: props.id, title: props.title, pubDate: props.pubDate },
URL_BLOG_PREFIX
);
const { title, pub_date: pubDate, slug, languages_code, pageLang } = props;
const articleLang = languages_code?.code.split("-")[0] || DEFAULT_LANGUAGE;
const href = createRelativeArticleUrl({ pubDate, slug, articleLang }, URL_BLOG_PREFIX);
return (
<article
@ -26,13 +27,11 @@ const ArticleItem: Component<ArticleItemProps> = (props) => {
>
<a href={href}>
<div class="flex">
<h3 itemprop="headline">{props.title}</h3>
{articleLang !== props.pageLang && <sup>{articleLang.toLocaleUpperCase()}</sup>}
<h3 itemprop="headline">{title}</h3>
{articleLang !== pageLang && <sup>{articleLang.toLocaleUpperCase()}</sup>}
</div>
<p>
<time datetime={props.pubDate.toISOString()}>
{createDateFormatter(props.pageLang).format(props.pubDate)}
</time>
<time datetime={pubDate}>{createDateFormatter(pageLang).format(new Date(pubDate))}</time>
</p>
</a>
</article>

View File

@ -1,15 +1,16 @@
---
import type { CollectionEntry } from "astro:content";
import { Icon } from "astro-icon";
import slugify from "@sindresorhus/slugify";
import { Icon } from "astro-icon/components";
import { createDateFormatter } from "@root/utils";
import ArticleCover from "@components/ArticleCover.astro";
import ArticleComments from "@components/ArticleComments.astro";
import TranslatedLink from "@components/TranslatedLink.astro";
import AvailableOnLangs from "@components/AvailableOnLangs.astro";
import { useTranslation, getLanguageFromURL } from "@i18n/utils";
import { DIRECTUS_URL } from "astro:env/server";
import { Article_TranslationsQuery } from "../graphql/graphql";
export type Props = {
article: CollectionEntry<"blog">;
article: Article_TranslationsQuery["article_translations"][0];
lang: string;
};
@ -17,10 +18,19 @@ const { article } = Astro.props as Props;
const pageLang = getLanguageFromURL(Astro.url.pathname);
const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
const {
data: { coverImage, title, author, description, tags, pubDate },
cover_image,
title,
author,
description,
tags,
pub_date,
slug,
views,
article_id: articleTranslations,
} = article;
const slug = slugify(title, { lowercase: true });
const t = useTranslation(Astro);
// const articleCodeLangs = articleTranslations?.translations?.map((t) => t?.languages_code?.code);
---
<article itemscope itemtype="http://schema.org/BlogPosting" class="article">
@ -32,8 +42,8 @@ const t = useTranslation(Astro);
<ul itemprop="keywords">
{tags.map((tag) => (
<li>
<TranslatedLink rel="tag" to={`/articles/?tags=${tag}`}>
{tag}
<TranslatedLink rel="tag" to={`/articles/?tags=${tag?.tag_id?.title}`}>
{tag?.tag_id?.title}
</TranslatedLink>
</li>
))}
@ -43,40 +53,33 @@ const t = useTranslation(Astro);
}
<div class="views">
<Icon name="eye" width="21" height="21" />
<span><span id="views">---</span>&nbsp;{t("article.views")}</span>
<span><span>{views}</span>&nbsp;{t("article.views")}</span>
</div>
</div>
<div class="heading">
<h1 itemprop="headline">{title}</h1>
</div>
<div class="published">
<time itemprop="datePublished" datetime={pubDate.toString()}>
{createDateFormatter(pageLang).format(pubDate)}
<time itemprop="datePublished" datetime={pub_date}>
{createDateFormatter(pageLang).format(new Date(pub_date))}&nbsp;
</time>
<span itemprop="author">{author}</span>
<span itemprop="author">{`${author?.first_name} ${author?.last_name}`}</span>
</div>
{coverImage && <ArticleCover src={coverImage} alt={title} />}
{cover_image && <ArticleCover src={`${DIRECTUS_URL}/assets/${cover_image.filename_disk}`} alt={title} />}
<div class="description">
<p>
{description}
</p>
</div>
<AvailableOnLangs articles={articleTranslations} />
</header>
<slot />
<ArticleComments url={canonicalUrl.href} id={slug} />
<ArticleComments lang={pageLang} url={canonicalUrl.href} id={slug} />
</article>
<script define:vars={{ slug, IS_DEV: import.meta.env.DEV }}>
(async () => {
if (IS_DEV) return;
const response = await fetch(`/api/views?slug=${slug}`, { method: "POST" });
if (!response.ok) return;
const views = await response.text();
const $views = document.getElementById("views");
if (isNaN(parseInt(views, 10))) return;
if ($views) $views.textContent = views;
})();
</script>
<!-- <script define:vars={{ articleCodeLangs }} is:inline>
console.log(navigator.language, articleCodeLangs);
</script> -->
<style lang="scss" is:global>
.article {
@ -117,9 +120,8 @@ const t = useTranslation(Astro);
flex-wrap: wrap;
column-gap: 7px;
row-gap: 7px;
& li:not(:last-child)::after {
& li:not(:last-child) a::after {
content: ",";
color: var(--color-blue-400);
}
}
}
@ -130,6 +132,19 @@ const t = useTranslation(Astro);
}
}
& a {
color: var(--font-color);
text-decoration: underline;
text-decoration-color: var(--color-blue-500);
text-underline-offset: 2.2px;
text-decoration-thickness: 2px;
font-weight: 500;
&:hover {
color: var(--color-blue-500);
text-decoration-thickness: 2px;
}
}
& .cover {
margin: 20px 0;
& figure {

View File

@ -0,0 +1,14 @@
---
import { useTranslation, getLanguageFromURL } from "@i18n/utils";
import * as utils from "@utils/article";
import List from "@components/ArticlesList";
import { getArticleList } from "@root/queries/article";
const t = useTranslation(Astro);
const pageLang = getLanguageFromURL(Astro.url.pathname);
const data = await getArticleList();
const articles = utils.filterArticlesByPageLang(data, pageLang);
const articleBlocks = utils.formatToArticleBlocks(articles);
---
<List client:load i18n={{ "articles.empty": t("articles.empty") }} articles={articleBlocks} pageLang={pageLang} />

View File

@ -1,25 +1,16 @@
import type { Component } from "solid-js";
import { createSignal, createEffect, For } from "solid-js";
import { search, selectedTags, setCount } from "@stores/searchStore";
import SearchInput from "./SearchInput";
import TagsList from "./TagsList";
import ArticleItem from "@components/ArticleItem";
import { ArticleQuery } from "@/src/graphql/graphql";
import "./ArticlesList.scss";
type UiStringsType = { "articles.empty": string; "input.search": string };
type ArticlesType = Array<{
id: string;
title: string;
tags: string[];
pubDate: Date;
}>;
type ArticlesBlockType = Record<string, ArticlesType>;
type UiStringsType = { "articles.empty": string };
type Articles = ArticleQuery["article"][0]["translations"];
type ArticlesBlockType = Record<string, Articles>;
type ArticlesListProps = {
articles: ArticlesBlockType;
tags: string[];
pageLang: string;
i18n: UiStringsType;
};
@ -29,7 +20,7 @@ const ArticlesList: Component<ArticlesListProps> = (props) => {
const isEmpty = () => Object.keys(articles()).length === 0;
const sortYears = (articles: ArticlesBlockType) => {
const sortByYears = (articles: ArticlesBlockType) => {
return Object.keys(articles)
.map(Number)
.sort((a, b) => b - a);
@ -40,20 +31,24 @@ const ArticlesList: Component<ArticlesListProps> = (props) => {
}: {
params: { search: string; tags?: string[] };
}): ArticlesBlockType => {
const filter = (cb: (articles: ArticlesType) => ArticlesType) => {
const filter = (cb: (articles: Articles) => Articles) => {
return Object.fromEntries(
Object.keys(props.articles)
.map((year) => [year, cb(props.articles[year])])
.filter((articles) => articles[1].length !== 0)
.filter((articles) => articles[1]?.length !== 0)
);
};
return filter((articles) => {
let filtered = articles.filter((a) =>
a.title.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
let filtered = articles?.filter(
(a) => a && a.title.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
);
if (tags && tags.length > 0) {
filtered = filtered.filter((a) => tags.some((t) => a.tags.includes(t)));
filtered = filtered?.filter((a) =>
tags.some((t) => {
return a?.tags?.map((t) => t?.tag_id?.title).includes(t);
})
);
}
return filtered;
});
@ -67,23 +62,24 @@ const ArticlesList: Component<ArticlesListProps> = (props) => {
return (
<>
<SearchInput i18n={{ "input.search": props.i18n["input.search"] }} />
<TagsList tags={props.tags} />
<div itemscope itemtype="http://schema.org/Blog" class="articles-list">
{!isEmpty() ? (
<For each={sortYears(articles())}>
<For each={sortByYears(articles())}>
{(year) => (
<div class="articles-list__wrapper">
<h2>{year}</h2>
<For each={articles()[year]}>
{({ id, title, pubDate }) => (
{(a) =>
a && (
<ArticleItem
id={id}
title={title}
pubDate={pubDate}
slug={a.slug}
title={a.title}
languages_code={a.languages_code}
pub_date={a.pub_date}
pageLang={props.pageLang}
/>
)}
)
}
</For>
</div>
)}

View File

@ -62,6 +62,7 @@ const SearchInput: Component<SearchInputProps> = (props) => {
onInput={onSearch}
value={search()}
type="search"
name="search"
placeholder={props.i18n["input.search"] || "Search article by title..."}
autocapitalize="off"
autocomplete="off"

View File

@ -0,0 +1,43 @@
---
import { getLanguageFromURL, useTranslation } from "@root/i18n/utils";
import { createRelativeArticleUrl } from "@root/utils";
import { Article_TranslationsQuery } from "../graphql/graphql";
export interface Props {
articles: Article_TranslationsQuery["article_translations"][0]["article_id"];
}
const pageLang = getLanguageFromURL(Astro.url.pathname);
const t = useTranslation(Astro);
const { articles } = Astro.props;
---
{
articles && articles.translations && articles.translations?.length !== 1 && (
<div>
{t("article.availableOn")}:{" "}
{articles.translations
.filter((a) => a && a.languages_code?.code.split("-")[0] !== pageLang)
.map(
(a) =>
a && (
<a
href={`${createRelativeArticleUrl({ slug: a.slug, articleLang: `${a.languages_code?.code.split("-")[0]}`, pubDate: a.pub_date })}`}
>{`${a.languages_code?.name}`}</a>
)
)}
</div>
)
}
<style lang="scss">
div {
margin: 10px 0;
font-size: var(--font-size-lg);
& a {
&:not(:last-child)::after {
content: ",";
}
}
}
</style>

View File

@ -1,6 +1,5 @@
---
import "@styles/global.scss";
import siteconfig from "siteconfig.json";
import { SITE_METADATA } from "@root/constants";
import YaMetrika from "./Analytics/YaMetrika.astro";
import { useTranslation } from "@root/i18n/utils";
@ -19,64 +18,52 @@ const t = useTranslation(Astro);
<head>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel='preconnect' href='https://fonts.googleapis.com' />
<link rel='preconnect' href='https://fonts.gstatic.com' crossorigin />
<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"
rel="preload"
as="style"
onload="this.rel='stylesheet'"
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'`}
/>
<!-- Canonical URL -->
<link rel="canonical" href={canonicalURL.href} />
<link rel='canonical' href={canonicalURL.href} />
<!-- Base metadata -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta charset='utf-8' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<title>{title || SITE_METADATA.title}</title>
<!-- Metadata -->
<meta name="title" content={title || SITE_METADATA.title} />
<meta name="generator" content={Astro.generator} />
<meta name="description" content={description || t("site.description")} />
<meta name="theme-color" content={SITE_METADATA.themeColor} />
<meta name="msapplication-TileColor" content="#da532c" />
<meta name='title' content={title || SITE_METADATA.title} />
<meta name='generator' content={Astro.generator} />
<meta name='description' content={description || t("site.description")} />
<meta name='theme-color' content={SITE_METADATA.themeColor} />
<meta name='msapplication-TileColor' content='#da532c' />
<!-- Favicons -->
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon.png' />
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicon-light-32x32.png"
media="(prefers-color-scheme: light)"
rel='icon'
type='image/png'
sizes='32x32'
href='/favicon-light-32x32.png'
media='(prefers-color-scheme: light)'
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicon-light-16x16.png"
media="(prefers-color-scheme: light)"
rel='icon'
type='image/png'
sizes='16x16'
href='/favicon-light-16x16.png'
media='(prefers-color-scheme: light)'
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicon-dark-32x32.png"
media="(prefers-color-scheme: dark)"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicon-dark-16x16.png"
media="(prefers-color-scheme: dark)"
/>
<link rel="manifest" href="/site.webmanifest" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
<link rel='icon' type='image/png' sizes='32x32' href='/favicon-dark-32x32.png' media='(prefers-color-scheme: dark)' />
<link rel='icon' type='image/png' sizes='16x16' href='/favicon-dark-16x16.png' media='(prefers-color-scheme: dark)' />
<link rel='manifest' href='/site.webmanifest' />
<link rel='mask-icon' href='/safari-pinned-tab.svg' color='#5bbad5' />
<link rel='sitemap' href='/sitemap-index.xml' />
<slot />
<script is:inline src="/assets/js/detect-theme.js"></script>
<script src="../quicklink.js"></script>
{!isDev && siteconfig?.ymCounter && <YaMetrika counter={siteconfig.ymCounter} />}
<script is:inline src='/assets/js/detect-theme.js'></script>
{!isDev && <YaMetrika />}
</head>

View File

@ -1,6 +1,6 @@
---
export type Props = astroHTML.JSX.ButtonHTMLAttributes;
const attrs = Astro.props as Props;
const attrs = Astro.props;
---
<button {...attrs}>

View File

@ -2,43 +2,23 @@
import Button from "@components/Button.astro";
import UIString from "./UIString.astro";
import { useTranslation } from "@i18n/utils";
import { actions } from "astro:actions";
const t = useTranslation(Astro);
const formResult = Astro.getActionResult(actions.appeal);
---
<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>
<>
<form name="contact" action={actions.appeal} method="post">
<div class="flex">
<div>
<input
name="name"
placeholder={t("input.nameField")}
type="text"
maxlength="20"
autocomplete="off"
required
/>
<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
/>
<input name="email" placeholder="example@email.com" type="email" autocomplete="off" required />
</div>
</div>
<input
name="subject"
placeholder={t("input.subjectField")}
type="text"
autocomplete="off"
required
/>
<input name="subject" placeholder={t("input.subjectField")} type="text" autocomplete="off" required />
<textarea
name="message"
placeholder={t("input.messageField")}
@ -48,11 +28,19 @@ const t = useTranslation(Astro);
rows={10}
required></textarea>
<div class="footer">
<Button type="submit">
{
formResult?.error && (
<span>
<UIString key={formResult.error.status === 429 ? "submitted.limit" : "submitted.error"} />
</span>
)
}
<Button type={"submit"}>
<UIString key={"btn.submit"} />
</Button>
</div>
</form>
</>
<style lang="scss">
form {
@ -71,7 +59,10 @@ const t = useTranslation(Astro);
border: 1px solid var(--color-error);
}
& .footer {
text-align: right;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
}
@media screen and (max-width: 560px) {

View File

@ -1,23 +1,32 @@
---
import { Icon } from "astro-icon";
import { Icon } from "astro-icon/components";
import { getContacts } from "../queries";
const contacts = await getContacts();
---
<div>
<a href={"mailto:" + "serzh.yarkov@gmail.com"}
>Email
<Icon name="arrow-up-right" width="15" height="15" data-filter="none" />
{
contacts && (
<>
<a href={"mailto:" + contacts.email}>
Email
<Icon name='arrow-up-right' width='15' height='15' data-filter='none' />
</a>
<a href={"https://github.com/sergeyyarkov"} target="_blank"
>Github
<Icon name="arrow-up-right" width="15" height="15" data-filter="none" />
<a href={contacts.github} target='_blank'>
Github
<Icon name='arrow-up-right' width='15' height='15' data-filter='none' />
</a>
<a href={"https://t.me/sergeyyarkov"} target="_blank"
>Telegram
<Icon name="arrow-up-right" width="15" height="15" data-filter="none" />
<a href={contacts.telegram} target='_blank'>
Telegram
<Icon name='arrow-up-right' width='15' height='15' data-filter='none' />
</a>
</>
)
}
</div>
<style lang="scss">
<style lang='scss'>
div {
display: flex;
flex-wrap: wrap;

View File

@ -1,22 +1,22 @@
---
import { SITE_METADATA } from "@root/constants";
import { Icon } from "astro-icon";
import { Icon } from "astro-icon/components";
import TranslatedLink from "./TranslatedLink.astro";
import UIString from "./UIString.astro";
import { getLanguageFromURL } from "../i18n/utils";
const pageLang = getLanguageFromURL(Astro.url.pathname);
---
<footer>
<div class="container">
<div>
<i>
<UIString key={"footer.developed"} />: <TranslatedLink to="/contacts/">
{SITE_METADATA.author}
</TranslatedLink>
<UIString key={"footer.developed"} />: <TranslatedLink to="/contacts/"> Sergey Yarkov </TranslatedLink>
&copy; {new Date().getFullYear()}
</i>
</div>
<div>
<a href="/rss.xml" target="_blank"><UIString key={"footer.rssFeed"} /></a>
<a href=`/${pageLang}/rss.xml` target="_blank"><UIString key={"footer.rssFeed"} /></a>
<span>
<Icon name="rss" width="20" height="20" />
</span>

View File

@ -1,14 +1,14 @@
---
import { Icon } from "astro-icon";
import { IS_SITE_UNDER_CONSTRUCTION, SITE_METADATA } from "@root/constants";
import ThemeSwitcher from "@components/ThemeSwticher";
import LanguageSelect from "@components/LanguageSelect.astro";
import NavToggle from "@components/NavToggle";
import UIString from "@components/UIString.astro";
import TranslatedLink from "@components/TranslatedLink.astro";
import HeaderContacts from "./HeaderContacts.astro";
---
<header {...(IS_SITE_UNDER_CONSTRUCTION ? { style: 'top: 45px;' } : {})}>
<header {...IS_SITE_UNDER_CONSTRUCTION ? { style: "top: 45px;" } : {}}>
<div class="container">
<div class="logo">
<TranslatedLink to="/">
@ -31,24 +31,7 @@ import TranslatedLink from "@components/TranslatedLink.astro";
))
}
</ul>
{
SITE_METADATA.contacts && (
<div class="contacts">
<a target="_blank" href={"mailto:" + SITE_METADATA.contacts.email}>
Email
<Icon name="email" width="18" height="18" />
</a>
<a target="_blank" href={SITE_METADATA.contacts.github}>
Github
<Icon name="github_filled" width="18" height="18" />
</a>
<a target="_blank" href={SITE_METADATA.contacts.telegram}>
Telegram
<Icon name="telegram" width="18" height="18" />
</a>
</div>
)
}
<HeaderContacts />
<div class="flex settings">
<LanguageSelect />
<ThemeSwitcher client:visible />
@ -104,19 +87,6 @@ import TranslatedLink from "@components/TranslatedLink.astro";
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;
}
@ -162,17 +132,6 @@ import TranslatedLink from "@components/TranslatedLink.astro";
}
}
}
& .contacts {
margin-right: 0;
margin-top: 2.5rem;
& a {
margin-right: 0.2rem;
}
& svg {
width: 21px;
height: 21px;
}
}
}
}
}

View File

@ -0,0 +1,54 @@
---
import { Icon } from "astro-icon/components";
import { getContacts } from "../queries";
const contacts = await getContacts();
---
{
contacts && (
<div class="contacts">
<a target="_blank" href={"mailto:" + contacts.email}>
Email
<Icon name="email" width="18" height="18" />
</a>
<a target="_blank" href={contacts.github}>
Github
<Icon name="github_filled" width="18" height="18" />
</a>
<a target="_blank" href={contacts.telegram}>
Telegram
<Icon name="telegram" width="18" height="18" />
</a>
</div>
)
}
<style lang="scss">
.contacts {
display: flex;
align-items: center;
column-gap: 10px;
margin-right: 30px;
& a {
text-decoration: none;
font-size: 0;
&:hover {
opacity: var(--opacity-hover);
}
}
}
@media screen and (max-width: 768px) {
.contacts {
margin-right: 0;
margin-top: 2.5rem;
& a {
margin-right: 0.2rem;
}
& svg {
width: 21px;
height: 21px;
}
}
}
</style>

View File

@ -1,5 +1,5 @@
---
import { Icon } from "astro-icon";
import { Icon } from "astro-icon/components";
import { DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES, URL_BLOG_PREFIX } from "@root/constants";
import { getLanguageFromURL } from "@i18n/utils";
@ -19,7 +19,7 @@ const lang = getLanguageFromURL(Astro.url.pathname);
</select>
</div>
<script define:vars={{ lang, DEFAULT_LANGUAGE, URL_BLOG_PREFIX }}>
<script is:inline define:vars={{ lang, DEFAULT_LANGUAGE, URL_BLOG_PREFIX }}>
const $select = document.getElementById("languageSelect");
const changeLanguageHandler = (e) => {
@ -37,8 +37,7 @@ const lang = getLanguageFromURL(Astro.url.pathname);
/* Move user to articles page with new language */
if (window.location.pathname.includes(`/${URL_BLOG_PREFIX}/`)) {
window.location.pathname =
`${newLang === DEFAULT_LANGUAGE ? "" : newLang + "/"}` + "articles/";
window.location.pathname = `${newLang === DEFAULT_LANGUAGE ? "" : newLang + "/"}` + "articles/";
return;
}

View File

@ -1,23 +1,21 @@
---
import { DEFAULT_LANGUAGE } from "@root/constants";
import { getLanguageFromURL } from "@i18n/utils";
import { Icon } from "astro-icon";
import type { CollectionEntry } from "astro:content";
// import UIString from "../UIString";
import { Icon } from "astro-icon/components";
import UIString from "@components/UIString.astro";
import { Project_TranslationsQuery } from "../graphql/graphql";
import { DIRECTUS_URL } from "astro:env/server";
export interface Props {
data: CollectionEntry<"projects">["data"];
data: Project_TranslationsQuery["project_translations"][0];
}
const { data } = Astro.props as Props;
// const lang = getLanguageFromURL(Astro.url.pathname);
const { icon, title, year, description, articleUrl, sourceUrl, demoUrl } = data;
const { icon, title, year, description, article_url, source_url, demo_url } = data;
---
<div class="project-item">
<div class="project-item__heading">
<h2>
<Icon name={icon} width="20" height="20" />
{icon && <img data-icon src={`${DIRECTUS_URL}/assets/${icon.filename_disk}`} />}
{title}
</h2>
<span>{year}</span>
@ -27,25 +25,25 @@ const { icon, title, year, description, articleUrl, sourceUrl, demoUrl } = data;
</div>
<div class="project-item__links">
{
articleUrl && (
<a class="btn-link" href={articleUrl} target="_blank">
Статья
article_url && (
<a class="btn-link" href={article_url} target="_blank">
<UIString key={"btn.article"} />
</a>
)
}
{
sourceUrl && (
<a class="btn-link" href={sourceUrl} target="_blank">
Исходник
<Icon name="link-out" width="15" height="15" />
source_url && (
<a class="btn-link" href={source_url} target="_blank">
<UIString key={"btn.sourceCode"} />
<Icon name="mdi:link-variant" width="15" height="15" />
</a>
)
}
{
demoUrl && (
<a class="btn-link" href={demoUrl} target="_blank">
Демо
<Icon name="link-out" width="15" height="15" />
demo_url && (
<a class="btn-link" href={demo_url} target="_blank">
<UIString key={"btn.demo"} />
<Icon name="mdi:link-variant" width="15" height="15" />
</a>
)
}

View File

@ -1,8 +1,14 @@
---
import ProjectItem from "@components/ProjectItem.astro";
import { getLanguageFromURL } from "../i18n/utils";
import { getProjectList } from "../queries";
const pageLang = getLanguageFromURL(Astro.url.pathname);
const projects = await getProjectList(pageLang);
---
<div>
<slot />
{projects.map((data) => <ProjectItem data={data} />)}
</div>
<style>

View File

@ -1,21 +1,16 @@
---
import type { CollectionEntry } from "astro:content";
import siteconfig from "siteconfig.json";
import { getLanguageFromURL } from "@i18n/utils";
import * as utils from "@utils/index";
import ArticleItem from "@components/ArticleItem";
import EmptyList from "@components/EmptyList.astro";
import UIString from "@components/UIString.astro";
import TranslatedLink from "@components/TranslatedLink.astro";
import { getRecentArticleList, getSiteSettings } from "../queries";
export interface Props {
articles: CollectionEntry<"blog">[];
length?: number | undefined;
}
const { articles, length = siteconfig.articles.recentLimit } = Astro.props as Props;
const pageLang = getLanguageFromURL(Astro.url.pathname);
const filtered = utils.removeDuplicates(articles.slice(0, length), pageLang);
const siteSettings = await getSiteSettings();
const articles = await getRecentArticleList(siteSettings?.recent_articles_limit || 5);
const filteredArticles = utils.filterArticlesByPageLang(articles, pageLang);
const isEmpty = articles.length === 0;
---
@ -30,15 +25,19 @@ const isEmpty = articles.length === 0;
</div>
<div itemscope itemtype="http://schema.org/Blog">
{
!isEmpty ? (
filtered.map((article) => (
!isEmpty && !!filteredArticles ? (
filteredArticles.map(
(a) =>
a && (
<ArticleItem
id={article.id}
title={article.data.title}
pubDate={article.data.pubDate}
slug={a.slug}
title={a.title}
languages_code={a.languages_code}
pub_date={a.pub_date}
pageLang={pageLang}
/>
))
)
)
) : (
<EmptyList />
)

View File

@ -1,69 +0,0 @@
---
import { Icon } from "astro-icon";
import type { CollectionEntry } from "astro:content";
export interface Props {
data: CollectionEntry<"skills">["data"];
}
const {
data: { title, list },
} = Astro.props as Props;
---
<div class="skills-block">
<div>
<h3>{title}</h3>
</div>
<div>
<ul>
{
list.map((entry) => (
<li>
{entry.icon && <Icon name={entry.icon} width="20" height="20" />}
{entry.title}
</li>
))
}
</ul>
</div>
</div>
<style lang="scss">
.skills-block {
& h3 {
font-size: var(--font-size-md);
color: var(--font-color-grayer);
font-weight: 500;
}
& ul {
margin: 0;
padding: 0;
list-style: none;
& li {
font-weight: 400;
margin-bottom: 10px;
font-size: var(--font-size-sm);
display: flex;
align-items: center;
column-gap: 5px;
}
}
}
@media screen and (max-width: 500px) {
.skills-block {
& h3 {
text-align: center;
}
& ul {
display: flex;
flex-direction: column;
align-items: center;
}
}
}
</style>

View File

@ -1,20 +0,0 @@
---
---
<div class="skills-list"><slot /></div>
<style lang="scss">
.skills-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
column-gap: 40px;
row-gap: 30px;
margin-top: 5rem;
}
@media screen and (max-width: 500px) {
.skills-list {
display: flex;
flex-direction: column;
}
}
</style>

View File

@ -2,7 +2,7 @@
<slot />
</header>
<style lang="scss">
<style lang='scss'>
header {
& :global(p) {
font-size: var(--font-size-lg);
@ -14,7 +14,7 @@
& :global(h1) {
font-size: var(--font-size-5xl);
font-weight: bold;
margin-bottom: 0.5rem;
margin-bottom: 16px;
}
& :global(h2) {

View File

@ -1,5 +1,5 @@
---
import { Icon } from "astro-icon";
import { Icon } from "astro-icon/components";
import TranslatedLink from "@components/TranslatedLink.astro";
export interface Props {

View File

@ -0,0 +1,8 @@
---
import { getTagList } from "@root/queries/tag";
import List from "@components/ArticlesList/TagsList";
const tags = (await getTagList()).map((t) => t.title);
---
<List client:load tags={tags} />

View File

@ -7,7 +7,7 @@ const MoonIcon: Component = () => (
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M10.4998 10.3311C9.39883 9.22975 8.64906 7.82674 8.34521 6.29939C8.04136 4.77203 8.19707 3.18889 8.79266 1.75C7.09451 2.08429 5.53468 2.91752 4.31266 4.14313C0.895781 7.56 0.895781 13.1005 4.31266 16.5174C7.73041 19.9351 13.27 19.9342 16.6878 16.5174C17.9131 15.2955 18.7462 13.736 19.0809 12.0382C17.642 12.6337 16.0589 12.7894 14.5316 12.4855C13.0042 12.1817 11.6012 11.432 10.4998 10.3311Z"
fill="#010409"
fill="black"
/>
</svg>
);
@ -16,7 +16,7 @@ const SunIcon: Component = () => (
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6.12062 10.5C6.12062 12.9159 8.08587 14.8811 10.5017 14.8811C12.9176 14.8811 14.8829 12.9159 14.8829 10.5C14.8829 8.08413 12.9176 6.11888 10.5017 6.11888C8.08587 6.11888 6.12062 8.08413 6.12062 10.5ZM9.625 16.625H11.375V19.25H9.625V16.625ZM9.625 1.75H11.375V4.375H9.625V1.75ZM1.75 9.625H4.375V11.375H1.75V9.625ZM16.625 9.625H19.25V11.375H16.625V9.625ZM4.93238 17.3057L3.69513 16.0685L5.551 14.2126L6.78825 15.4499L4.93238 17.3057ZM14.2118 5.551L16.0685 3.69425L17.3057 4.9315L15.449 6.78825L14.2118 5.551ZM5.551 6.78912L3.69513 4.93238L4.93325 3.69513L6.78825 5.55187L5.551 6.78912ZM17.3057 16.0685L16.0685 17.3057L14.2118 15.449L15.449 14.2118L17.3057 16.0685Z"
fill="black"
fill="white"
/>
</svg>
);

View File

@ -13,4 +13,4 @@ if (!SUPPORTED_LANGUAGES.includes(pageLang)) pageLang = DEFAULT_LANGUAGE;
const href = pageLang === DEFAULT_LANGUAGE ? to : `/${pageLang}${to}`;
---
<a href={href} {...attrs}><slot /></a>
<a data-astro-prefetch href={href} {...attrs}><slot /></a>

View File

@ -1,24 +0,0 @@
---
firstName: Sergey
lastName: Yarkov
---
My name is Sergey and I am a web developer. My main activity is creating web sites. I am interested in areas such as programming, embedded systems, and electronics.
I started making web sites in 2017, starting with simple HTML pages, and in 2019 I started learning programming. At present, I have experience in **full-stack** web development of client-server applications that solve a wide range of tasks.
Some of my personal [projects](/projects/) I put in public access on [Github](https://github.com/sergeyyarkov).
## Purpose of this site
I originally created this website for myself, to consolidate my knowledge by writing articles.
Since 2022, I have been posting materials on this website, and now at any time I can return to any topic and learn it again.
My articles can also help someone with mastering a particular topic. The main direction of this site is IT,
and [here](/articles/) I publish materials on topics that interest me, as well as talk about my [projects](/projects/).
I am absolutely calm about any criticism and I do not claim to be the best programmer in the world,
so do not judge the materials of this resource harshly.
## Skills
This list shows the skills and technologies that I have used/use in my projects.
For creating web projects, I use **Node.js** and the **Adonis.js** framework for the server-side.
For the client-side, I develop using **React.js** or **Alpine.js**.
<q>this website is made using the [**Astro.js**](https://astro.build) framework and [**Solid.js**](https://www.solidjs.com/) library.</q>

View File

@ -1,28 +0,0 @@
---
firstName: Сергей
lastName: Ярков
---
Мое имя **Сергей** я являюсь веб-разработчиком. Основной деятельностью моей работы является создание веб-сайтов.
Мне интересны такие сферы как программирование, встраиваемые системы и электроника.
Веб-сайты я начал делать с 2017 года начиная с верстки простых HTML страничек и с 2019 года я начал
обучаться программированием и на сегодняшний момент я имею опыт в **full-stack** веб-разработке клиент-серверных приложений,
которые решают самые разные задачи.
Некоторые из своих личных [проектов](/projects/) я выкладываю в общий доступ на [Github](https://github.com/sergeyyarkov).
## Зачем этот сайт
Первоначально я создал этот сайт для себя, чтобы закреплять свои знания путем написания статей. С 2022 года я начал
выкладывать материалы на этот сайт и сейчас в любой момент я могу вернуться к какой-либо теме и еще раз её усвоить.
Мои статьи также смогут кому-то помочь с освоением какой-либо темы.
Главным направлением этого сайта является IT, и [здесь](/articles/) я публикую материалы на темы, которые меня интересуют,
а также рассказываю о своих [проектах](/projects/).
Я отношусь абсолютно спокойно к любой критике и я не претендую на звание лучшего программиста в мире, поэтому
не судите строго материалы этого ресурса.
## Навыки
Этот список показывает имеющиеся у меня навыки и технологии которые я когда-либо использовал/использую в своих проектах.
Для создания веб-проектов в качестве серверной части я использую **Node.js** и фреймворк **Adonis.js**.
Клиентскую часть я разрабатываю на **React.js** или **Alpine.js**.
<q>этот веб-сайт сделан с помощью фреймворка **[Astro.js](https://astro.build)** и библиотеки **[Solid.js](https://www.solidjs.com/)**</q>

View File

@ -1,898 +0,0 @@
---
coverImage: /media/serial-port.jpg
draft: false
author: Sergey Yarkov
title: Using the WebSerial API to communicate with the microcontroller
description: We will talk about what is WebSerial API, how it can be used in a
browser to read and write data to a serial device and where it can be applied.
tags:
- JavaScript
- AVR
- WebSerial API
- Tutorial
pubDate: 2022-11-04T21:00:00.000Z
updatedDate: 2023-02-13T16:34:23.326Z
---
# What you will learn
By the end you will learn how to send and receive data from a serial port device in a browser, develop a demo web application that sends commands to a serial port microcontroller and write firmware for the [AVR128DA48](http://ww1.microchip.com/downloads/en/DeviceDoc/40002183A.pdf) microcontroller to process those commands. You can see the finished version of the project [here](https://web-serial-example.netlify.app/), the sources are uploaded to [repository](https://github.com/sergeyyarkov/avr128da48_web-serial-example) on GitHub.
# What it is and where it's used
The WebSerial API is a browser-based API that provides the ability to read and write data to a serial device. Using this API is quite easy, you only need to write a few lines of code to receive or send your data. There is also a detailed [documentation](https://wicg.github.io/serial/) with examples of how to use this API.
Thanks to this API, new possibilities open up. For example you can flash your microcontroller right from the browser which sounds really interesting (but you need to write a bootloader) or you can just output the sensor info to a web page. The possibilities are endless, it all depends on your imagination.
There are also high level APIs to access devices like a gamepad or camera. I'll leave this link to [this](https://web.dev/devices-introduction/) article which talks about a list of these APIs.
# Browser support.
As of this writing (2022), full support for this API is only available in Chromium-based browsers. You can see the full table at [Can I Use](https://caniuse.com/web-serial).
On Android, support can be provided based on [WebUSB API](https://wicg.github.io/webusb/) and [Serial API polyfill](https://github.com/google/web-serial-polyfill).
# A short excursus about UART
Before you work with serial port it would be nice to know the basics of UART data transfer protocol, if you are already familiar with it you can skip this part.
**UART** stands for Universal Asynchronous Receiver Transmitter and defines a protocol for sending and receiving data between two devices. **Universal** means that we can customize the parameters, including the baud rate. **Asynchronous**, on the other hand, means we have no synchronizing signal between transmitter and receiver. There is also a synchronous version of data transmission with a common clock signal, which can transmit data much faster, but it is rarely used. Only two lines are used to communicate between the two devices and these are designated as RX (**receiver**) and TX (**transmitter**).
<figure>
<img alt="Wiring diagram of two UART devices" width="550" height="300" src="/media/uart-connection.jpg" loading="lazy" decoding="async">
<figcaption>Wiring diagram of two UART devices</figcaption>
</figure>
UART transmits data one bit at a time in one of three modes:
* one-way mode - data is sent in one direction only, from transmitter to receiver
* half-duplex mode - devices can transmit and receive data one at a time
* full-duplex mode - devices can transmit and receive data simultaneously.
Since this project is supposed to use asynchronous version of the UART, then accordingly you need to set **the same**bit rate per second (`baud rate`, `bps`) on the two devices. The most common bit rates are: **4800, 9600, 19.2K, 57.6K, and 115.2K.**
Data is transmitted in so-called `packets`, where each packet contains:
* start bit and stop bit - the start bit signals the arrival of data bits, the stop bit signals the end of the data, respectively.
* data bits - user data that comes right after the start bit, it can contain from 5 to 8 bits.
* the parity bit is an optional bit that comes after the data bits and before the stop bit and is used to detect errors.
<figure>
<img src="/media/uart-packet.jpg" width="700" height="300" alt="Structure of the UART packet" format="webp">
<figcaption>Structure of the UART packet</figcaption>
</figure>
I think this information is enough to understand what UART is all about. If you want to learn more about this interface there are many articles about it on the internet.
# Interface Converter
Since you will most likely connect the microcontroller to the USB port of your computer, you need some kind of converter between the two interfaces to send and receive data, since USB and UART are completely different data transfer protocols. There are many such translators available, here are some of them: [CH340](https://pdf1.alldatasheet.com/datasheet-pdf/view/1132602/WCH/CH340.html), [CP2102](https://pdf1.alldatasheet.com/datasheet-pdf/view/201067/SILABS/CP2102.html) or [FT232](https://pdf1.alldatasheet.com/datasheet-pdf/view/197629/FTDI/FT232BL.html). If you are using an Arduino UNO debug board then you already have one of these converters on your board.
<figure>
<img src="/media/arduino-uart-converter.png" width="470" height="320" alt="CH340G interface converter on Arduino UNO board" format="webp"><figcaption>
CH340G interface converter on Arduino UNO board
</figcaption>
</figure>
If you don't have this interface converter on your development board, you can buy it in any store and it usually costs less than $5.
# Microcontroller firmware development
As I mentioned before the firmware will be written for the AVR128DA48 Microcontroller. It doesn't really matter what kind of microcontroller you use, you just need to be able to send and receive data using the UART peripherals on your microcontroller.
The program will be very simple: you need to write a ***command handler*** which will take a string (like `led_toggle`) and call a function which will do a certain action, in this case switch the led.
The problem statement is clear, now let's look at the implementation of this program. I will use the MPLAB X IDE and the C language to write the firmware.
## Necessary constants
First, we define a list of constants for the whole project by creating a file `constants.h`:
```c
#ifndef CONSTANTS_H
#define CONSTANTS_H
#define F_CPU 4000000UL
#define BAUD_RATE 9600
#define BUFFER_SIZE 20
#define EOT 0x04
#endif /* CONSTANTS_H */
```
* `F_CPU` - microcontroller clock speed
* `BAUD_RATE` - UART bit rate
* `BUFFER_SIZE` - buffer size, where we will put the incoming data
* `EOT` - End-of-Transmission, ASCII character for the end of data transfer
## USART initialization
Create a header file `usart.h` and define some functions to initialize USART, send a char and string.
```c
#include "constants.h"
#ifndef USART_H
#define USART_H
#define USART1_BAUD_RATE(BAUD_RATE) ((float)(64 * F_CPU / (16 * (float)BAUD_RATE)) + 0.5)
#include <avr/io.h>
#include <stdio.h>
#include <string.h>
void USART1_Initialize(void);
void USART1_SendChar(char c);
void USART1_SendString(char *str);
#endif /* USART_H */
```
The macro `USART1_BAUD_RATE` calculates the value to write to the register `USARTn.BAUD` to set the baud rate. The formula is taken from [documentation](https://ww1.microchip.com/downloads/aemDocuments/documents/MCU08/ProductDocuments/DataSheets/AVR128DA28-32-48-64-Data-Sheet-40002183C.pdf) for the microcontroller (chapter 25.3.2.2.1, table 25-1).
Let's implement the USART initialization function in the file `usart.c`:
```c
void USART1_Initialize(void) {
/* set baud rate */
USART1.BAUD = (uint16_t) (USART1_BAUD_RATE(BAUD_RATE));
/* set char size in a frame to 8 bit */
USART1.CTRLC = USART_CHSIZE0_bm | USART_CHSIZE1_bm;
/* config pins for TX and RX */
PORTC.DIRSET = PIN0_bm;
PORTC.DIRCLR = PIN1_bm;
/* enable reveice complete interrupt */
USART1.CTRLA = USART_RXCIE_bm;
/* enable transmitter and receiver */
USART1.CTRLB = USART_TXEN_bm | USART_RXEN_bm;
}
```
For full-duplex mode initialization of the asynchronous version of USART is as follows:
* Configuration of the baud rate by writing a value to the register `USARTn.BAUD`.
* Configuration of the `frame` size, in our case it is 8 bits.
* Configuration of the `TX` pin for output and `RX` pin for input.
* Enabling an interrupt for when the data reception is over (when one packet has arrived).
* Enabling the receiver and transmitter.
Now, let's take a look at the functions of sending data:
```c
void USART1_SendChar(char c) {
while (!(USART1.STATUS & USART_DREIF_bm));
USART1.TXDATAL = c;
}
void USART1_SendString(char *str) {
for (size_t i = 0; i < strlen(str); i++) {
USART1_SendChar(str[i]);
}
}
```
To send data you must write one byte to the `USARTn.TXDATAL` register, but before doing so you must ensure that the previous transfer was completed by checking the `USARTn.STATUS` register. As stated in the documentation, the `DREIF` (Data Register Empty Flag) bit is set if the data in the TX buffer is **absent**. Accordingly it is possible to make an empty loop which will run as long as we have data in the TX buffer.
The send string function is a kind of wrapper which will call the send one character function.
## Command Handler
As you can see from the problem statement, we get a string that tells us what action should be executed.
Let's define a header file `command.h` and write the following in it:
```c
#ifndef COMMAND_H
#define COMMAND_H
#include <avr/io.h>
#include <string.h>
#define COMMAND_SIZE 5
#define COMMAND_MAX_NAME_LENGTH 20
struct Command {
void *addr;
char name[COMMAND_MAX_NAME_LENGTH];
};
uint8_t command_define(void *fp, char name[COMMAND_MAX_NAME_LENGTH]);
uint8_t command_process(char *cmd_name);
void command_list(void);
#endif /* COMMAND_H */
```
To solve this problem, I propose to create an array of structures, where each structure will contain a pointer to the function we need to execute, and the name of the command itself.
* `command_define` will create a new structure in the array.
* `command_process` will call the corresponding function on the command name.
* `command_list` will send information about existing commands via UART.
You can implement this by creating a file `command.c` as follows:
```c
#include "command.h"
#include "usart.h"
struct Command commands[COMMAND_SIZE];
uint8_t cmd_idx = 0;
uint8_t command_define(void *fp, char name[COMMAND_MAX_NAME_LENGTH]) {
size_t name_length = strlen(name);
if (name_length > COMMAND_MAX_NAME_LENGTH) {
return 0;
}
if (cmd_idx > COMMAND_SIZE) {
cmd_idx = 0;
}
struct Command command;
command.addr = fp;
strcpy(command.name, name);
commands[cmd_idx++] = command;
return 1;
}
uint8_t command_process(char *cmd_name) {
uint8_t i = 0;
do {
if (strcmp(cmd_name, commands[i].name) == 0) {
int (*execute)();
execute = commands[i].addr;
execute();
USART1_SendString("[LOG]: Command completed successfully with code \"0\".\n");
USART1_SendChar(EOT);
return 0;
}
} while (i++ < COMMAND_SIZE);
USART1_SendString("[LOG]: Invalid command name!\n");
USART1_SendChar(EOT);
return 1;
}
void command_list(void) {
USART1_SendString("[LOG]: List of available commands:\n");
for (uint8_t i = 0; i < COMMAND_SIZE; i++) {
if (commands[i].addr) {
USART1_SendChar((i + 1) + '0');
USART1_SendString(". ");
USART1_SendString(commands[i].name);
USART1_SendChar('\n');
}
}
}
```
It is important to note that after sending the data, you must send the `EOT` symbol, which will tell you that the transmission is complete, this will help in the future development of the web application.
## Receiving data
The last thing to do is to receive commands from USART and send them to a handler. You can use an interrupt to do this:
```c
char buffer[BUFFER_SIZE];
volatile uint8_t buff_idx = 0;
ISR(USART1_RXC_vect) {
if (buff_idx > BUFFER_SIZE) buff_idx = 0;
buffer[buff_idx] = USART1.RXDATAL;
buff_idx++;
}
```
When the interrupt `USART1_RXC_vect` is triggered, data (one byte) appears in the `USART1.RXDATAL` register. This byte we have to write to our fixed size buffer. There is also another way to accept data without using an interrupt. To do this simply check the bit `RXCIF` (USART Receive Complete Interrupt Flag) in the register `USARTn.STATUS`. This bit is set when there is data in the receive buffer.
In order to process incoming commands you can check the buffer in the main loop for the `\n` character, this character will signify the end of the command name:
```c
while (1) {
if (buff_idx >= 1) {
if (buffer[buff_idx - 1] == '\n') {
buffer[buff_idx - 1] = '\0';
command_process(buffer);
buff_idx = 0;
}
}
}
```
After finding this character, you can pass the buffer to the handler, then reset the buffer index to zero. As a result, the `main.c` file will look like this
```c
#include <avr/io.h>
#include <avr/interrupt.h>
#include <string.h>
#include "constants.h"
#include "command.h"
#include "usart.h"
char buffer[BUFFER_SIZE];
volatile uint8_t buff_idx = 0;
void led_toggle(void);
void hello_world(void);
ISR(USART1_RXC_vect) {
if (buff_idx > BUFFER_SIZE) buff_idx = 0;
buffer[buff_idx] = USART1.RXDATAL;
buff_idx++;
}
void MCU_Init_Ports(void) {
/* led pin PC6 to output */
PORTC.DIRSET = PIN6_bm;
}
int main(void) {
MCU_Init_Ports();
USART1_Initialize();
sei();
command_define(led_toggle, "led_toggle");
command_define(hello_world, "hello");
command_define(command_list, "list");
while (1) {
if (buff_idx >= 1) {
if (buffer[buff_idx - 1] == '\n') {
buffer[buff_idx - 1] = '\0';
command_process(buffer);
buff_idx = 0;
}
}
}
}
void led_toggle(void) {
PORTC.OUTTGL = PIN6_bm;
}
void hello_world(void) {
USART1_SendString("Hello world!\n");
}
```
After initialization of the microcontroller ports and USART it is obligatory to enable the global interrupts by calling the function `sei`.
With the function `command_define` we can define by which USART command to start the function:
* `led_toggle` will toggle the LED.
* `hello_world` will output the message "Hello world".
* `command_list` will show a list of available commands.
## Operation demonstration
Now with the firmware assembled and loaded into your Microcontroller you can test it's functionality. I will use the program `CuteCom` to open the serial port, you can use any other similar program.
Select your device from the list (in my case it is `/dev/ttyACM1`) and set the following parameters:
* Speed: **9600** baud
* Number of bits: **8**
* Parity: **None**
* Stop bits: **1**
<figure>
<img
src="/media/uart-connect-settings.png"
width="700"
height="400"
alt="Settings for opening a serial port"
format="webp"
fit="fill"
>
<figcaption>Settings for opening a serial port</figcaption>
</figure>
Now after opening the port you can send commands, don't forget to set the carriage return as `LF`, because our firmware only recognizes the end of the command by this character.
<figure>
<img
src="/media/webserial-demo-1.png"
width="700"
height="400"
alt="An example of sending the ”hello” command"
format="webp"
fit="fill"
>
<figcaption>An example of sending the ”hello” command</figcaption>
</figure>
# Web application development
The web application will consist of just a few elements: an input box where we can write our commands, buttons to open and close the serial port, connection status. You can see the finished version [here](https://web-serial-example.netlify.app/).
### Create HTML markup and styles
The HTML markup will look like this:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web Serial API Example</title>
<link href="./style.css" rel="stylesheet" />
<script defer src="./app.js"></script>
</head>
<body>
<div id="app">
<div>
<button id="connect">Connect</button>
<button id="disconnect">Disconnect</button>
</div>
<form id="terminal_form" action="#">
<label for="input">Enter command name:</label><br />
<div>
<input id="input" name="input" placeholder="e.g led_toggle" type="text" disabled />
<button name="send" type="submit" disabled>Send</button>
</div>
</form>
<textarea id="serial_log" placeholder=">" readonly></textarea>
<div id="port_info">
<div>
<p id="status">not connected</p>
</div>
<div>
<span>vendorId: <span id="vendor_id">-</span>&nbsp;|</span>
<span>deviceId: <span id="product_id">-</span></span>
</div>
</div>
</div>
</body>
</html>
```
Let's add some styles by creating a `style.css` file:
```css
:root {
--border-color: #c9d1d9;
--border-color-hover: #6e7681;
--font-family: monospace;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
#app {
display: flex;
flex-direction: column;
width: 100%;
max-width: 600px;
row-gap: 5px;
}
html,
body {
box-sizing: border-box;
font-size: 16px;
}
body {
display: flex;
justify-content: center;
margin-top: 2rem;
font-family: var(--font-family);
}
p {
padding: 0;
margin: 5px;
}
input,
textarea {
font-family: var(--font-family);
padding: 5px 15px;
border-radius: 4px;
width: 100%;
border: 1px solid var(--border-color);
}
textarea {
resize: vertical;
min-height: 200px;
line-height: 1.4;
font-size: 16px;
outline: none;
font-size: 12px;
}
label {
font-size: 14px;
font-style: italic;
}
button {
cursor: pointer;
background: none;
font-family: var(--font-family);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 5px 15px;
}
button:hover:not(:disabled) {
border: 1px solid var(--border-color-hover);
}
#terminal_form div {
display: flex;
justify-content: space-between;
gap: 5px;
margin-bottom: 5px;
margin-top: 5px;
}
#port_info {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
font-size: 14px;
}
#port_info #status {
text-transform: uppercase;
}
```
## Creating a SerialPortHandler class
The logic of working with serial port will be implemented in this class, which will be a small wrapper for easy use of WebSerial API, it will have only 5 methods and a few properties.
Let's define this class in the `app.js` file:
```jsx
class SerialPortHandler {
constructor(options, onConnect, onDisconnect) {
this.encoder = new TextEncoder();
this.decoder = new TextDecoder();
this.onConnect = onConnect;
this.onDisconnect = onDisconnect;
this.options = options;
this.port = null;
this.isOpened = false;
this.#setupListeners();
}
async open() {}
async close() {}
async write(data) {}
async read() {}
#setupListeners() {}
}
```
* [`TextEncoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder) is a class that encodes a string into an array of unsigned 8-bit integers [`Uint8Array`](https://webidl.spec.whatwg.org/#idl-Uint8Array).
* [`TextDecoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder#examples), on the contrary, decodes into a string. We will need instances of these classes, because we will be operating with data of type `Uint8Array`.
* The `onConnect` and `onDisconnect` functions will be called when the device is connected or disconnected.
* The [`options`](https://wicg.github.io/serial/#serialoptions-dictionary) property contains the necessary parameters that will be used for transmitting and receiving data.
* Property [`port`](https://wicg.github.io/serial/#serialport-interface) - object which will contain methods for working with the port and information about the device after the connection is established.
* The function `setupListeners` will simply add event handlers.
The following is the implementation of these methods.
## Opening a serial port
To open a serial port you first need to call the [`requestPort`](https://wicg.github.io/serial/#requestport-method) method which will open a special window with a list of devices. It is also necessary that the user himself activate the call to this window, otherwise there will be an error. This can be done just by the event of pressing the button.
```jsx
async open() {
try {
const port = await navigator.serial.requestPort();
await port.open(this.options);
this.port = port;
this.isOpened = true;
return this.port.getInfo();
} catch (error) {
console.error(error);
throw error;
}
}
```
You can pass an optional filter parameter to the `requestPort` function, which will limit the list of selectable ports according to the USB ID:
```jsx
const port = await navigator.serial.requestPort({
filters: [{ usbVendorId: 0x7522 }],
});
```
A list of USB IDs can be found at [this](https://devicehunt.com/all-usb-vendors).
<figure>
<img
src="/media/webserial-connect.png"
width="700"
height="400"
alt="Selecting a device through a special window"
format="webp"
fit="fill"
>
<figcaption>Selecting a device through a special window</figcaption>
</figure>
After the user has selected the device, the `requestPort` method returns [`port`](https://wicg.github.io/serial/#serialport-interface) which can be opened by passing [parameters](https://wicg.github.io/serial/#serialoptions-dictionary) there:
* `baudRate` : baud rate.
* `dataBits` : number of data bits in the frame (7 or 8).
* `stopBits` : number of stop bits at the end of the packet (1 or 2).
* `parity` : parity mode ( `none` , `even` or `odd` ).
* `bufferSize` : the size of the read and write buffers to be created.
* `flowControl` : flow control mode ( `none` or `hardware`).
The `baudRate` property is the only mandatory parameter, the others are optional and have [default values](https://wicg.github.io/serial/#serialoptions-dictionary).
At the end our method returns [information](https://wicg.github.io/serial/#dom-serialportinfo) about the connected device using the method [`getInfo`](https://wicg.github.io/serial/#dom-serialport-getinfo).
## Read data
Note that the WebSerial API is asynchronous, which prevents the user interface from locking up while accepting data.
It is also important to know that the WebSerial API uses streams [Stream API](https://streams.spec.whatwg.org/). When streaming, the data is split into `(chunks)`, this allows the data to be processed without completely waiting for it to arrive.
Receiving data can be implemented as follows:
```jsx
async read() {
while (this.port.readable) {
const reader = this.port.readable.getReader();
let chunks = '';
try {
while (true) {
const { value, done } = await reader.read();
const decoded = this.decoder.decode(value);
chunks += decoded;
if (done || decoded.includes(EOT)) {
console.log('Reading done.');
reader.releaseLock();
break;
}
}
return chunks;
} catch (error) {
console.error(error);
throw error;
} finally {
reader.releaseLock();
}
}
}
```
When the user has connected to the device, `this.port` will have properties like `writable` and `readable` which are instances of the [WritableStream](https://streams.spec.whatwg.org/#writablestream) and [ReadableStream](https://streams.spec.whatwg.org/#readablestream) classes. We will need them to read and write data.
The external `while` loop is for error checking, such as parity checks. If a fatal error occurs, the `readable` property will become `null`.
The method [`getReader`](https://streams.spec.whatwg.org/#rs-get-reader) creates [reader](https://streams.spec.whatwg.org/#reader) which will allow to get data in fragments. A readable thread has no more than one reader at a time, so after that the thread is **[locked](https://streams.spec.whatwg.org/#lock)**, but the reader remains **active**.
The internal `while` loop reads data using the `read` method of the reader. This method returns the `value` property, which has data of type `UInt8Array`, and the `done` property, which becomes `true` if the serial device no longer transmits any data. Then we decode the data into a string and write it into the `chunks` variable. There is also a check for the `EOT` (End-of-Transmission) character, which means the end of the data transfer. Define this character at the beginning of the `app.js` file as a global variable:
```jsx
var EOT = "\u0004";
```
After the end of data reception, the thread must be unlocked by calling the `releaseLock` method to make the reader inactive.
## Write data
In order to send data to a serial device, you must create a [writer](https://streams.spec.whatwg.org/#writer) that will provide the ability to send snippets to the writing stream:
```jsx
async write(data) {
const writer = this.port.writable.getWriter();
const encoded = this.encoder.encode(data);
await writer.write(encoded);
writer.releaseLock();
}
```
Before sending, you need to encode your string into data of type `UInt8Array`. After creating a writer, the writing thread is also locked, so after writing it must be unlocked using the same `releaseLock` method.
## Closing a port
You can use the [`close`](https://wicg.github.io/serial/#dom-serialport-close) method to close a port when communication with the device is no longer needed:
```jsx
async close() {
await this.port.close();
this.isOpened = false;
}
```
The port cannot be closed when the thread that is writing or reading is blocked.
## Event Handling
The API allows you to subscribe to events when a trusted device connects or when it disconnects:
```jsx
#setupListeners() {
navigator.serial.addEventListener('connect', this.onConnect);
navigator.serial.addEventListener('disconnect', this.onDisconnect);
}
```
## Using the created class
Now you can write the front-end part of the web application using our created class `SerialPortHandler`:
```jsx
class Application {
constructor(root) {
if (!("serial" in navigator)) {
console.error("Web Serial API is not supported in your browser.");
return;
}
this.serialPortHandler = new SerialPortHandler(
{ baudRate: 9600 },
() => console.log("Device connected."),
() => {
console.log("Device disconnected.");
this.#disconnectHandler();
}
);
/**
* DOM Elements
*/
this.$root = root;
this.$connectButton = this.$root.querySelector("#connect");
this.$disconnectButton = this.$root.querySelector("#disconnect");
this.$terminalForm = this.$root.querySelector("#terminal_form");
this.$serialLog = this.$root.querySelector("#serial_log");
this.$status = this.$root.querySelector("#status");
this.$vendorId = this.$root.querySelector("#vendor_id");
this.$productId = this.$root.querySelector("#product_id");
this.#setupEvents();
}
/**
* Handlers for connecting, disconnecting and sending a command
*/
#setupEvents() {
this.$connectButton.addEventListener("click", this.#connectHandler.bind(this));
this.$disconnectButton.addEventListener("click", this.#disconnectHandler.bind(this));
this.$terminalForm.addEventListener("submit", this.#submitHandler.bind(this));
}
/**
* Open serial port and notify user of connection status
* @returns {Promise<void>}
*/
async #connectHandler() {
try {
if (this.serialPortHandler.isOpened) return;
const info = await this.serialPortHandler.open();
console.log("Port opened: ", info);
this.$terminalForm.elements.input.removeAttribute("disabled");
this.$terminalForm.elements.send.removeAttribute("disabled");
this.$vendorId.textContent = "0x" + info.usbVendorId.toString(16);
this.$productId.textContent = "0x" + info.usbProductId.toString(16);
this.$status.textContent = "CONNECTED";
} catch (error) {
this.$status.textContent = "ERROR";
}
}
/**
* Closes the serial port and updates the connection status.
* @returns {Promise<void>}
*/
async #disconnectHandler() {
if (!this.serialPortHandler.isOpened) return;
await this.serialPortHandler.close();
this.$terminalForm.elements.input.setAttribute("disabled", "true");
this.$terminalForm.elements.send.setAttribute("disabled", "true");
this.$vendorId.textContent = "-";
this.$productId.textContent = "-";
this.$status.textContent = "NOT CONNECTED";
}
/**
* Writes data to the serial port and reads the response
* @param {SubmitEvent} e - Form submit event
*/
async #submitHandler(e) {
e.preventDefault();
const $form = e.target;
const data = $form.elements.input.value;
$form.reset();
if (this.serialPortHandler.isOpened && data) {
this.$serialLog.innerHTML += ">" + data + "\n";
await this.serialPortHandler.write(data + "\n");
const message = await this.serialPortHandler.read();
this.$serialLog.textContent += message.replaceAll(EOT, "");
console.log("Message received: \n" + message);
}
this.$serialLog.scrollTo(0, this.$serialLog.scrollHeight);
}
}
```
Basically there is a work with interface updates and I think it is not necessary to explain the logic in detail, I will note only the key points:
In the constructor of this class, you can determine whether the browser supports WebSerial API by checking the `serial` key in the `navigator` object, and if not, we write an error to the console. Object `this.serialPortHandler` will be an instance of our created class `SerialPortHandler`, we pass the speed **9600** and functions which will be the handlers of events to connect and disconnect the device as arguments. Then we look for the necessary DOM elements and add an event listener to some of them.
The `#submitHandler` method is designed to send commands and accept the result of command execution. When you use the `write` method, be sure to add a `\n` character at the end, so the program in the microcontroller knows where the command name ends.
Initialize the application at the end of the `app.js` file:
```js
new Application(document.getElementById("app"));
```
## Checking the web application
You can now send commands to the microcontroller:
<figure>
<img
src="/media/webserial-demo-2.png"
width="550"
height="300"
alt="The finished result of the web application"
format="webp"
>
<figcaption>The finished result of the web application</figcaption>
</figure>
For fun, you can flash the LED:
```js
const app = new Application(document.getElementById("app"));
setInterval(async () => {
if (app.serialPortHandler.isOpened) {
await app.serialPortHandler.write("led_toggle" + "\n");
const message = await app.serialPortHandler.read();
console.log(message);
}
}, 500);
```
<figure>
<img src="/media/webserial-demo-3.gif" loading="lazy" alt="blinking LED" />
<figcaption>Blinking LED</figcaption>
</figure>
# Conclusion
Having developed the web application we have seen that using the WebSerial API is quite easy. I hope that this article will be useful to you and help you in developing your project.
# Useful stuff
* [Browser support status Can I use](https://caniuse.com/web-serial)
* [WebSerial API documentation](https://wicg.github.io/serial/)
* [Stream API detailed guide](https://web.dev/streams/)
* [USB ID list](https://devicehunt.com/all-usb-vendors)
* [Access to different devices from the browser](https://web.dev/devices-introduction/)

View File

@ -1,900 +0,0 @@
---
coverImage: /media/serial-port.jpg
draft: false
author: Сергей Ярков
title: Использование WebSerial API для общения с микроконтроллером
description: Речь пойдет о том, что такое WebSerial API, как его можно
использовать в браузере для чтения и записи данных в последовательное
устройство и где его можно применить.
tags:
- JavaScript
- AVR
- WebSerial API
- Tutorial
pubDate: 2022-11-04T21:00:00.000Z
updatedDate: 2023-02-13T16:34:23.326Z
---
# Что вы узнаете
По итогу вы узнаете, как отправлять и принимать данные от устройства с последовательным портом в браузере, разработаете демонстрационное веб-приложение, которое отправляет команды в микроконтроллер с последовательным портом и напишите прошивку для микроконтроллера [AVR128DA48](http://ww1.microchip.com/downloads/en/DeviceDoc/40002183A.pdf) для обработки этих команд. Готовый вариант проекта можно посмотреть [здесь](https://web-serial-example.netlify.app/), исходники загружены в [репозиторий](https://github.com/sergeyyarkov/avr128da48_web-serial-example) на GitHub.
# Что это и где применяется
WebSerial API - это браузерный API, который предоставляет возможность к чтению и записи данных в последовательное устройство. Использование этого API довольно простое, нужно лишь написать несколько строк кода, чтобы получить или отправить ваши данные. К тому же имеется подробная [документация](https://wicg.github.io/serial/), где имеются примеры использования этого API.
Благодаря этому API, открываются новые возможности. Например, вы можете перепрошивать ваш микроконтроллер прямо из браузера, что звучит весьма интересно (но перед этим вам необходимо написать bootloader), или же просто выводить информацию с датчиков на веб страницу. Вариантов применения неограниченно, все зависит только от вашей фантазии.
Еще существуют и высокоуровневые API для доступа к таким устройствам как геймпад или камера. Я оставлю эту ссылку на [эту](https://web.dev/devices-introduction/) статью, где рассказывается про список этих API.
# Поддержка браузеров
На момент написания статьи (2022 год) полноценная поддержка этого API доступна лишь в браузерах на базе Chromium. Полную таблицу можно посмотреть на сайте [Can I Use](https://caniuse.com/web-serial).
На Android поддержка может быть осуществлена на базе [WebUSB API](https://wicg.github.io/webusb/) и [полифилла Serial API](https://github.com/google/web-serial-polyfill).
# Краткий экскурс про UART
Перед работой с последовательным портом, неплохо было бы знать основы протокола передачи данных UART, если вы с ним уже знакомы, можете пропустить эту часть.
**UART** - расшифровывается как (Universal Asynchronous Receiver Transmitter) - универсальный асинхронный приемопередатчик, который определяет протокол для приема и передачи данных между двумя устройствами. **Универсальность** означает, что мы можем настраивать параметры, включая скорость передачи данных. **Асинхронность** же означает, что у нас нет синхронизирующего сигнала между передатчиком и приёмником. Существует также и синхронная версия передачи данных с общим тактовым сигналом, которая может передавать данные намного быстрее, но она используется редко. Для связи двух устройств используются лишь две линии, которые обозначаются как RX (**receiver**) и TX (**transmitter**).
<figure>
<img alt="Схема подключения двух UART устройств" width="550" height="300" src="/media/uart-connection.jpg" loading="lazy" decoding="async">
<figcaption>Схема подключения двух UART устройств</figcaption>
</figure>
UART передает данные последовательно по одному биту в одном из трех режимов:
- односторонний режим - данные отправляются только в одном направлении, от передатчика к приемнику
- полудуплексный режим - устройства могут передавать и принимать данные по очереди
- полнодуплексный режим - устройства могут передавать и принимать данные одновременно
Так как в этом проекте предполагается использование асинхронной версии UART, то соответственно необходимо устанавливать **одинаковую** скорость передачи битов в секунду (`baud rate`, `bps`) на двух устройствах. Наиболее распространенные скорости являются: **4800, 9600, 19.2 К, 57.6К и 115.2К.**
Данные передаются в виде так называемых `пакетов`, где каждый пакет содержит в себе:
- стартовый и стоповый биты - стартовый бит сигнализирует о поступлении битов данных, стоповый соответственно о конце данных.
- биты данных - пользовательские данные, которые поступают сразу после стартового бита, может содержаться от 5 до 8 битов.
- бит четности - необязательный бит, который идет после битов данных и перед стоповым битом и используется для обнаружения ошибок.
<figure>
<img src="/media/uart-packet.jpg" width="700" height="300" alt="Структура UART пакета" format="webp">
<figcaption>Структура UART пакета</figcaption>
</figure>
Я думаю этой информации вполне достаточно для того, чтобы понимать, что из себя представляет UART. Если вы хотите более подробно изучить этот интерфейс, то в интернете существует множество статей на эту тему.
# Преобразователь интерфейсов
Поскольку скорее всего вы будете подключать микроконтроллер к USB порту компьютера, вам необходим некий переводчик между двумя интерфейсами для отправки и получения данных, так как USB и UART совершенно разные протоколы передачи данных. Таких преобразователей существует множество, вот одни из них: [CH340](https://pdf1.alldatasheet.com/datasheet-pdf/view/1132602/WCH/CH340.html), [CP2102](https://pdf1.alldatasheet.com/datasheet-pdf/view/201067/SILABS/CP2102.html) или [FT232](https://pdf1.alldatasheet.com/datasheet-pdf/view/197629/FTDI/FT232BL.html). Если вы используете отладочную плату Arduino UNO, то у вас уже используется один из этих преобразователей на вашей плате.
<figure>
<img src="/media/arduino-uart-converter.png" width="470" height="320" alt="Преобразователь интерфейсов CH340G на плате Arduino UNO" format="webp"><figcaption>
Преобразователь интерфейсов CH340G на плате Arduino UNO
</figcaption>
</figure>
Если же на вашей отладочной плате не оказалось этого преобразователя интерфейсов, то вы можете приобрести модуль в любом магазине, цена на него обычно не превышает $5.
# Разработка прошивки для микроконтроллера
Как я упомянул ранее, прошивка будет написана для микроконтроллера AVR128DA48. На самом деле неважно какой микроконтроллер вы используете, вам достаточно нужно уметь отправлять и принимать данные используя UART периферию вашего микроконтроллера.
Суть программы будет очень проста: необходимо написать некий **_обработчик команд_** который будет принимать на вход строку (например `led_toggle`) и вызывать необходимую функцию, которая будет выполнять какое-то действие, в данном случае переключать светодиод.
Постановка задачи ясна, теперь рассмотрим реализацию данной программы. Я буду использовать среду разработки MPLAB X IDE и язык C для написания прошивки.
## Необходимые константы
Для начала для всего проекта определим список констант, создав файл `constants.h`:
```c
#ifndef CONSTANTS_H
#define CONSTANTS_H
#define F_CPU 4000000UL
#define BAUD_RATE 9600
#define BUFFER_SIZE 20
#define EOT 0x04
#endif /* CONSTANTS_H */
```
- `F_CPU` - тактовая частота микроконтроллера
- `BAUD_RATE` - скорость передачи данных UART
- `BUFFER_SIZE` - размер буфера, куда будем складывать поступающие данные
- `EOT` - End-of-Transmission, ASCII символ конца передачи данных
## Инициализация USART
Создадим заголовочный файл `usart.h` и определим несколько функций для инициализации USART, отправки символа и отправки строки.
```c
#include "constants.h"
#ifndef USART_H
#define USART_H
#define USART1_BAUD_RATE(BAUD_RATE) ((float)(64 * F_CPU / (16 * (float)BAUD_RATE)) + 0.5)
#include <avr/io.h>
#include <stdio.h>
#include <string.h>
void USART1_Initialize(void);
void USART1_SendChar(char c);
void USART1_SendString(char *str);
#endif /* USART_H */
```
Макрос `USART1_BAUD_RATE` вычисляет значение, которое необходимо записать в регистр `USARTn.BAUD` для установки скорости передачи данных. Формула взята из [документации](https://ww1.microchip.com/downloads/aemDocuments/documents/MCU08/ProductDocuments/DataSheets/AVR128DA28-32-48-64-Data-Sheet-40002183C.pdf) к микроконтроллеру (глава 25.3.2.2.1, таблица 25-1).
Реализуем функцию инициализации USART в файле `usart.c`:
```c
void USART1_Initialize(void) {
/* set baud rate */
USART1.BAUD = (uint16_t) (USART1_BAUD_RATE(BAUD_RATE));
/* set char size in a frame to 8 bit */
USART1.CTRLC = USART_CHSIZE0_bm | USART_CHSIZE1_bm;
/* config pins for TX and RX */
PORTC.DIRSET = PIN0_bm;
PORTC.DIRCLR = PIN1_bm;
/* enable reveice complete interrupt */
USART1.CTRLA = USART_RXCIE_bm;
/* enable transmitter and receiver */
USART1.CTRLB = USART_TXEN_bm | USART_RXEN_bm;
}
```
Для полнодуплексного режима инициализация асинхронной версии USART происходит следующим образом:
- Конфигурация скорости передачи данных путем записывания значения в регистр `USARTn.BAUD`
- Конфигурация размера `фрейма`, в нашем случае это 8 бит.
- Конфигурация пина `TX` на выход и пина `RX` на вход.
- Включение прерывания на то, когда закончился прием данных (когда пришел один пакет).
- Включение приемника и передатчика.
Теперь, рассмотрим функции отправки данных:
```c
void USART1_SendChar(char c) {
while (!(USART1.STATUS & USART_DREIF_bm));
USART1.TXDATAL = c;
}
void USART1_SendString(char *str) {
for (size_t i = 0; i < strlen(str); i++) {
USART1_SendChar(str[i]);
}
}
```
Для того чтобы отправить данные, необходимо записать в регистр `USARTn.TXDATAL` один байт, но перед этим нужно убедиться, что предыдущая передача была завершена путем проверки регистра `USARTn.STATUS`. Как указано в документации, бит `DREIF` (Data Register Empty Flag) является установленным, если данные в TX буфере **отсутствуют**. Соответственно можно сделать пустой цикл который будет выполняться пока у нас есть данные в TX буфере.
Функция отправки строки является некой оберткой, которая будет вызывать функцию отправки одного символа.
## Обработчик команд
Как видно из постановки задачи, на вход нам поступает некая строка, которая говорит о том, какое действие должно выполниться.
Определим заголовочный файл `command.h` и напишем туда следующее:
```c
#ifndef COMMAND_H
#define COMMAND_H
#include <avr/io.h>
#include <string.h>
#define COMMAND_SIZE 5
#define COMMAND_MAX_NAME_LENGTH 20
struct Command {
void *addr;
char name[COMMAND_MAX_NAME_LENGTH];
};
uint8_t command_define(void *fp, char name[COMMAND_MAX_NAME_LENGTH]);
uint8_t command_process(char *cmd_name);
void command_list(void);
#endif /* COMMAND_H */
```
Для решения этой задачи я предлагаю создавать массив структур, где в каждой структуре будет содержаться указатель на функцию которую нам нужно выполнить, и название самой команды.
- `command_define` будет создавать новую структуру в массиве.
- `command_process` будет вызывать соответствующую функцию по имени команды.
- `command_list` будет отправлять по UART информацию о существующих командах.
Реализовать задуманное можно следующим образом создав файл `command.c`:
```c
#include "command.h"
#include "usart.h"
struct Command commands[COMMAND_SIZE];
uint8_t cmd_idx = 0;
uint8_t command_define(void *fp, char name[COMMAND_MAX_NAME_LENGTH]) {
size_t name_length = strlen(name);
if (name_length > COMMAND_MAX_NAME_LENGTH) {
return 0;
}
if (cmd_idx > COMMAND_SIZE) {
cmd_idx = 0;
}
struct Command command;
command.addr = fp;
strcpy(command.name, name);
commands[cmd_idx++] = command;
return 1;
}
uint8_t command_process(char *cmd_name) {
uint8_t i = 0;
do {
if (strcmp(cmd_name, commands[i].name) == 0) {
int (*execute)();
execute = commands[i].addr;
execute();
USART1_SendString("[LOG]: Command completed successfully with code \"0\".\n");
USART1_SendChar(EOT);
return 0;
}
} while (i++ < COMMAND_SIZE);
USART1_SendString("[LOG]: Invalid command name!\n");
USART1_SendChar(EOT);
return 1;
}
void command_list(void) {
USART1_SendString("[LOG]: List of available commands:\n");
for (uint8_t i = 0; i < COMMAND_SIZE; i++) {
if (commands[i].addr) {
USART1_SendChar((i + 1) + '0');
USART1_SendString(". ");
USART1_SendString(commands[i].name);
USART1_SendChar('\n');
}
}
}
```
Важно отметить, что после отправки данных, необходимо отослать символ `EOT`, который будет говорить о том, что передача закончена, это поможет в будущем в разработке веб-приложения.
## Прием данных
Последнее, что необходимо сделать, это принимать команды по USART и отправлять их обработчику. Для этого можно использовать прерывание:
```c
char buffer[BUFFER_SIZE];
volatile uint8_t buff_idx = 0;
ISR(USART1_RXC_vect) {
if (buff_idx > BUFFER_SIZE) buff_idx = 0;
buffer[buff_idx] = USART1.RXDATAL;
buff_idx++;
}
```
После срабатывания прерывания `USART1_RXC_vect`, в регистре `USART1.RXDATAL` появляются данные (один байт). Этот байт мы должны записать в свой буфер с фиксированным размером. Еще есть другой вариант принятия данных не используя прерывание. Для этого достаточно проверять бит `RXCIF` (USART Receive Complete Interrupt Flag) в регистре `USARTn.STATUS`. Этот бит является установленным, когда в приемном буфере присутствуют данные.
Для того того чтобы обработать входящие команды, в главном цикле можно проверять буфер на наличие символа `\n`, этот символ будет означать конец названия команды:
```c
while (1) {
if (buff_idx >= 1) {
if (buffer[buff_idx - 1] == '\n') {
buffer[buff_idx - 1] = '\0';
command_process(buffer);
buff_idx = 0;
}
}
}
```
После нахождения этого символа, можно передать буфер обработчику, затем сбросить индекс буфера в ноль. В итоге файл `main.c` будет выглядеть следующим образом:
```c
#include <avr/io.h>
#include <avr/interrupt.h>
#include <string.h>
#include "constants.h"
#include "command.h"
#include "usart.h"
char buffer[BUFFER_SIZE];
volatile uint8_t buff_idx = 0;
void led_toggle(void);
void hello_world(void);
ISR(USART1_RXC_vect) {
if (buff_idx > BUFFER_SIZE) buff_idx = 0;
buffer[buff_idx] = USART1.RXDATAL;
buff_idx++;
}
void MCU_Init_Ports(void) {
/* led pin PC6 to output */
PORTC.DIRSET = PIN6_bm;
}
int main(void) {
MCU_Init_Ports();
USART1_Initialize();
sei();
command_define(led_toggle, "led_toggle");
command_define(hello_world, "hello");
command_define(command_list, "list");
while (1) {
if (buff_idx >= 1) {
if (buffer[buff_idx - 1] == '\n') {
buffer[buff_idx - 1] = '\0';
command_process(buffer);
buff_idx = 0;
}
}
}
}
void led_toggle(void) {
PORTC.OUTTGL = PIN6_bm;
}
void hello_world(void) {
USART1_SendString("Hello world!\n");
}
```
Обязательно после инициализации портов микроконтроллера и USART нужно включить глобальные прерывания, вызвав функцию `sei`
С помощью функции `command_define` мы можем определить, по какой переданной команде по USART запускать функцию:
- `led_toggle` будет переключать светодиод.
- `hello_world` выводить сообщение “Hello world”.
- `command_list` будет показывать список имеющихся команд.
## Демонстрация работы
Теперь собрав и загрузив прошивку в ваш микроконтроллер, можно проверить работоспособность. Я буду использовать программу `CuteCom` для открытия последовательного порта, вы можете использовать любую другую подобную.
Выберете ваше устройство из списка (в моем случае это `/dev/ttyACM1`) и установите следующие настройки:
- Скорость: **9600** бод
- Количество битов: **8**
- Четность: **None**
- Стоповые биты: **1**
<figure>
<img
src="/media/uart-connect-settings.png"
width="700"
height="400"
alt="Настройки для открытия последовательного порта"
format="webp"
fit="fill"
>
<figcaption>Настройки для открытия последовательного порта</figcaption>
</figure>
Теперь после открытия порта можно отправлять команды, не забудьте установить возврат каретки как `LF`, т.к наша прошивка распознает окончание команды только по этому символу.
<figure>
<img
src="/media/webserial-demo-1.png"
width="700"
height="400"
alt="Пример отправки команды “hello”"
format="webp"
fit="fill"
>
<figcaption>Пример отправки команды “hello”</figcaption>
</figure>
# Разработка веб-приложения
Веб-приложение будет состоять всего из нескольких элементов: поле для ввода, куда мы сможем писать наши команды, кнопки для открытия и закрытия последовательного порта, статус соединения. Готовый вариант можно посмотреть [здесь](https://web-serial-example.netlify.app/).
### Создание разметки HTML и стилей
Разметка HTML будет выглядеть следующим образом:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web Serial API Example</title>
<link href="./style.css" rel="stylesheet" />
<script defer src="./app.js"></script>
</head>
<body>
<div id="app">
<div>
<button id="connect">Connect</button>
<button id="disconnect">Disconnect</button>
</div>
<form id="terminal_form" action="#">
<label for="input">Enter command name:</label><br />
<div>
<input id="input" name="input" placeholder="e.g led_toggle" type="text" disabled />
<button name="send" type="submit" disabled>Send</button>
</div>
</form>
<textarea id="serial_log" placeholder=">" readonly></textarea>
<div id="port_info">
<div>
<p id="status">not connected</p>
</div>
<div>
<span>vendorId: <span id="vendor_id">-</span>&nbsp;|</span>
<span>deviceId: <span id="product_id">-</span></span>
</div>
</div>
</div>
</body>
</html>
```
Добавим немного стилей создав файл `style.css`:
```css
:root {
--border-color: #c9d1d9;
--border-color-hover: #6e7681;
--font-family: monospace;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
#app {
display: flex;
flex-direction: column;
width: 100%;
max-width: 600px;
row-gap: 5px;
}
html,
body {
box-sizing: border-box;
font-size: 16px;
}
body {
display: flex;
justify-content: center;
margin-top: 2rem;
font-family: var(--font-family);
}
p {
padding: 0;
margin: 5px;
}
input,
textarea {
font-family: var(--font-family);
padding: 5px 15px;
border-radius: 4px;
width: 100%;
border: 1px solid var(--border-color);
}
textarea {
resize: vertical;
min-height: 200px;
line-height: 1.4;
font-size: 16px;
outline: none;
font-size: 12px;
}
label {
font-size: 14px;
font-style: italic;
}
button {
cursor: pointer;
background: none;
font-family: var(--font-family);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 5px 15px;
}
button:hover:not(:disabled) {
border: 1px solid var(--border-color-hover);
}
#terminal_form div {
display: flex;
justify-content: space-between;
gap: 5px;
margin-bottom: 5px;
margin-top: 5px;
}
#port_info {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
font-size: 14px;
}
#port_info #status {
text-transform: uppercase;
}
```
## Создание класса SerialPortHandler
Логика работы с последовательным портом будет реализована в этом классе, который будет небольшой оберткой для удобного использования WebSerial API, он будет иметь всего 5 методов и несколько свойств.
Определим этот класс в файле `app.js`:
```jsx
class SerialPortHandler {
constructor(options, onConnect, onDisconnect) {
this.encoder = new TextEncoder();
this.decoder = new TextDecoder();
this.onConnect = onConnect;
this.onDisconnect = onDisconnect;
this.options = options;
this.port = null;
this.isOpened = false;
this.#setupListeners();
}
async open() {}
async close() {}
async write(data) {}
async read() {}
#setupListeners() {}
}
```
- [`TextEncoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder) является классом, который кодирует строку в массив без знаковых 8-ми битных целых чисел [`Uint8Array`](https://webidl.spec.whatwg.org/#idl-Uint8Array).
- [`TextDecoder`](https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder#examples) наоборот, декодирует в строку. Экземпляры этих классов нам понадобятся, т.к мы будет оперировать данными с типом `Uint8Array`.
- Функции `onConnect` и `onDisconnect` будут вызываться при подключении или отключении устройства.
- Свойство [`options`](https://wicg.github.io/serial/#serialoptions-dictionary) содержит необходимые параметры, которые будут использоваться для передачи и приема данных.
- Свойство [`port`](https://wicg.github.io/serial/#serialport-interface) - объект, который будет содержать методы для работы с портом и информацию об устройстве после установки соединения.
- Функция `setupListeners` просто будет добавлять обработчики событий.
Далее рассматривается реализация этих методов.
## Открытие последовательного порта
Для открытия последовательного порта для начала необходимо вызвать метод [`requestPort`](https://wicg.github.io/serial/#requestport-method), который откроет специальное окно со списком устройств. Также необходимо, чтобы пользователь сам активировал вызов этого окна, иначе будет ошибка. Это как раз можно сделать по событию нажатия кнопки.
```jsx
async open() {
try {
const port = await navigator.serial.requestPort();
await port.open(this.options);
this.port = port;
this.isOpened = true;
return this.port.getInfo();
} catch (error) {
console.error(error);
throw error;
}
}
```
В функцию `requestPort` в качестве параметра можно передать необязательный параметр фильтр, который ограничит список выбираемых портов в соответствии с USB идентификатором:
```jsx
const port = await navigator.serial.requestPort({
filters: [{ usbVendorId: 0x7522 }],
});
```
Список USB идентификаторов можно посмотреть на [этом](https://devicehunt.com/all-usb-vendors) сайте.
<figure>
<img
src="/media/webserial-connect.png"
width="700"
height="400"
alt="Выбор устройства через специальное окно"
format="webp"
fit="fill"
>
<figcaption>Выбор устройства через специальное окно</figcaption>
</figure>
После того как пользователь выбрал устройство, метод `requestPort` возвращает [`port`](https://wicg.github.io/serial/#serialport-interface), который можно открыть передав туда [параметры](https://wicg.github.io/serial/#serialoptions-dictionary):
- `baudRate` : скорость передачи данных.
- `dataBits` : количество бит данных во фрейме (7 или 8).
- `stopBits` : количество стоповых битов в конце пакета (1 или 2).
- `parity` : режим четности ( `none` , `even` или `odd` ).
- `bufferSize` : размер буферов чтения и записи, которые должны быть созданы.
- `flowControl` : режим управления потоком ( `none` или `hardware` ).
Свойство `baudRate` является единственным обязательным параметром, остальные же являются необязательными и имеют [значения по умолчанию](https://wicg.github.io/serial/#serialoptions-dictionary).
В конце наш метод возвращает [информацию](https://wicg.github.io/serial/#dom-serialportinfo) о подключенном устройстве используя метод [`getInfo`](https://wicg.github.io/serial/#dom-serialport-getinfo).
## Чтение данных
Отмечу, что WebSerial API является асинхронным, что позволяет предотвращать блокировку пользовательского интерфейса во время принятия данных.
Еще важно знать, что WebSerial API использует потоки [Stream API](https://streams.spec.whatwg.org/). При потоковой передаче данные разбиваются на `фрагменты` `(chunks)`, это позволяет обрабатывать данные без полного ожидания их поступления.
Получение данных можно реализовать следующим образом:
```jsx
async read() {
while (this.port.readable) {
const reader = this.port.readable.getReader();
let chunks = '';
try {
while (true) {
const { value, done } = await reader.read();
const decoded = this.decoder.decode(value);
chunks += decoded;
if (done || decoded.includes(EOT)) {
console.log('Reading done.');
reader.releaseLock();
break;
}
}
return chunks;
} catch (error) {
console.error(error);
throw error;
} finally {
reader.releaseLock();
}
}
}
```
Когда пользователь подключился к устройству, `this.port` будет иметь такие свойства как `writable` и `readable`, которые являются экземплярами классов [WritableStream](https://streams.spec.whatwg.org/#writablestream) и [ReadableStream](https://streams.spec.whatwg.org/#readablestream). Они нам понадобятся для чтения и записи данных.
Внешний цикл `while` предназначен для проверки ошибок, такие как проверка четности. При возникновении фатальной ошибки, свойство `readable` станет `null`.
Метод [`getReader`](https://streams.spec.whatwg.org/#rs-get-reader) создает [читатель](https://streams.spec.whatwg.org/#reader), который даст возможность получать данные фрагментами. Доступный для чтения поток одновременно имеет не более одного читателя, соответственно после этого поток является **[заблокированным](https://streams.spec.whatwg.org/#lock)**, но читатель остается **активным**.
Внутренний цикл `while` читает данные используя метод `read` у читателя. Этот метод возвращает свойство `value`, который имеет данные c типом `UInt8Array`, и свойство `done`, которое станет `true`, если последовательное устройство больше не передает никаких данных. Далее мы декодируем данные в строку и записываем в переменную `chunks`. Так же идет проверка на наличие символа `EOT` (End-of-Transmission), который означает конец передачи данных. Определите этот символ в начале файла `app.js` как глобальную переменную:
```jsx
var EOT = "\u0004";
```
После конца приема данных необходимо разблокировать поток, вызвав метод `releaseLock` для того чтобы сделать читателя неактивным.
## Запись данных
Для того чтобы отправить данные в последовательное устройство, необходимо создать [писателя](https://streams.spec.whatwg.org/#writer), который предоставит возможность отправлять фрагменты в записывающий поток:
```jsx
async write(data) {
const writer = this.port.writable.getWriter();
const encoded = this.encoder.encode(data);
await writer.write(encoded);
writer.releaseLock();
}
```
Перед отправкой нужно закодировать вашу строку в данные с типом `UInt8Array`. После создания писателя записывающий поток так же является заблокированным, соответственно после записи необходимо этот поток разблокировать используя тот же метод `releaseLock`.
## Закрытие порта
Для закрытия порта, когда коммуникация с устройством больше не требуется можно использовать метод [`close`](https://wicg.github.io/serial/#dom-serialport-close):
```jsx
async close() {
await this.port.close();
this.isOpened = false;
}
```
Порт невозможно закрыть когда записывающий или читающий поток является заблокированным.
## Обработка событий
API дает возможность подписываться на события, когда подключается доверенное устройство или когда оно отключается:
```jsx
#setupListeners() {
navigator.serial.addEventListener('connect', this.onConnect);
navigator.serial.addEventListener('disconnect', this.onDisconnect);
}
```
## Использование созданного класса
Теперь можно написать front-end часть веб-приложения используя наш созданный класс `SerialPortHandler`:
```jsx
class Application {
constructor(root) {
if (!("serial" in navigator)) {
console.error("Web Serial API is not supported in your browser.");
return;
}
this.serialPortHandler = new SerialPortHandler(
{ baudRate: 9600 },
() => console.log("Device connected."),
() => {
console.log("Device disconnected.");
this.#disconnectHandler();
}
);
/**
* DOM Elements
*/
this.$root = root;
this.$connectButton = this.$root.querySelector("#connect");
this.$disconnectButton = this.$root.querySelector("#disconnect");
this.$terminalForm = this.$root.querySelector("#terminal_form");
this.$serialLog = this.$root.querySelector("#serial_log");
this.$status = this.$root.querySelector("#status");
this.$vendorId = this.$root.querySelector("#vendor_id");
this.$productId = this.$root.querySelector("#product_id");
this.#setupEvents();
}
/**
* Handlers for connecting, disconnecting and sending a command
*/
#setupEvents() {
this.$connectButton.addEventListener("click", this.#connectHandler.bind(this));
this.$disconnectButton.addEventListener("click", this.#disconnectHandler.bind(this));
this.$terminalForm.addEventListener("submit", this.#submitHandler.bind(this));
}
/**
* Open serial port and notify user of connection status
* @returns {Promise<void>}
*/
async #connectHandler() {
try {
if (this.serialPortHandler.isOpened) return;
const info = await this.serialPortHandler.open();
console.log("Port opened: ", info);
this.$terminalForm.elements.input.removeAttribute("disabled");
this.$terminalForm.elements.send.removeAttribute("disabled");
this.$vendorId.textContent = "0x" + info.usbVendorId.toString(16);
this.$productId.textContent = "0x" + info.usbProductId.toString(16);
this.$status.textContent = "CONNECTED";
} catch (error) {
this.$status.textContent = "ERROR";
}
}
/**
* Closes the serial port and updates the connection status.
* @returns {Promise<void>}
*/
async #disconnectHandler() {
if (!this.serialPortHandler.isOpened) return;
await this.serialPortHandler.close();
this.$terminalForm.elements.input.setAttribute("disabled", "true");
this.$terminalForm.elements.send.setAttribute("disabled", "true");
this.$vendorId.textContent = "-";
this.$productId.textContent = "-";
this.$status.textContent = "NOT CONNECTED";
}
/**
* Writes data to the serial port and reads the response
* @param {SubmitEvent} e - Form submit event
*/
async #submitHandler(e) {
e.preventDefault();
const $form = e.target;
const data = $form.elements.input.value;
$form.reset();
if (this.serialPortHandler.isOpened && data) {
this.$serialLog.innerHTML += ">" + data + "\n";
await this.serialPortHandler.write(data + "\n");
const message = await this.serialPortHandler.read();
this.$serialLog.textContent += message.replaceAll(EOT, "");
console.log("Message received: \n" + message);
}
this.$serialLog.scrollTo(0, this.$serialLog.scrollHeight);
}
}
```
В основном здесь происходит работа с обновлением интерфейса и я думаю не стоит подробно разъяснять логику, отмечу только ключевые моменты:
В конструкторе этого класса можно определить, поддерживает ли браузер WebSerial API, проверив ключ `serial` в объекте `navigator` и если нет, то пишем ошибку в консоль. Объект `this.serialPortHandler` будет являться экземпляром нашего созданного класса `SerialPortHandler`, в качестве аргументов передаем скорость **9600** и функции, которые будут являться обработчиками событий подключения и отключения устройства. Далее ищем необходимые DOM элементы и добавляем на некоторые из них прослушиватель событий.
Метод `#submitHandler` предназначен для отправки команд и принятия результата выполнения команды. При использовании метода `write` обязательно нужно в конце добавить символ `\n`, так программа в микроконтроллере узнает, где заканчивается название команды.
Инициализируйте приложение в конце файла `app.js`:
```js
new Application(document.getElementById("app"));
```
## Проверка работы веб-приложения
Теперь можно отсылать команды микроконтроллеру:
<figure>
<img
src="/media/webserial-demo-2.png"
width="550"
height="300"
alt="Готовый результат веб-приложения"
format="webp"
>
<figcaption>Готовый результат веб-приложения</figcaption>
</figure>
Ради забавы можно помигать светодиодом:
```js
const app = new Application(document.getElementById("app"));
setInterval(async () => {
if (app.serialPortHandler.isOpened) {
await app.serialPortHandler.write("led_toggle" + "\n");
const message = await app.serialPortHandler.read();
console.log(message);
}
}, 500);
```
<figure>
<img src="/media/webserial-demo-3.gif" loading="lazy" alt="мигание светодиодом" />
<figcaption>Мигание светодиодом</figcaption>
</figure>
# Заключение
Разработав веб-приложение мы убедились, что использование WebSerial API довольно простое. Надеюсь, что эта статья пригодится вам и поможет в разработке вашего проекта.
# Полезные материалы
- [Статус поддержки браузеров Can I use](https://caniuse.com/web-serial)
- [Документация к WebSerial API](https://wicg.github.io/serial/)
- [Подробное руководство по Stream API](https://web.dev/streams/)
- [Список USB идентификаторов](https://devicehunt.com/all-usb-vendors)
- [Доступ к различным устройствам из браузера](https://web.dev/devices-introduction/)

View File

@ -1,15 +0,0 @@
---
draft: true
author: Сергей Ярков
title: Управление сдвиговым регистром 74HC595 микроконтроллером
description: В этом туториале рассматривается то, как управлять сдвиговым
регистром 74HC595 в связке с микроконтроллером AVR.
tags:
- AVR
- Digital Electronics
- Tutorial
pubDate: 2023-02-14T19:23:55.622Z
updatedDate: 2023-02-14T19:23:55.646Z
---
...

View File

@ -1,60 +0,0 @@
import { defineCollection, z } from "astro:content";
const blog = defineCollection({
schema: z.object({
title: z.string(),
draft: z.boolean().nullable().optional(),
coverImage: z.string().nullable().optional(),
author: z.string(),
tags: z.string().array(),
description: z.string(),
pubDate: z
.string()
.or(z.date())
.transform((val) => new Date(val)),
updatedDate: z
.string()
.or(z.date())
.optional()
.transform((str) => (str ? new Date(str) : undefined)),
}),
});
const tags = defineCollection({
schema: z.object({
title: z.string(),
}),
});
const authors = defineCollection({
schema: z.object({
firstName: z.string(),
lastName: z.string(),
}),
});
const projects = defineCollection({
schema: z.object({
icon: z.string(),
title: z.string(),
year: z.number(),
description: z.string(),
sourceUrl: z.string(),
demoUrl: z.string().nullable().optional(),
articleUrl: z.string().nullable().optional(),
}),
});
const skills = defineCollection({
schema: z.object({
title: z.string(),
list: z
.object({
icon: z.string().optional(),
title: z.string(),
})
.array(),
}),
});
export const collections = { blog, tags, authors, projects, skills };

View File

@ -1,10 +0,0 @@
---
icon: triangle_filled
title: devthread
year: 2021
description: Static website for publishing various articles, developed with
Gatsby.js and NetlifyCMS
sourceUrl: https://github.com/sergeyyarkov/devthread.ru
demoUrl: https://devthread.yarkov.tech
articleUrl: null
---

View File

@ -1,9 +0,0 @@
---
icon: round-shop-two
title: digital-store
year: 2019
description: Customizable online store with control panel for selling digital goods
sourceUrl: https://github.com/sergeyyarkov/digital-store/tree/digital-store-docker
demoUrl: https://digital-store.herokuapp.com
articleUrl: null
---

View File

@ -1,9 +0,0 @@
---
icon: round-school
title: educt
year: 2022
description: Learning Management System (LMS) to publish your courses, lectures, lessons
sourceUrl: https://github.com/sergeyyarkov/educt
demoUrl: https://educt.yarkov.tech
articleUrl: null
---

View File

@ -1,9 +0,0 @@
---
icon: chip
title: memory-led-game
year: 2022
description: Analogue of the popular game "Simon says" on the AVR microcontroller
sourceUrl: https://github.com/sergeyyarkov/attiny24a_memory-led-game
demoUrl: null
articleUrl: null
---

View File

@ -1,9 +0,0 @@
---
icon: nodejs
title: node-docker-example
year: 2022
description: An example of using Docker to deploy a Node.js application
sourceUrl: https://github.com/sergeyyarkov/node-docker-example
demoUrl: https://node-docker-example.onrender.com
articleUrl: null
---

View File

@ -1,9 +0,0 @@
---
icon: chat
title: react-chat-app
year: 2021
description: Client-server web chat application developed with Node.js and Socket.io
sourceUrl: https://github.com/sergeyyarkov/react-chat-app
demoUrl: null
articleUrl: null
---

View File

@ -1,10 +0,0 @@
---
icon: serial
title: web-serial-example
year: 2022
description: An example of using the Web Serial API to interact with the
microcontroller through a browser
sourceUrl: https://github.com/sergeyyarkov/avr128da48_web-serial-example
demoUrl: https://web-serial-example.netlify.app
articleUrl: null
---

View File

@ -1,10 +0,0 @@
---
icon: book
title: yarkov.tech
year: 2023
description: 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
demoUrl: https://yarkov.tech
articleUrl: null
---

View File

@ -1,9 +0,0 @@
---
icon: triangle_filled
title: devthread
year: 2021
description: Статический веб-сайт для публикации различных статей, разработанный
при помощи Gatsby.js и NetlifyCMS
sourceUrl: https://github.com/sergeyyarkov/devthread.ru
demoUrl: https://devthread.yarkov.tech
---

View File

@ -1,9 +0,0 @@
---
icon: round-shop-two
title: digital-store
year: 2019
description: Настраиваемый интернет-магазин с панелью управления для продажи
цифровых товаров
sourceUrl: https://github.com/sergeyyarkov/digital-store/tree/digital-store-docker
demoUrl: https://digital-store.herokuapp.com
---

View File

@ -1,8 +0,0 @@
---
icon: round-school
title: educt
year: 2022
description: Система управления обучением (LMS) для публикации ваших курсов, лекций, уроков
sourceUrl: https://github.com/sergeyyarkov/educt
demoUrl: https://educt.yarkov.tech
---

View File

@ -1,7 +0,0 @@
---
icon: chip
title: memory-led-game
year: 2022
description: Аналог популярной игры "Саймон говорит" на AVR микроконтроллере
sourceUrl: https://github.com/sergeyyarkov/attiny24a_memory-led-game
---

View File

@ -1,8 +0,0 @@
---
icon: nodejs
title: node-docker-example
year: 2022
description: Пример использования Docker для развертывания Node.js приложения
sourceUrl: https://github.com/sergeyyarkov/node-docker-example
demoUrl: https://node-docker-example.onrender.com
---

View File

@ -1,8 +0,0 @@
---
icon: chat
title: react-chat-app
year: 2021
description: Клиент-серверное веб приложение чата разработанное при помощи
Node.js и Socket.io
sourceUrl: https://github.com/sergeyyarkov/react-chat-app
---

View File

@ -1,10 +0,0 @@
---
icon: serial
title: web-serial-example
year: 2022
description: Пример использования Web Serial API для взаимодействия с
микроконтроллером через браузер.
sourceUrl: https://github.com/sergeyyarkov/avr128da48_web-serial-example
demoUrl: https://web-serial-example.netlify.app
articleUrl: https://yarkov.tech/blog/2022-11-04/ispolzovanie-webserial-api-dlya-obshcheniya-s-mikrokontrollerom/
---

View File

@ -1,9 +0,0 @@
---
icon: book
title: yarkov.tech
year: 2023
description: Мой персональный веб-сайт где находятся информация обо мне и список
различных статей и туториалов...
sourceUrl: https://github.com/sergeyyarkov/yarkov.tech
demoUrl: https://yarkov.tech
---

View File

@ -1,10 +0,0 @@
---
title: Databases
list:
- icon: postgresql
title: PostgreSQL
- icon: mongodb
title: MongoDB
- icon: redis
title: Redis
---

View File

@ -1,16 +0,0 @@
---
title: Tools
list:
- icon: nodejs
title: Node.js
- icon: docker
title: Docker
- icon: git-branch
title: Git/gitflow
- icon: sass
title: SASS
- icon: webpack
title: Webpack
- icon: vite
title: Vite.js
---

View File

@ -1,12 +0,0 @@
---
title: Programming
list:
- icon: nodejs
title: Javascript/Typescript
- icon: language-c
title: C Language
- icon: mysql
title: SQL
- icon: avr
title: AVR instruction set
---

View File

@ -1,18 +0,0 @@
---
title: Frameworks
list:
- icon: adonisjs
title: Adonis.js
- icon: alpinejs
title: Alpine.js
- icon: expressjs
title: Express.js
- icon: socket-io
title: Socket.io
- icon: astro_filled
title: Astro.js
- icon: reactjs
title: React.js
- icon: gatsbyjs
title: Gatsby.js
---

View File

@ -1,6 +0,0 @@
---
title: Languages
list:
- title: Russian (native)
- title: English (B1)
---

View File

@ -1,10 +0,0 @@
---
title: Базы данных
list:
- icon: postgresql
title: PostgreSQL
- icon: mongodb
title: MongoDB
- icon: redis
title: Redis
---

View File

@ -1,16 +0,0 @@
---
title: Инструменты
list:
- icon: nodejs
title: Node.js
- icon: docker
title: Docker
- icon: git-branch
title: Git/gitflow
- icon: sass
title: SASS
- icon: webpack
title: Webpack
- icon: vite
title: Vite.js
---

View File

@ -1,12 +0,0 @@
---
title: Программирование
list:
- icon: nodejs
title: Javascript/Typescript
- icon: language-c
title: C Language
- icon: mysql
title: SQL
- icon: avr
title: AVR instruction set
---

View File

@ -1,18 +0,0 @@
---
title: Фреймворки
list:
- icon: adonisjs
title: Adonis.js
- icon: alpinejs
title: Alpine.js
- icon: expressjs
title: Express.js
- icon: socket-io
title: Socket.io
- icon: astro_filled
title: Astro.js
- icon: reactjs
title: React.js
- icon: gatsbyjs
title: Gatsby.js
---

View File

@ -1,6 +0,0 @@
---
title: Языки
list:
- title: Русский (родной)
- title: Английский (B1)
---

View File

@ -1,3 +0,0 @@
---
title: AVR
---

View File

@ -1,3 +0,0 @@
---
title: Digital Electronics
---

View File

@ -1,3 +0,0 @@
---
title: JavaScript
---

View File

@ -1,3 +0,0 @@
---
title: Tutorial
---

View File

@ -1,3 +0,0 @@
---
title: Web Development
---

View File

@ -1,3 +0,0 @@
---
title: WebSerial API
---

3
src/env.d.ts vendored
View File

@ -1,2 +1,3 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="@astrojs/image/client" />
/// <reference types="@astrojs/client" />
/// <reference types="vite/client" />

27
src/graphql/execute.ts Normal file
View File

@ -0,0 +1,27 @@
import type { ExecutionResult } from "graphql";
import type { TypedDocumentString } from "./graphql";
import { DIRECTUS_URL_GRAPHQL, DIRECTUS_TOKEN } from "astro:env/server";
export default async function execute<TResult, TVariables>(
query: TypedDocumentString<TResult, TVariables>,
...[variables]: TVariables extends Record<string, never> ? [] : [TVariables]
) {
const response = await fetch(DIRECTUS_URL_GRAPHQL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/graphql-response+json",
Authorization: `Bearer ${DIRECTUS_TOKEN}`,
},
body: JSON.stringify({
query,
variables,
}),
});
if (!response.ok) {
throw new Error(`Network response was not ok. ${response.statusText}`);
}
return response.json() as ExecutionResult<TResult>;
}

View File

@ -0,0 +1,83 @@
/* eslint-disable */
import { ResultOf, DocumentTypeDecoration } from '@graphql-typed-document-node/core';
import { Incremental, TypedDocumentString } from './graphql';
export type FragmentType<TDocumentType extends DocumentTypeDecoration<any, any>> = TDocumentType extends DocumentTypeDecoration<
infer TType,
any
>
? [TType] extends [{ ' $fragmentName'?: infer TKey }]
? TKey extends string
? { ' $fragmentRefs'?: { [key in TKey]: TType } }
: never
: never
: never;
// return non-nullable if `fragmentType` is non-nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>>
): TType;
// return nullable if `fragmentType` is undefined
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | undefined
): TType | undefined;
// return nullable if `fragmentType` is nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | null
): TType | null;
// return nullable if `fragmentType` is nullable or undefined
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | null | undefined
): TType | null | undefined;
// return array of non-nullable if `fragmentType` is array of non-nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: Array<FragmentType<DocumentTypeDecoration<TType, any>>>
): Array<TType>;
// return array of nullable if `fragmentType` is array of nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: Array<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined
): Array<TType> | null | undefined;
// return readonly array of non-nullable if `fragmentType` is array of non-nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>>
): ReadonlyArray<TType>;
// return readonly array of nullable if `fragmentType` is array of nullable
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined
): ReadonlyArray<TType> | null | undefined;
export function useFragment<TType>(
_documentNode: DocumentTypeDecoration<TType, any>,
fragmentType: FragmentType<DocumentTypeDecoration<TType, any>> | Array<FragmentType<DocumentTypeDecoration<TType, any>>> | ReadonlyArray<FragmentType<DocumentTypeDecoration<TType, any>>> | null | undefined
): TType | Array<TType> | ReadonlyArray<TType> | null | undefined {
return fragmentType as any;
}
export function makeFragmentData<
F extends DocumentTypeDecoration<any, any>,
FT extends ResultOf<F>
>(data: FT, _fragment: F): FragmentType<F> {
return data as FragmentType<F>;
}
export function isFragmentReady<TQuery, TFrag>(
queryNode: TypedDocumentString<TQuery, any>,
fragmentNode: TypedDocumentString<TFrag, any>,
data: FragmentType<TypedDocumentString<Incremental<TFrag>, any>> | null | undefined
): data is FragmentType<typeof fragmentNode> {
const deferredFields = queryNode.__meta__?.deferredFields as Record<string, (keyof TFrag)[]>;
const fragName = fragmentNode.__meta__?.fragmentName as string | undefined;
if (!deferredFields || !fragName) return true;
const fields = deferredFields[fragName] ?? [];
return fields.length > 0 && fields.every(field => data && field in data);
}

104
src/graphql/gql.ts Normal file
View File

@ -0,0 +1,104 @@
/* eslint-disable */
import * as types from './graphql';
/**
* Map of all GraphQL operations in the project.
*
* This map has several performance disadvantages:
* 1. It is not tree-shakeable, so it will include all operations in the project.
* 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle.
* 3. It does not support dead code elimination, so it will add unused operations.
*
* Therefore it is highly recommended to use the babel or swc plugin for production.
* Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size
*/
type Documents = {
"\n\t\tquery LatestAppealByClientIpQuery($client_ip: String!, $limit: Int!) {\n\t\t\tappeal(filter: { client_ip: { _eq: $client_ip } }, sort: [\"-date_created\"], limit: $limit) {\n\t\t\t\tclient_ip\n\t\t\t\tdate_created\n\t\t\t}\n\t\t}\n\t": typeof types.LatestAppealByClientIpQueryDocument,
"\n\t\tmutation CreateAppealQuery(\n\t\t\t$name: String!\n\t\t\t$email: String!\n\t\t\t$subject: String!\n\t\t\t$message: String!\n\t\t\t$client_ip: String!\n\t\t) {\n\t\t\tcreate_appeal_item(\n\t\t\t\tdata: { name: $name, email: $email, subject: $subject, message: $message, client_ip: $client_ip }\n\t\t\t) {\n\t\t\t\tid\n\t\t\t}\n\t\t}\n\t": typeof types.CreateAppealQueryDocument,
"\n\t\tquery ArticleRSS($lang: String!) {\n\t\t\tarticle_translations(filter: { languages_code: { code: { _contains: $lang } }, status: { _eq: \"published\" } }) {\n\t\t\t\ttitle\n\t\t\t\tslug\n\t\t\t\ttags {\n\t\t\t\t\ttag_id {\n\t\t\t\t\t\ttitle\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tauthor {\n\t\t\t\t\tfirst_name\n\t\t\t\t\tlast_name\n\t\t\t\t\temail\n\t\t\t\t}\n\t\t\t\tdescription\n\t\t\t\tpub_date\n\t\t\t\tlanguages_code {\n\t\t\t\t\tcode\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t": typeof types.ArticleRssDocument,
"\n\t\tquery Article {\n\t\t\tarticle {\n\t\t\t\ttranslations(filter: { status: { _eq: \"published\" } }) {\n\t\t\t\t\tid\n\t\t\t\t\ttitle\n\t\t\t\t\ttags {\n\t\t\t\t\t\ttag_id {\n\t\t\t\t\t\t\ttitle\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tdescription\n\t\t\t\t\tslug\n\t\t\t\t\tpub_date\n\t\t\t\t\tlanguages_code {\n\t\t\t\t\t\tcode\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t": typeof types.ArticleDocument,
"\n\t\tquery RecentArticle($limit: Int!) {\n\t\t\tarticle(limit: $limit, sort: [\"-translations.pub_date\"]) {\n\t\t\t\ttranslations(filter: { status: { _eq: \"published\" } }) {\n\t\t\t\t\ttitle\n\t\t\t\t\tslug\n\t\t\t\t\tpub_date\n\t\t\t\t\tlanguages_code {\n\t\t\t\t\t\tcode\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t": typeof types.RecentArticleDocument,
"\n\t\tquery Article_translations($slug: String!) {\n\t\t\tarticle_translations(filter: { slug: { _eq: $slug } }) {\n\t\t\t\tid\n\t\t\t\ttitle\n\t\t\t\ttags {\n\t\t\t\t\ttag_id {\n\t\t\t\t\t\ttitle\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tdescription\n\t\t\t\tslug\n\t\t\t\tcontent\n\t\t\t\tauthor {\n\t\t\t\t\tfirst_name\n\t\t\t\t\tlast_name\n\t\t\t\t}\n\t\t\t\tpub_date\n\t\t\t\tviews\n\t\t\t\tlanguages_code {\n\t\t\t\t\tcode\n\t\t\t\t\tname\n\t\t\t\t}\n\t\t\t\tcover_image {\n\t\t\t\t\ttitle\n\t\t\t\t\ttype\n\t\t\t\t\tfilename_disk\n\t\t\t\t}\n\t\t\t\tarticle_id {\n\t\t\t\t\ttranslations {\n\t\t\t\t\t\tslug\n\t\t\t\t\t\tlanguages_code {\n\t\t\t\t\t\t\tcode\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t\tpub_date\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t": typeof types.Article_TranslationsDocument,
"\n\t\tmutation Update_article_translations_item($id: ID!, $views: Int!) {\n\t\t\tupdate_article_translations_item(id: $id, data: { views: $views }) {\n\t\t\t\tviews\n\t\t\t}\n\t\t}\n\t": typeof types.Update_Article_Translations_ItemDocument,
"\n\t\tquery AboutMeQuery($pageLang: String!) {\n\t\t\tglobal_translations(filter: { languages_code: { code: { _contains: $pageLang } } }) {\n\t\t\t\tabout\n\t\t\t}\n\t\t}\n\t": typeof types.AboutMeQueryDocument,
"\n\t\tquery SplashInfo($pageLang: String!) {\n\t\t\thome_page_translations(filter: { languages_code: { code: { _contains: $pageLang } } }) {\n\t\t\t\theadline\n\t\t\t\tsubheadline\n\t\t\t\tdescription\n\t\t\t}\n\t\t}\n\t": typeof types.SplashInfoDocument,
"\n\t\tquery ContactsQuery {\n\t\t\tglobal {\n\t\t\t\temail\n\t\t\t\tgithub\n\t\t\t\ttelegram\n\t\t\t}\n\t\t}\n\t": typeof types.ContactsQueryDocument,
"\n\t\tquery SiteSettingsQuery {\n\t\t\tglobal {\n\t\t\t\tym_counter\n\t\t\t\trecent_articles_limit\n\t\t\t\tgithub\n\t\t\t}\n\t\t}\n\t": typeof types.SiteSettingsQueryDocument,
"\n\t\tquery Project_translations($pageLang: String!) {\n\t\t\tproject_translations(filter: { languages_code: { code: { _contains: $pageLang } } }, sort: [\"-year\"]) {\n\t\t\t\tid\n\t\t\t\ttitle\n\t\t\t\tyear\n\t\t\t\tdescription\n\t\t\t\tsource_url\n\t\t\t\tdemo_url\n\t\t\t\tarticle_url\n\t\t\t\ticon {\n\t\t\t\t\tfilename_disk\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t": typeof types.Project_TranslationsDocument,
"\n\t\tquery Tag {\n\t\t\ttag {\n\t\t\t\tid\n\t\t\t\ttitle\n\t\t\t}\n\t\t}\n\t": typeof types.TagDocument,
};
const documents: Documents = {
"\n\t\tquery LatestAppealByClientIpQuery($client_ip: String!, $limit: Int!) {\n\t\t\tappeal(filter: { client_ip: { _eq: $client_ip } }, sort: [\"-date_created\"], limit: $limit) {\n\t\t\t\tclient_ip\n\t\t\t\tdate_created\n\t\t\t}\n\t\t}\n\t": types.LatestAppealByClientIpQueryDocument,
"\n\t\tmutation CreateAppealQuery(\n\t\t\t$name: String!\n\t\t\t$email: String!\n\t\t\t$subject: String!\n\t\t\t$message: String!\n\t\t\t$client_ip: String!\n\t\t) {\n\t\t\tcreate_appeal_item(\n\t\t\t\tdata: { name: $name, email: $email, subject: $subject, message: $message, client_ip: $client_ip }\n\t\t\t) {\n\t\t\t\tid\n\t\t\t}\n\t\t}\n\t": types.CreateAppealQueryDocument,
"\n\t\tquery ArticleRSS($lang: String!) {\n\t\t\tarticle_translations(filter: { languages_code: { code: { _contains: $lang } }, status: { _eq: \"published\" } }) {\n\t\t\t\ttitle\n\t\t\t\tslug\n\t\t\t\ttags {\n\t\t\t\t\ttag_id {\n\t\t\t\t\t\ttitle\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tauthor {\n\t\t\t\t\tfirst_name\n\t\t\t\t\tlast_name\n\t\t\t\t\temail\n\t\t\t\t}\n\t\t\t\tdescription\n\t\t\t\tpub_date\n\t\t\t\tlanguages_code {\n\t\t\t\t\tcode\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t": types.ArticleRssDocument,
"\n\t\tquery Article {\n\t\t\tarticle {\n\t\t\t\ttranslations(filter: { status: { _eq: \"published\" } }) {\n\t\t\t\t\tid\n\t\t\t\t\ttitle\n\t\t\t\t\ttags {\n\t\t\t\t\t\ttag_id {\n\t\t\t\t\t\t\ttitle\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tdescription\n\t\t\t\t\tslug\n\t\t\t\t\tpub_date\n\t\t\t\t\tlanguages_code {\n\t\t\t\t\t\tcode\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t": types.ArticleDocument,
"\n\t\tquery RecentArticle($limit: Int!) {\n\t\t\tarticle(limit: $limit, sort: [\"-translations.pub_date\"]) {\n\t\t\t\ttranslations(filter: { status: { _eq: \"published\" } }) {\n\t\t\t\t\ttitle\n\t\t\t\t\tslug\n\t\t\t\t\tpub_date\n\t\t\t\t\tlanguages_code {\n\t\t\t\t\t\tcode\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t": types.RecentArticleDocument,
"\n\t\tquery Article_translations($slug: String!) {\n\t\t\tarticle_translations(filter: { slug: { _eq: $slug } }) {\n\t\t\t\tid\n\t\t\t\ttitle\n\t\t\t\ttags {\n\t\t\t\t\ttag_id {\n\t\t\t\t\t\ttitle\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tdescription\n\t\t\t\tslug\n\t\t\t\tcontent\n\t\t\t\tauthor {\n\t\t\t\t\tfirst_name\n\t\t\t\t\tlast_name\n\t\t\t\t}\n\t\t\t\tpub_date\n\t\t\t\tviews\n\t\t\t\tlanguages_code {\n\t\t\t\t\tcode\n\t\t\t\t\tname\n\t\t\t\t}\n\t\t\t\tcover_image {\n\t\t\t\t\ttitle\n\t\t\t\t\ttype\n\t\t\t\t\tfilename_disk\n\t\t\t\t}\n\t\t\t\tarticle_id {\n\t\t\t\t\ttranslations {\n\t\t\t\t\t\tslug\n\t\t\t\t\t\tlanguages_code {\n\t\t\t\t\t\t\tcode\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t\tpub_date\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t": types.Article_TranslationsDocument,
"\n\t\tmutation Update_article_translations_item($id: ID!, $views: Int!) {\n\t\t\tupdate_article_translations_item(id: $id, data: { views: $views }) {\n\t\t\t\tviews\n\t\t\t}\n\t\t}\n\t": types.Update_Article_Translations_ItemDocument,
"\n\t\tquery AboutMeQuery($pageLang: String!) {\n\t\t\tglobal_translations(filter: { languages_code: { code: { _contains: $pageLang } } }) {\n\t\t\t\tabout\n\t\t\t}\n\t\t}\n\t": types.AboutMeQueryDocument,
"\n\t\tquery SplashInfo($pageLang: String!) {\n\t\t\thome_page_translations(filter: { languages_code: { code: { _contains: $pageLang } } }) {\n\t\t\t\theadline\n\t\t\t\tsubheadline\n\t\t\t\tdescription\n\t\t\t}\n\t\t}\n\t": types.SplashInfoDocument,
"\n\t\tquery ContactsQuery {\n\t\t\tglobal {\n\t\t\t\temail\n\t\t\t\tgithub\n\t\t\t\ttelegram\n\t\t\t}\n\t\t}\n\t": types.ContactsQueryDocument,
"\n\t\tquery SiteSettingsQuery {\n\t\t\tglobal {\n\t\t\t\tym_counter\n\t\t\t\trecent_articles_limit\n\t\t\t\tgithub\n\t\t\t}\n\t\t}\n\t": types.SiteSettingsQueryDocument,
"\n\t\tquery Project_translations($pageLang: String!) {\n\t\t\tproject_translations(filter: { languages_code: { code: { _contains: $pageLang } } }, sort: [\"-year\"]) {\n\t\t\t\tid\n\t\t\t\ttitle\n\t\t\t\tyear\n\t\t\t\tdescription\n\t\t\t\tsource_url\n\t\t\t\tdemo_url\n\t\t\t\tarticle_url\n\t\t\t\ticon {\n\t\t\t\t\tfilename_disk\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t": types.Project_TranslationsDocument,
"\n\t\tquery Tag {\n\t\t\ttag {\n\t\t\t\tid\n\t\t\t\ttitle\n\t\t\t}\n\t\t}\n\t": types.TagDocument,
};
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n\t\tquery LatestAppealByClientIpQuery($client_ip: String!, $limit: Int!) {\n\t\t\tappeal(filter: { client_ip: { _eq: $client_ip } }, sort: [\"-date_created\"], limit: $limit) {\n\t\t\t\tclient_ip\n\t\t\t\tdate_created\n\t\t\t}\n\t\t}\n\t"): typeof import('./graphql').LatestAppealByClientIpQueryDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n\t\tmutation CreateAppealQuery(\n\t\t\t$name: String!\n\t\t\t$email: String!\n\t\t\t$subject: String!\n\t\t\t$message: String!\n\t\t\t$client_ip: String!\n\t\t) {\n\t\t\tcreate_appeal_item(\n\t\t\t\tdata: { name: $name, email: $email, subject: $subject, message: $message, client_ip: $client_ip }\n\t\t\t) {\n\t\t\t\tid\n\t\t\t}\n\t\t}\n\t"): typeof import('./graphql').CreateAppealQueryDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n\t\tquery ArticleRSS($lang: String!) {\n\t\t\tarticle_translations(filter: { languages_code: { code: { _contains: $lang } }, status: { _eq: \"published\" } }) {\n\t\t\t\ttitle\n\t\t\t\tslug\n\t\t\t\ttags {\n\t\t\t\t\ttag_id {\n\t\t\t\t\t\ttitle\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tauthor {\n\t\t\t\t\tfirst_name\n\t\t\t\t\tlast_name\n\t\t\t\t\temail\n\t\t\t\t}\n\t\t\t\tdescription\n\t\t\t\tpub_date\n\t\t\t\tlanguages_code {\n\t\t\t\t\tcode\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t"): typeof import('./graphql').ArticleRssDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n\t\tquery Article {\n\t\t\tarticle {\n\t\t\t\ttranslations(filter: { status: { _eq: \"published\" } }) {\n\t\t\t\t\tid\n\t\t\t\t\ttitle\n\t\t\t\t\ttags {\n\t\t\t\t\t\ttag_id {\n\t\t\t\t\t\t\ttitle\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tdescription\n\t\t\t\t\tslug\n\t\t\t\t\tpub_date\n\t\t\t\t\tlanguages_code {\n\t\t\t\t\t\tcode\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t"): typeof import('./graphql').ArticleDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n\t\tquery RecentArticle($limit: Int!) {\n\t\t\tarticle(limit: $limit, sort: [\"-translations.pub_date\"]) {\n\t\t\t\ttranslations(filter: { status: { _eq: \"published\" } }) {\n\t\t\t\t\ttitle\n\t\t\t\t\tslug\n\t\t\t\t\tpub_date\n\t\t\t\t\tlanguages_code {\n\t\t\t\t\t\tcode\n\t\t\t\t\t\tname\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t"): typeof import('./graphql').RecentArticleDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n\t\tquery Article_translations($slug: String!) {\n\t\t\tarticle_translations(filter: { slug: { _eq: $slug } }) {\n\t\t\t\tid\n\t\t\t\ttitle\n\t\t\t\ttags {\n\t\t\t\t\ttag_id {\n\t\t\t\t\t\ttitle\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tdescription\n\t\t\t\tslug\n\t\t\t\tcontent\n\t\t\t\tauthor {\n\t\t\t\t\tfirst_name\n\t\t\t\t\tlast_name\n\t\t\t\t}\n\t\t\t\tpub_date\n\t\t\t\tviews\n\t\t\t\tlanguages_code {\n\t\t\t\t\tcode\n\t\t\t\t\tname\n\t\t\t\t}\n\t\t\t\tcover_image {\n\t\t\t\t\ttitle\n\t\t\t\t\ttype\n\t\t\t\t\tfilename_disk\n\t\t\t\t}\n\t\t\t\tarticle_id {\n\t\t\t\t\ttranslations {\n\t\t\t\t\t\tslug\n\t\t\t\t\t\tlanguages_code {\n\t\t\t\t\t\t\tcode\n\t\t\t\t\t\t\tname\n\t\t\t\t\t\t}\n\t\t\t\t\t\tpub_date\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t"): typeof import('./graphql').Article_TranslationsDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n\t\tmutation Update_article_translations_item($id: ID!, $views: Int!) {\n\t\t\tupdate_article_translations_item(id: $id, data: { views: $views }) {\n\t\t\t\tviews\n\t\t\t}\n\t\t}\n\t"): typeof import('./graphql').Update_Article_Translations_ItemDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n\t\tquery AboutMeQuery($pageLang: String!) {\n\t\t\tglobal_translations(filter: { languages_code: { code: { _contains: $pageLang } } }) {\n\t\t\t\tabout\n\t\t\t}\n\t\t}\n\t"): typeof import('./graphql').AboutMeQueryDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n\t\tquery SplashInfo($pageLang: String!) {\n\t\t\thome_page_translations(filter: { languages_code: { code: { _contains: $pageLang } } }) {\n\t\t\t\theadline\n\t\t\t\tsubheadline\n\t\t\t\tdescription\n\t\t\t}\n\t\t}\n\t"): typeof import('./graphql').SplashInfoDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n\t\tquery ContactsQuery {\n\t\t\tglobal {\n\t\t\t\temail\n\t\t\t\tgithub\n\t\t\t\ttelegram\n\t\t\t}\n\t\t}\n\t"): typeof import('./graphql').ContactsQueryDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n\t\tquery SiteSettingsQuery {\n\t\t\tglobal {\n\t\t\t\tym_counter\n\t\t\t\trecent_articles_limit\n\t\t\t\tgithub\n\t\t\t}\n\t\t}\n\t"): typeof import('./graphql').SiteSettingsQueryDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n\t\tquery Project_translations($pageLang: String!) {\n\t\t\tproject_translations(filter: { languages_code: { code: { _contains: $pageLang } } }, sort: [\"-year\"]) {\n\t\t\t\tid\n\t\t\t\ttitle\n\t\t\t\tyear\n\t\t\t\tdescription\n\t\t\t\tsource_url\n\t\t\t\tdemo_url\n\t\t\t\tarticle_url\n\t\t\t\ticon {\n\t\t\t\t\tfilename_disk\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t"): typeof import('./graphql').Project_TranslationsDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n\t\tquery Tag {\n\t\t\ttag {\n\t\t\t\tid\n\t\t\t\ttitle\n\t\t\t}\n\t\t}\n\t"): typeof import('./graphql').TagDocument;
export function graphql(source: string) {
return (documents as any)[source] ?? {};
}

Some files were not shown because too many files have changed in this diff Show More