feat(blog+categories): Wave 3-5 — blog infrastructure, categories, content, navigation

- Install @astrojs/rss and setup content collections (blog + generators)
- Create BlogLayout with BlogPosting schema, article OG tags, prev/next nav
- Create blog post pages EN/RU ([slug].astro) with dynamic routing
- Create blog index pages EN/RU with post cards and filtering
- Create RSS feeds EN/RU with auto-discovery links
- Create generator category pages EN/RU (5 categories each)
- Add category navigation pills to homepage
- Create Breadcrumbs, RelatedGenerators, RelatedPosts components
- Add breadcrumbs to GeneratorLayout and BlogLayout
- Wire RelatedGenerators into GeneratorLayout
- Wire RelatedPosts into blog post pages
- Create 8 EN + 8 RU blog post templates with placeholder content
- Add blog link to LanguageSwitcher navigation
- Add category and blog translation keys
This commit is contained in:
emil
2026-05-14 03:19:06 +03:00
parent 23cf7058f0
commit 25fd24d236
56 changed files with 2078 additions and 28 deletions
+8 -8
View File
@@ -157,7 +157,7 @@
"plan_name": "seo-blog-categories",
"status": "active",
"started_at": "2026-05-13T23:32:06.345Z",
"updated_at": "2026-05-13T23:47:20.362Z",
"updated_at": "2026-05-14T00:06:43.314Z",
"session_ids": [
"ses_1de6ede59ffexUwt5C0sddEWwR"
],
@@ -183,12 +183,12 @@
"task_key": "todo:7",
"task_label": "7",
"task_title": "Update generatorSchema.ts with category field",
"session_id": "ses_1dc44f7c9ffeRvYIFc2EOUSbNd",
"session_id": "ses_1dc34addfffegfOdNPOQJJYU66",
"agent": "Sisyphus-Junior",
"category": "quick",
"category": "visual-engineering",
"started_at": "2026-05-13T23:42:40.563Z",
"status": "running",
"updated_at": "2026-05-13T23:47:20.363Z"
"updated_at": "2026-05-14T00:06:43.315Z"
}
}
}
@@ -196,7 +196,7 @@
"active_plan": "/home/emil/Desktop/Coding/AI/Randify.pro/.sisyphus/plans/seo-blog-categories.md",
"started_at": "2026-05-13T23:32:06.345Z",
"status": "active",
"updated_at": "2026-05-13T23:47:20.362Z",
"updated_at": "2026-05-14T00:06:43.314Z",
"session_ids": [
"ses_1de6ede59ffexUwt5C0sddEWwR"
],
@@ -222,12 +222,12 @@
"task_key": "todo:7",
"task_label": "7",
"task_title": "Update generatorSchema.ts with category field",
"session_id": "ses_1dc44f7c9ffeRvYIFc2EOUSbNd",
"session_id": "ses_1dc34addfffegfOdNPOQJJYU66",
"agent": "Sisyphus-Junior",
"category": "quick",
"category": "visual-engineering",
"started_at": "2026-05-13T23:42:40.563Z",
"status": "running",
"updated_at": "2026-05-13T23:47:20.363Z"
"updated_at": "2026-05-14T00:06:43.315Z"
}
},
"agent": "atlas"
@@ -0,0 +1,10 @@
Task 11: Install @astrojs/rss and set up content collections config
- Package installed: @astrojs/rss (added 9 packages, 511 total)
- File updated: src/content/config.ts
- Imported z from astro:content
- Added blog collection with type: "content"
- Blog schema fields: title (max 120), description (max 160), pubDate, modDate (optional), draft (default false), lang (en/ru), category (tutorial/guide/news/tips), tags (default []), ogImage (optional, image()), relatedGenerators (optional), relatedPosts (optional)
- Preserved existing generators collection (type: "data", schema: generatorSchema)
- Directories created: src/content/blog/en/ and src/content/blog/ru/
- Build verification: npm run build passed with 64 pages, 0 errors
+38
View File
@@ -0,0 +1,38 @@
Task 12: BlogLayout.astro with BlogPosting JSON-LD and Article OG Meta Tags
Verification: npm run build — PASSED (64 pages built)
File: src/layouts/BlogLayout.astro
Features implemented:
1. Wraps BaseLayout, passing title, description, ogImage.
2. Injects BlogPosting JSON-LD schema via slot="head" with:
- @type: BlogPosting
- headline (from title prop)
- author: Organization { name: "Randify" }
- publisher: Organization { name: "Randify", logo: ImageObject }
- datePublished (ISO 8601)
- dateModified (ISO 8601, falls back to datePublished)
- url (canonical)
- description
- inLanguage
3. OG type set to "article" via <meta property="og:type" content="article" /> in slot="head".
4. Article-specific meta tags added:
- article:published_time
- article:modified_time
5. Styled container: max-w-2xl mx-auto, matching GeneratorLayout spacing.
6. Header with title, accent dot, publication date, and conditional "Updated" label.
7. Prev/next post navigation with bilingual labels (EN/RU), arrow icons, and focus rings.
8. Named slot "related" for RelatedPosts component.
Props accepted:
- title: string
- description: string
- pubDate: Date | string
- modDate?: Date | string
- ogImage?: string
- lang?: Lang
- prevPost?: { slug: string; title: string }
- nextPost?: { slug: string; title: string }
Build output: 64 pages, 0 errors.
+7
View File
@@ -0,0 +1,7 @@
Task 13: EN blog post page ([slug].astro)
- Build: PASS (77 pages)
- Generated: dist/blog/test-post/index.html
- Verified: post title present in HTML
- Verified: BlogPosting JSON-LD schema present
- Verified: article:published_time meta tag present
- Slug derived from post.id with en/ prefix and .md extension stripped
+16
View File
@@ -0,0 +1,16 @@
Task 14: Create RU blog post page src/pages/ru/blog/[slug].astro
Build result: PASS
Pages built: 77
RU blog post page: compiled successfully (no RU blog posts yet, so no static pages generated)
EN blog post page: /blog/test-post/index.html generated
Files created/modified:
- src/pages/ru/blog/[slug].astro (created)
- src/layouts/BlogLayout.astro (fixed locale-aware prev/next links)
Verification:
- npm run build exited with code 0
- No TypeScript or Astro errors
- RU [slug].astro correctly filters getCollection("blog") by lang === "ru"
- BlogLayout prev/next navigation updated to use locale-aware paths (/ru/blog/ for RU, /blog/ for EN)
+36
View File
@@ -0,0 +1,36 @@
Task 15: Blog Index Pages — Evidence
======================================
Build Status: PASS (76 pages, exit code 0)
Files Created:
- src/pages/blog/index.astro
- src/pages/ru/blog/index.astro
Files Modified:
- src/i18n/translations.ts (added blogDesc, readMore, noPosts keys for EN + RU)
Build Output Verification:
- dist/blog/index.html (6893 bytes)
- dist/ru/blog/index.html (7342 bytes)
EN Page Features:
- Queries getCollection("blog"), filters lang === "en" and draft !== true
- Sorts by pubDate descending
- Links to /blog/[slug]/
- Date formatting: toLocaleDateString("en-US", ...)
- JSON-LD Blog schema with blogPost array
RU Page Features:
- Queries getCollection("blog"), filters lang === "ru" and draft !== true
- Sorts by pubDate descending
- Links to /ru/blog/[slug]/
- Date formatting: toLocaleDateString("ru-RU", ...)
- JSON-LD Blog schema with blogPost array
Both pages:
- Use BaseLayout with title and description props
- Include OG/Twitter Card meta tags (inherited from BaseLayout)
- Include canonical + hreflang alternates (inherited from BaseLayout)
- Show empty state "No posts yet" / "Пока нет записей." when collection is empty
- Display category badge + tag badges per post card
+20
View File
@@ -0,0 +1,20 @@
Task 16: RSS Feeds
Files created:
- src/pages/rss.xml.ts (EN feed)
- src/pages/ru/rss.xml.ts (RU feed)
File modified:
- src/layouts/BaseLayout.astro (added <link rel="alternate" type="application/rss+xml"> for both EN and RU feeds)
Build: PASS (npm run build exited 0)
Generated feeds:
- dist/rss.xml exists (287 bytes)
- dist/ru/rss.xml exists (404 bytes)
Feed content verified:
- EN: title="Randify Blog", language="en", description present, link=https://randify.pro/
- RU: title="Блог Randify", language="ru", description present, link=https://randify.pro/
Note: Feeds are currently empty because no blog posts exist in src/content/blog/ yet. When posts are added with lang="en"/"ru" and draft !== true, they will appear automatically.
+26
View File
@@ -0,0 +1,26 @@
Task 17: Category Pages for Generators
======================================
Build Result: PASS
Pages built: 76
EN Category Pages Created:
- dist/generators/category/gaming/index.html
- dist/generators/category/security/index.html
- dist/generators/category/decision-making/index.html
- dist/generators/category/creative/index.html
- dist/generators/category/utility/index.html
RU Category Pages Created:
- dist/ru/generators/category/gaming/index.html
- dist/ru/generators/category/security/index.html
- dist/ru/generators/category/decision-making/index.html
- dist/ru/generators/category/creative/index.html
- dist/ru/generators/category/utility/index.html
Verification:
- EN gaming page contains: "Gaming generators for tabletop RPGs" and generator titles (Lottery, Rock Paper Scissors, etc.)
- RU gaming page contains: "Игровые генераторы для настольных RPG" and generator titles (Колесо фортуны, Лотерея, etc.)
- All 5 categories render correctly with getStaticPaths
- Breadcrumb JSON-LD schema included on each page
- Generators filtered dynamically by category field from src/data/generators.ts
+16
View File
@@ -0,0 +1,16 @@
Task 18: Category Navigation Pills on Homepage
Files modified:
- src/pages/index.astro
- src/pages/ru/index.astro
- src/i18n/translations.ts
Build result: PASS (85 pages, 0 errors)
Verification:
- dist/index.html contains category navigation pills
- dist/ru/index.html contains localized category navigation pills
- All 5 categories present: Gaming (6), Security (3), Decision Making (4), Creative (5), Utility (10)
- Links point to /generators/category/[category]/ (EN) and /ru/generators/category/[category]/ (RU)
- Pills styled with Tailwind: rounded-full, bg-zinc-800/60, border-zinc-700/50, hover:bg-accent/20, hover:text-accent
- Responsive flex-wrap layout
@@ -0,0 +1,28 @@
Task 19: Create Breadcrumbs.astro Component — Evidence
=====================================================
1. File Created
---------------
Path: src/components/Breadcrumbs.astro
2. Component Specification
--------------------------
- Props interface: { items: Array<{ label: string, href?: string }> }
- Renders <nav aria-label="Breadcrumb"> containing <ol>
- Each item rendered as <li> with "/" separator between items
- Last item (no href or end of array): <span aria-current="page"> with text-zinc-400
- Items with href: <a> with text-zinc-500 hover:text-accent transition-colors and focus ring
- Tailwind styling: text-sm, muted zinc colors, accent hover state
3. Build Verification
---------------------
Command: npm run build
Result: PASS (64 pages built, 0 errors)
4. Example Usage
----------------
<Breadcrumbs items={[
{ label: "Home", href: "/" },
{ label: "Gaming", href: "/generators/category/gaming/" },
{ label: "Dice Roller" }
]} />
+19
View File
@@ -0,0 +1,19 @@
Task 20: RelatedGenerators.astro
Files changed:
- src/components/RelatedGenerators.astro (created)
- src/layouts/GeneratorLayout.astro (updated — integrated RelatedGenerators)
- src/i18n/translations.ts (updated — added relatedGenerators key EN+RU)
Build result:
- npm run build: PASSED
- 76 pages built, 0 errors
Component behavior:
- Accepts props: currentSlug, category, optional lang
- Filters generators by category, excludes current slug, limits to 4
- Returns null (renders nothing) if no related generators exist
- Locale-aware titles and links (EN / RU)
- Compact card layout: icon + title in a responsive 2-column grid
- Uses Tailwind with existing design tokens (accent color, zinc palette)
- Includes focus-visible ring for accessibility
@@ -0,0 +1,24 @@
Task 21: RelatedPosts Component
================================
Files created:
- src/components/RelatedPosts.astro
Files modified:
- src/i18n/translations.ts (added relatedPosts key: en="Related posts", ru="Похожие статьи")
- src/pages/blog/[slug].astro (imported RelatedPosts, wired into "related" slot)
- src/pages/ru/blog/[slug].astro (imported RelatedPosts, wired into "related" slot)
Build result: PASS
- 83 pages built successfully
- No errors or warnings
Implementation details:
- Accepts post: CollectionEntry<"blog">
- Uses getCollection to fetch non-draft posts in same language
- Scores related posts by tag overlap count
- Shows top 3 posts with score > 0
- Hides section entirely if no related posts found
- Renders title, date, and description snippet for each related post
- Links use correct /blog/ or /ru/blog/ base path
- Styled with Tailwind matching existing dark theme (zinc palette, accent color)
+25
View File
@@ -0,0 +1,25 @@
Task 22: Create 8 English blog post templates
Date: 2026-05-14
Build result: PASS
Pages built: 85
Files created:
- src/content/blog/en/what-is-cryptographically-secure-randomness.md
- src/content/blog/en/how-to-create-strong-passwords.md
- src/content/blog/en/dice-notation-explained.md
- src/content/blog/en/psychology-of-randomness.md
- src/content/blog/en/rng-in-gaming.md
- src/content/blog/en/random-team-picker.md
- src/content/blog/en/color-theory-palettes.md
- src/content/blog/en/uuid-vs-sequential-ids.md
Build output confirms all 8 posts were generated alongside the existing test-post:
/blog/what-is-cryptographically-secure-randomness/index.html
/blog/how-to-create-strong-passwords/index.html
/blog/dice-notation-explained/index.html
/blog/psychology-of-randomness/index.html
/blog/rng-in-gaming/index.html
/blog/random-team-picker/index.html
/blog/color-theory-palettes/index.html
/blog/uuid-vs-sequential-ids/index.html
/blog/test-post/index.html
+20
View File
@@ -0,0 +1,20 @@
Build completed: 93 pages
RU blog posts generated (8):
- /ru/blog/what-is-cryptographically-secure-randomness/index.html
- /ru/blog/how-to-create-strong-passwords/index.html
- /ru/blog/dice-notation-guide/index.html
- /ru/blog/psychology-of-randomness/index.html
- /ru/blog/rng-in-gaming/index.html
- /ru/blog/random-team-selection/index.html
- /ru/blog/color-theory-guide/index.html
- /ru/blog/uuid-vs-sequential-ids/index.html
Files created:
- src/content/blog/ru/what-is-cryptographically-secure-randomness.md
- src/content/blog/ru/how-to-create-strong-passwords.md
- src/content/blog/ru/dice-notation-guide.md
- src/content/blog/ru/psychology-of-randomness.md
- src/content/blog/ru/rng-in-gaming.md
- src/content/blog/ru/random-team-selection.md
- src/content/blog/ru/color-theory-guide.md
- src/content/blog/ru/uuid-vs-sequential-ids.md
Build timestamp: 2026-05-14T03:08:00+03:00
+16
View File
@@ -0,0 +1,16 @@
Task 24 Evidence: Blog Navigation Link
=======================================
Build: PASS
- npm run build completed successfully (84 pages built)
Verification:
- EN blog link (/blog/) found in dist/index.html
- RU blog link (/ru/blog/) found in 38 RU pages including dist/ru/index.html
Changes made:
- Modified src/components/LanguageSwitcher.astro
- Added Blog / Блог link between language toggle and About link
- Link points to /blog/ on EN pages and /ru/blog/ on RU pages
- Used existing translation keys (blog: "Blog", blog: "Блог") from src/i18n/translations.ts
- Styled consistently with existing About/Privacy links
@@ -0,0 +1,17 @@
Task: Add Breadcrumbs component to generator and blog layouts
Date: 2026-05-14
1. npm run build
Result: PASS (93 pages built, exit code 0)
2. Grep dist/generators/dice/index.html for breadcrumb items:
- Found: Home, Gaming, Dice
- Command: grep -o 'Home\|Gaming\|Dice' dist/generators/dice/index.html
3. Grep dist/blog/test-post/index.html for breadcrumb items:
- Found: Home, Blog, Test Post
- Command: grep -o 'Home\|Blog\|Test Post' dist/blog/test-post/index.html
Files modified:
- src/layouts/GeneratorLayout.astro
- src/layouts/BlogLayout.astro
@@ -46,3 +46,154 @@
- Removed `.optional()` from `category` in `src/lib/generator-schema.ts` (line 24).
- Build passes with 64 pages and 0 Zod errors — schema now strictly enforces the category field.
- Order matters: all JSONs must have the field BEFORE removing `.optional()` from schema, otherwise build fails immediately.
## Task 11: Install @astrojs/rss and set up content collections config
- Installed `@astrojs/rss` package (9 packages added, 511 total).
- Updated `src/content/config.ts`:
- Added `z` import from `astro:content`.
- Defined `blog` collection with `type: "content"` and full schema: title (max 120), description (max 160), pubDate, modDate (optional), draft (default false), lang (en|ru), category (tutorial|guide|news|tips), tags (default []), ogImage (image().optional()), relatedGenerators (optional), relatedPosts (optional).
- Exported `collections = { generators, blog }` — preserved existing generators collection untouched.
- Created empty directories: `src/content/blog/en/` and `src/content/blog/ru/`.
- Build passes with 64 pages and 0 errors.
- Note: `src/data/generators.ts` remains the runtime loader for generators; the content collection config is for build-time validation only.
## Task 19: Reusable Breadcrumb Component
- Created `src/components/Breadcrumbs.astro` as a standalone, reusable component.
- Props: `items: Array<{ label: string, href?: string }>`.
- Accessibility: `<nav aria-label="Breadcrumb">` with `<ol>` list semantics; last item gets `aria-current="page"`.
- Separators are plain-text `/` wrapped in `<span aria-hidden="true">` to avoid screen-reader noise.
- Styling matches existing dark theme: `text-sm`, `text-zinc-500` for links, `text-zinc-400` for current page, `hover:text-accent` on links, plus `focus-visible:ring-accent` for keyboard navigation.
- Intentionally NOT added to `BaseLayout` — breadcrumbs are opt-in per page (e.g., category landing pages, blog posts).
- Build passes with 64 pages and 0 errors.
## Task 12: BlogLayout.astro
- Created `src/layouts/BlogLayout.astro` wrapping `BaseLayout`.
- Injected `BlogPosting` JSON-LD schema with Organization author/publisher, headline, dates, and canonical URL.
- Set OG type to `article` and added `article:published_time` / `article:modified_time` meta tags via `slot="head"`.
- Styled container follows `GeneratorLayout` pattern (`max-w-2xl mx-auto px-4 py-12 sm:py-20`).
- Prev/next navigation supports bilingual labels, arrow icons, and keyboard-focus rings.
- Exposed named slot `related` for a `RelatedPosts` component.
- Props: `title`, `description`, `pubDate`, `modDate`, `ogImage`, `lang`, `prevPost?`, `nextPost?`.
- `PostLink` interface: `{ slug: string; title: string }`.
- Build passes (64 pages, 0 errors).
## Task 16: RSS Feeds (2026-05-14)
- Astro RSS endpoint pattern: `src/pages/rss.xml.ts` exports `GET(context)` and returns `rss({...})` from `@astrojs/rss`.
- `context.site` is automatically populated from `astro.config.mjs` `site` field.
- Use `getCollection("blog")` then filter by `post.data.lang` and `post.data.draft` to keep languages separate and exclude drafts.
- Empty collections produce a valid but item-less RSS feed; build succeeds with a warning.
- RSS auto-discovery links should be `<link rel="alternate" type="application/rss+xml" title="..." href="..." />` inside `<head>`.
## Task 15: Blog Index Pages (EN + RU)
- Created `src/pages/blog/index.astro` and `src/pages/ru/blog/index.astro` using `getCollection("blog")`.
- Filter pattern: `post.data.lang === "en"/"ru" && post.data.draft !== true` — ensures no cross-language mixing and no draft posts leak.
- Sort: `b.data.pubDate.getTime() - a.data.pubDate.getTime()` for newest-first.
- Each post card displays: title, description, formatted date (locale-aware), category badge, tag badges.
- Links: `/blog/${post.slug}/` for EN, `/ru/blog/${post.slug}/` for RU.
- Added translation keys `blogDesc`, `readMore`, `noPosts` to both EN and RU objects.
- JSON-LD `Blog` schema injected via `slot="head"` with nested `BlogPosting` array for each post.
- Empty collection gracefully handled with localized "No posts yet" message.
- Build passes with 76 pages; warnings about empty blog collection are expected and harmless.
- Reused styling patterns from `src/pages/index.astro` (max-w-4xl, blur accent orb, card hover states).
## Task 17: Category Pages for Generators
- Created `src/pages/generators/category/[category].astro` and `src/pages/ru/generators/category/[category].astro`.
- Both pages use `getStaticPaths` with 5 hardcoded categories: gaming, security, decision-making, creative, utility.
- Generators are filtered dynamically via `generators.filter((g) => g.category === category)` — no hardcoded generator lists.
- Each page displays a breadcrumb (Home > Categories > [Category Name] / Главная > Категории > [Name]), category-specific title/description, and a grid of `GeneratorCard` components matching the `index.astro` layout.
- JSON-LD BreadcrumbList schema is injected via `<Fragment slot="head">` for SEO.
- Category names are formatted for display: EN uses hyphen-split capitalization ("Decision Making"), RU uses a translation map ("Принятие решений").
- Build passes with 76 pages (up from 64), confirming all 10 category variants (EN + RU × 5 categories) are statically generated.
- Verified `dist/generators/category/gaming/index.html` exists and contains correct content.
## Task 20: RelatedGenerators.astro
- Created `src/components/RelatedGenerators.astro` with props `currentSlug`, `category`, and optional `lang`.
- Filter logic: `generators.filter(g => g.category === category && g.slug !== currentSlug).slice(0, 4)`.
- Edge case handled: returns `null` (renders nothing) when no related generators exist.
- Locale-aware: titles and hrefs switch between EN (`/generators/{slug}/`) and RU (`/ru/generators/{slug}/`) based on `Astro.currentLocale`.
- Compact card design: icon (from inline `icons` map matching `GeneratorCard`) + title in a responsive 2-column grid (`grid-cols-1 sm:grid-cols-2`).
- Styling uses existing Tailwind tokens: `border-zinc-800`, `hover:border-accent`, `bg-accent/10`, `focus-visible:ring-accent`.
- Integrated into `GeneratorLayout.astro` at the bottom of the content area (after FAQ block).
- Added `relatedGenerators` translation key to both EN ("Related generators") and RU ("Похожие генераторы").
- Build passes with 76 pages and 0 errors.
## Task 13: EN Blog Post Page ([slug].astro)
- Created `src/pages/blog/[slug].astro` with `getStaticPaths` querying `getCollection("blog")`.
- Filtered posts by `lang === "en"` and excluded drafts.
- Sorted posts by `pubDate` ascending to calculate prev/next navigation chronologically.
- Derived clean slugs by stripping `en/` prefix and `.md` extension from `post.id` (Astro content collection `id` includes file extension).
- Rendered markdown via `post.render()` -> `<Content />`.
- Passed prev/next data to `BlogLayout` using `prevPost` / `nextPost` props (matching existing BlogLayout interface from Task 12).
- Build passes with 77 pages; verified output at `dist/blog/test-post/index.html`.
## Task 14: RU Blog Post Page ([slug].astro)
- Created `src/pages/ru/blog/[slug].astro` as an exact structural copy of the EN file, with two language-specific changes:
1. Filter: `post.data.lang === "ru"` (instead of `"en"`).
2. Slug normalization: stripped `ru/` prefix from `post.id` (instead of `en/`).
- Prev/next navigation uses `BlogLayout` props (`prevPost`, `nextPost`) just like EN.
- Rendered markdown via `post.render()` -> `<Content />` inside `<article>`.
- Passed `lang="ru"` prop to `BlogLayout` for explicit locale context.
- **Bug fix in `BlogLayout.astro`:** prev/next links were hardcoded to `/blog/${slug}/`, breaking RU navigation. Added `const blogBase = lang === "ru" ? "/ru/blog/" : "/blog/";` and updated both prev/next `href` attributes to use `${blogBase}${slug}/`. This makes BlogLayout fully locale-aware without duplicating layout logic.
- Build passes with 77 pages; RU `[slug].astro` compiles correctly. No RU blog posts exist yet, so no static RU post pages are generated (expected — will populate in Tasks 22-23).
- Evidence saved to `.sisyphus/evidence/task-14-ru-post.txt`.
## Task 21: RelatedPosts Component
- Created `src/components/RelatedPosts.astro` accepting `post: CollectionEntry<"blog">`.
- Tag overlap scoring: `p.data.tags.filter(t => post.data.tags.includes(t)).length`.
- Filters out current post, requires `score > 0`, sorts descending, slices top 3.
- Edge case: returns `null` (renders nothing) when no related posts — section is completely hidden.
- Language isolation: `getCollection("blog", ({ data }) => !data.draft && data.lang === post.data.lang)` ensures EN and RU posts never mix.
- Slug derivation: `post.id.replace(/^(en|ru)\//, "").replace(/\.md$/, "")` — Astro content collection `id` includes subdirectory prefix and file extension.
- Blog base path computed from `post.data.lang`: `/blog/` for EN, `/ru/blog/` for RU.
- Styled with Tailwind matching existing dark theme: `border-zinc-800`, `hover:border-accent`, `line-clamp-2` for description snippets.
- Wired into both `src/pages/blog/[slug].astro` and `src/pages/ru/blog/[slug].astro` via `<RelatedPosts post={post} slot="related" />`.
- Added `relatedPosts` translation key: EN "Related posts", RU "Похожие статьи".
- Build passes with 83 pages and 0 errors.
## Task 23: Russian Blog Post Templates (8 posts)
- Created 8 Russian blog post templates in `src/content/blog/ru/`.
- All posts include valid frontmatter with `lang: ru`, matching the EN schema.
- Frontmatter fields: title, description, pubDate, modDate, draft: false, lang: ru, category: guide, tags, relatedGenerators.
- `relatedGenerators` uses English slugs (same convention as EN posts).
- Staggered dates used: 2025-01-16 through 2025-01-23.
- Body content: 3 paragraphs of Russian placeholder text per post.
- Build passes with 93 pages (up from 77), confirming all 8 RU blog posts are statically generated.
- RU blog index (`/ru/blog/index.html`) and individual post pages (`/ru/blog/<slug>/index.html`) render correctly.
- Note: First build attempt hit a transient Astro module resolution bug on `src/pages/ru/generators/list.astro`; retry succeeded without changes.
## Task 26: Add Breadcrumbs to GeneratorLayout and BlogLayout
- Imported `Breadcrumbs` into `src/layouts/GeneratorLayout.astro` and `src/layouts/BlogLayout.astro`.
- **GeneratorLayout:** Added `<Breadcrumbs items={...} />` between the existing back-link nav and the `<header>`.
- Items: Home → Category → Generator Title.
- Category label is locale-aware: maps `generator.category` to translation keys (`catGaming`, `catSecurity`, etc.) for both EN and RU.
- Category href is prefixed with `/ru` when `isRu` is true.
- **BlogLayout:** Added `<Breadcrumbs items={...} />` inside `<article>`, before the `<header>`.
- Items: Home → Blog → Post Title.
- Labels and hrefs switch based on `lang === "ru"`.
- `npm run build` passes with 93 pages.
- Verified breadcrumb text presence in built HTML:
- `dist/generators/dice/index.html` contains "Home", "Gaming", "Dice".
- `dist/blog/test-post/index.html` contains "Home", "Blog", "Test Post".
- Evidence saved to `.sisyphus/evidence/task-26-breadcrumbs.txt`.
## Task 22: Create 8 English Blog Post Templates
- Created 8 markdown files in `src/content/blog/en/` with valid frontmatter matching the blog schema.
- All posts use `category: guide`, `draft: false`, `lang: en`, and include `relatedGenerators`.
- Dates are staggered from 2025-01-15 (newest) down to 2025-01-08 (oldest) for correct newest-first sorting.
- Body content is 3 paragraphs of placeholder text ("This article will explore... Stay tuned for the full guide.") per instructions.
- Build passes with 85 pages (up from 77), confirming 8 new static blog post pages were generated.
- Existing `test-post.md` remains untouched; total EN blog collection now has 9 posts.
+16 -16
View File
@@ -638,7 +638,7 @@ Max Concurrent: 6 (Wave 1), 4 (Wave 2), 6 (Wave 3), 8 (Wave 4), 2 (Wave 5)
**Commit**: YES (Wave 2)
- [ ] 11. Install @astrojs/rss, set up content collections config
- [x] 11. Install @astrojs/rss, set up content collections config
**What to do**:
- Run `npm install @astrojs/rss`
@@ -705,7 +705,7 @@ Max Concurrent: 6 (Wave 1), 4 (Wave 2), 6 (Wave 3), 8 (Wave 4), 2 (Wave 5)
**Commit**: YES (Wave 3)
- [ ] 12. Create BlogLayout.astro with BlogPosting schema
- [x] 12. Create BlogLayout.astro with BlogPosting schema
**What to do**:
- Create `src/layouts/BlogLayout.astro`
@@ -752,7 +752,7 @@ Max Concurrent: 6 (Wave 1), 4 (Wave 2), 6 (Wave 3), 8 (Wave 4), 2 (Wave 5)
**Commit**: YES (Wave 3)
- [ ] 13. Create blog post page EN ([slug].astro)
- [x] 13. Create blog post page EN ([slug].astro)
**What to do**:
- Create `src/pages/blog/[slug].astro`
@@ -799,7 +799,7 @@ Max Concurrent: 6 (Wave 1), 4 (Wave 2), 6 (Wave 3), 8 (Wave 4), 2 (Wave 5)
**Commit**: YES (Wave 3)
- [ ] 14. Create blog post page RU ([slug].astro)
- [x] 14. Create blog post page RU ([slug].astro)
**What to do**:
- Create `src/pages/ru/blog/[slug].astro`
@@ -842,7 +842,7 @@ Max Concurrent: 6 (Wave 1), 4 (Wave 2), 6 (Wave 3), 8 (Wave 4), 2 (Wave 5)
**Commit**: YES (Wave 3)
- [ ] 15. Create blog index pages (EN + RU)
- [x] 15. Create blog index pages (EN + RU)
**What to do**:
- Create `src/pages/blog/index.astro` — list all EN blog posts sorted by date (newest first)
@@ -889,7 +889,7 @@ Max Concurrent: 6 (Wave 1), 4 (Wave 2), 6 (Wave 3), 8 (Wave 4), 2 (Wave 5)
**Commit**: YES (Wave 3)
- [ ] 16. Create RSS feeds (EN + RU)
- [x] 16. Create RSS feeds (EN + RU)
**What to do**:
- Create `src/pages/rss.xml.ts` — English RSS feed
@@ -941,7 +941,7 @@ Max Concurrent: 6 (Wave 1), 4 (Wave 2), 6 (Wave 3), 8 (Wave 4), 2 (Wave 5)
**Commit**: YES (Wave 3)
- [ ] 17. Create category pages for generators (EN + RU)
- [x] 17. Create category pages for generators (EN + RU)
**What to do**:
- Create `src/pages/generators/category/[category].astro`
@@ -991,7 +991,7 @@ Max Concurrent: 6 (Wave 1), 4 (Wave 2), 6 (Wave 3), 8 (Wave 4), 2 (Wave 5)
**Commit**: YES (Wave 4)
- [ ] 18. Add category navigation to homepage
- [x] 18. Add category navigation to homepage
**What to do**:
- Update `src/pages/index.astro` to show category pills/links above or below the generator grid
@@ -1037,7 +1037,7 @@ Max Concurrent: 6 (Wave 1), 4 (Wave 2), 6 (Wave 3), 8 (Wave 4), 2 (Wave 5)
**Commit**: YES (Wave 4)
- [ ] 19. Create Breadcrumbs component
- [x] 19. Create Breadcrumbs component
**What to do**:
- Create `src/components/Breadcrumbs.astro`
@@ -1084,7 +1084,7 @@ Max Concurrent: 6 (Wave 1), 4 (Wave 2), 6 (Wave 3), 8 (Wave 4), 2 (Wave 5)
**Commit**: YES (Wave 4)
- [ ] 20. Create RelatedGenerators component
- [x] 20. Create RelatedGenerators component
**What to do**:
- Create `src/components/RelatedGenerators.astro`
@@ -1133,7 +1133,7 @@ Max Concurrent: 6 (Wave 1), 4 (Wave 2), 6 (Wave 3), 8 (Wave 4), 2 (Wave 5)
**Commit**: YES (Wave 4)
- [ ] 21. Create RelatedPosts component
- [x] 21. Create RelatedPosts component
**What to do**:
- Create `src/components/RelatedPosts.astro`
@@ -1190,7 +1190,7 @@ Max Concurrent: 6 (Wave 1), 4 (Wave 2), 6 (Wave 3), 8 (Wave 4), 2 (Wave 5)
**Commit**: YES (Wave 4)
- [ ] 22. Create 8 blog post templates EN
- [x] 22. Create 8 blog post templates EN
**What to do**:
- Create 8 markdown files in `src/content/blog/en/` with correct frontmatter:
@@ -1255,7 +1255,7 @@ Max Concurrent: 6 (Wave 1), 4 (Wave 2), 6 (Wave 3), 8 (Wave 4), 2 (Wave 5)
**Commit**: YES (Wave 5)
- [ ] 23. Create 8 blog post templates RU
- [x] 23. Create 8 blog post templates RU
**What to do**:
- Create 8 markdown files in `src/content/blog/ru/` with Russian titles/descriptions
@@ -1307,7 +1307,7 @@ Max Concurrent: 6 (Wave 1), 4 (Wave 2), 6 (Wave 3), 8 (Wave 4), 2 (Wave 5)
**Commit**: YES (Wave 5)
- [ ] 24. Add blog link to navigation
- [x] 24. Add blog link to navigation
**What to do**:
- Update `src/components/LanguageSwitcher.astro` or add a new nav component
@@ -1355,7 +1355,7 @@ Max Concurrent: 6 (Wave 1), 4 (Wave 2), 6 (Wave 3), 8 (Wave 4), 2 (Wave 5)
**Commit**: YES (Wave 5)
- [ ] 25. Add related generators to generator pages
- [x] 25. Add related generators to generator pages
**What to do**:
- Update `src/layouts/GeneratorLayout.astro` to import and render `<RelatedGenerators />`
@@ -1401,7 +1401,7 @@ Max Concurrent: 6 (Wave 1), 4 (Wave 2), 6 (Wave 3), 8 (Wave 4), 2 (Wave 5)
**Commit**: YES (Wave 5)
- [ ] 26. Add breadcrumbs to generator and blog layouts
- [x] 26. Add breadcrumbs to generator and blog layouts
**What to do**:
- Update `src/layouts/GeneratorLayout.astro` to include `<Breadcrumbs />`
+119
View File
@@ -8,6 +8,7 @@
"name": "randify",
"version": "0.0.1",
"dependencies": {
"@astrojs/rss": "^4.0.18",
"@astrojs/sitemap": "^3.2.1",
"@tailwindcss/vite": "^4.0.0",
"astro": "^4.16.0",
@@ -75,6 +76,26 @@
"node": "^18.17.1 || ^20.3.0 || >=21.0.0"
}
},
"node_modules/@astrojs/rss": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@astrojs/rss/-/rss-4.0.18.tgz",
"integrity": "sha512-wc5DwKlbTEdgVAWnHy8krFTeQ42t1v/DJqeq5HtulYK3FYHE4krtRGjoyhS3eXXgfdV6Raoz2RU3wrMTFAitRg==",
"license": "MIT",
"dependencies": {
"fast-xml-parser": "^5.5.7",
"piccolore": "^0.1.3",
"zod": "^4.3.6"
}
},
"node_modules/@astrojs/rss/node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/@astrojs/sitemap": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.2.1.tgz",
@@ -996,6 +1017,18 @@
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@nodable/entities": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
"integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -4209,6 +4242,44 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-xml-builder": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz",
"integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.5.0",
"xml-naming": "^0.1.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.8.0",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz",
"integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"@nodable/entities": "^2.1.0",
"fast-xml-builder": "^1.2.0",
"path-expression-matcher": "^1.5.0",
"strnum": "^2.3.0",
"xml-naming": "^0.1.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -6349,6 +6420,21 @@
"node": ">=8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -6366,6 +6452,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/piccolore": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
"integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==",
"license": "ISC"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -7195,6 +7287,18 @@
"node": ">=0.10.0"
}
},
"node_modules/strnum": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz",
"integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/suf-log": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/suf-log/-/suf-log-2.5.3.tgz",
@@ -7935,6 +8039,21 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/xml-naming": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz",
"integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/xxhash-wasm": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz",
+1
View File
@@ -15,6 +15,7 @@
"format:check": "prettier --check ."
},
"dependencies": {
"@astrojs/rss": "^4.0.18",
"@astrojs/sitemap": "^3.2.1",
"@tailwindcss/vite": "^4.0.0",
"astro": "^4.16.0",
+44
View File
@@ -0,0 +1,44 @@
---
interface BreadcrumbItem {
label: string;
href?: string;
}
interface Props {
items: BreadcrumbItem[];
}
const { items } = Astro.props;
---
<nav aria-label="Breadcrumb">
<ol class="flex flex-wrap items-center gap-1.5 text-sm">
{
items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<li class="flex items-center gap-1.5">
{index > 0 && (
<span class="text-zinc-600" aria-hidden="true">/</span>
)}
{isLast || !item.href ? (
<span
class="text-zinc-400"
aria-current={isLast ? "page" : undefined}
>
{item.label}
</span>
) : (
<a
href={item.href}
class="text-zinc-500 hover:text-accent transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded"
>
{item.label}
</a>
)}
</li>
);
})
}
</ol>
</nav>
+7
View File
@@ -26,6 +26,13 @@ const alternatePath = isRu
data-alternate={isRu ? "" : alternatePath}>RU</a
>
<span class="w-px h-3 bg-zinc-700 mx-0.5" aria-hidden="true"></span>
<a
href={isRu ? "/ru/blog/" : "/blog/"}
class="px-2 py-1 rounded-md transition-colors text-zinc-400 hover:text-zinc-200"
>
{isRu ? "Блог" : "Blog"}
</a>
<span class="w-px h-3 bg-zinc-700 mx-0.5" aria-hidden="true"></span>
<a
href={isRu ? "/ru/about/" : "/about/"}
class="px-2 py-1 rounded-md transition-colors text-zinc-400 hover:text-zinc-200"
+77
View File
@@ -0,0 +1,77 @@
---
import { generators } from "@/data/generators";
import { useT } from "@/i18n/translations";
import type { Lang } from "@/i18n/translations";
interface Props {
currentSlug: string;
category: string;
lang?: Lang;
}
const { currentSlug, category, lang: langProp } = Astro.props;
const lang = langProp || (Astro.currentLocale as Lang) || "en";
const T = useT(lang);
const isRu = lang === "ru";
const related = generators
.filter((g) => g.category === category && g.slug !== currentSlug)
.slice(0, 4);
if (related.length === 0) return null;
const icons: Record<string, string> = {
hash: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" x2="20" y1="9" y2="9"/><line x1="4" x2="20" y1="15" y2="15"/><line x1="10" x2="8" y1="3" y2="21"/><line x1="16" x2="14" y1="3" y2="21"/></svg>`,
palette: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/></svg>`,
lock: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`,
ticket: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z"/><path d="M13 5v2"/><path d="M13 17v2"/><path d="M13 11v2"/></svg>`,
"dice-6": `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="M16 8h.01"/><path d="M16 12h.01"/><path d="M16 16h.01"/><path d="M8 8h.01"/><path d="M8 12h.01"/><path d="M8 16h.01"/></svg>`,
"square-stack": `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 10c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h4c1.1 0 2 .9 2 2"/><path d="M10 16c-1.1 0-2-.9-2-2v-4c0-1.1.9-2 2-2h4c1.1 0 2 .9 2 2"/><rect width="8" height="8" x="14" y="14" rx="2"/></svg>`,
"circle-dollar-sign": `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/></svg>`,
list: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg>`,
fingerprint: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 10a2 2 0 0 0-2 2c0 1.02-.1 2.51-.26 4"/><path d="M14 13.12c0 2.38 0 6.38-1 8.88"/><path d="M17.29 21.02c.12-.6.43-2.3.5-3.02"/><path d="M2 12a10 10 0 0 1 18-6"/><path d="M2 17c1 .5 2.25 1 4 1 1.5 0 3-.5 4-1"/><path d="M20 12c0 2-.5 4.5-2 6"/><path d="M7 13.02c0 2.38 0 5.5 1 7.48"/><path d="M8.5 8.5A5 5 0 0 1 17 12"/></svg>`,
"pie-chart": `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></svg>`,
"help-circle": `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>`,
user: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`,
users: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
type: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" x2="15" y1="20" y2="20"/><line x1="12" x2="12" y1="4" y2="20"/></svg>`,
calendar: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="4" rx="2" ry="2"/><line x1="16" x2="16" y1="2" y2="6"/><line x1="8" x2="8" y1="2" y2="6"/><line x1="3" x2="21" y1="10" y2="10"/></svg>`,
hand: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v0"/><path d="M14 10V4a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v8"/><path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/></svg>`,
smile: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/></svg>`,
paintbrush: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m14.622 17.897-10.68-2.913"/><path d="M18.376 2.622a1 1 0 1 1 3.002 3.002L17.36 9.643a.5.5 0 0 0 0 .707l.944.944a2.41 2.41 0 0 1 0 3.408l-.944.944a.5.5 0 0 1-.707 0L8.354 7.348a.5.5 0 0 1 0-.707l.944-.944a2.41 2.41 0 0 1 3.408 0l.944.944a.5.5 0 0 0 .707 0z"/><path d="M9 8c-1.804 2.71-3.97 3.46-6.583 3.948a.507.507 0 0 0-.302.819l7.32 8.883a1 1 0 0 0 1.185.204C12.735 20.405 16 16.792 16 15"/></svg>`,
shuffle: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 18h1.4c1.3 0 2.5-.6 3.3-1.7l14.4-14.4"/><path d="M16 2h6v6"/><path d="M22 18l-5.8-5.8"/><path d="M22 18h-1.4c-1.3 0-2.5.6-3.3 1.7l-4 5"/><path d="M8 22h-6v-6"/><path d="m2 6 5.8 5.8"/></svg>`,
sparkles: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="M5 3v4"/><path d="M9 5H5"/><path d="M19 10v4"/><path d="M19 17h4"/><path d="M15 19h4"/></svg>`,
font: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>`,
clock: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
utensils: `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2"/><path d="M7 2v20"/><path d="M21 15V2v0a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7"/></svg>`,
};
---
<section class="mt-12">
<h2 class="text-lg font-semibold text-zinc-100 mb-4">{T.relatedGenerators}</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{
related.map((g) => {
const href = isRu
? `/ru/generators/${g.slug}/`
: `/generators/${g.slug}/`;
const title = isRu ? g.ruTitle : g.title;
return (
<a
href={href}
class="group flex items-center gap-3 p-3 border border-zinc-800 rounded-lg hover:border-accent hover:bg-zinc-900/50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
>
<div
class="inline-flex w-8 h-8 rounded-lg bg-accent/10 items-center justify-center text-accent shrink-0"
aria-hidden="true"
set:html={icons[g.icon] ?? icons["hash"]}
/>
<span class="text-sm font-medium text-zinc-300 group-hover:text-accent transition-colors">
{title}
</span>
</a>
);
})
}
</div>
</section>
+69
View File
@@ -0,0 +1,69 @@
---
import { getCollection } from "astro:content";
import type { CollectionEntry } from "astro:content";
import { useT } from "@/i18n/translations";
import type { Lang } from "@/i18n/translations";
interface Props {
post: CollectionEntry<"blog">;
}
const { post } = Astro.props;
const lang = post.data.lang as Lang;
const T = useT(lang);
const isRu = lang === "ru";
const allPosts = await getCollection(
"blog",
({ data }) => !data.draft && data.lang === lang,
);
const related = allPosts
.filter((p) => p.id !== post.id)
.map((p) => ({
post: p,
score: p.data.tags.filter((t) => post.data.tags.includes(t)).length,
}))
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 3)
.map(({ post }) => post);
if (related.length === 0) return null;
function getSlug(p: CollectionEntry<"blog">) {
return p.id.replace(/^(en|ru)\//, "").replace(/\.md$/, "");
}
const blogBase = isRu ? "/ru/blog/" : "/blog/";
---
<section class="mt-12 pt-10 border-t border-zinc-800/60">
<h2 class="text-lg font-semibold text-zinc-100 mb-4">{T.relatedPosts}</h2>
<div class="flex flex-col gap-4">
{
related.map((p) => {
const slug = getSlug(p);
const href = `${blogBase}${slug}/`;
const dateStr = new Date(p.data.pubDate).toLocaleDateString(
isRu ? "ru-RU" : "en-US",
{ year: "numeric", month: "long", day: "numeric" },
);
return (
<a
href={href}
class="group block p-4 border border-zinc-800 rounded-lg hover:border-accent hover:bg-zinc-900/50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
>
<span class="block text-sm font-medium text-zinc-300 group-hover:text-accent transition-colors">
{p.data.title}
</span>
<span class="block text-xs text-zinc-500 mt-1">{dateStr}</span>
<span class="block text-sm text-zinc-400 mt-2 leading-relaxed line-clamp-2">
{p.data.description}
</span>
</a>
);
})
}
</div>
</section>
@@ -0,0 +1,17 @@
---
title: "Color Theory: How to Generate Harmonious Palettes"
description: "Learn the basics of color theory and how to create beautiful, harmonious color palettes for your projects."
pubDate: 2025-01-09
modDate: 2025-01-09
draft: false
lang: en
category: guide
tags: ["color", "design", "palette"]
relatedGenerators: ["colors", "gradient", "palette"]
---
This article will explore the principles of color theory that help designers and developers build visually cohesive palettes. From complementary contrasts to analogous harmony, understanding these relationships is the foundation of good design. Stay tuned for the full guide.
We will break down concepts like hue, saturation, and brightness, and show you how tools like the color wheel translate theory into practical decisions. You will also learn about accessibility considerations, including contrast ratios and color blindness.
Finally, this guide will walk you through using palette generators to experiment quickly and export ready-to-use color codes. Great color choices are not just about taste, they are about structure, context, and user experience.
@@ -0,0 +1,17 @@
---
title: "Dice Notation Explained: From d4 to Exploding Dice"
description: "Everything you need to know about dice notation for D&D, Pathfinder, and tabletop RPGs."
pubDate: 2025-01-13
modDate: 2025-01-13
draft: false
lang: en
category: guide
tags: ["dice", "gaming", "rpg"]
relatedGenerators: ["dice"]
---
This article will explore the standard dice notation used in tabletop role-playing games and explain what terms like 2d6, d20, and exploding dice really mean. Whether you are new to RPGs or a seasoned dungeon master, a solid grasp of notation makes gameplay smoother. Stay tuned for the full guide.
We will break down how to read roll expressions, interpret modifiers, and understand advanced mechanics such as advantage, disadvantage, and critical thresholds. Knowing the math behind the rolls can also help game masters design fairer encounters.
Finally, this guide will show you how to use digital dice rollers when physical dice are not available, and how random number generators can replicate complex roll formulas with perfect accuracy.
@@ -0,0 +1,17 @@
---
title: "How to Create Strong Passwords: A Complete Guide"
description: "Master the art of creating unbreakable passwords with our comprehensive guide to password security."
pubDate: 2025-01-14
modDate: 2025-01-14
draft: false
lang: en
category: guide
tags: ["password", "security", "guide"]
relatedGenerators: ["password", "hash"]
---
This article will explore proven strategies for creating strong, memorable passwords that keep your accounts safe from attackers. We will break down common mistakes and show you how length, complexity, and uniqueness work together. Stay tuned for the full guide.
You will learn why passphrases can be both easier to remember and harder to crack than traditional passwords. We will also cover the role of password managers and two-factor authentication in building a layered defense.
Finally, this guide includes a practical checklist you can follow every time you create a new account or update an existing password. Small changes in habit can lead to a dramatic improvement in your overall security posture.
@@ -0,0 +1,17 @@
---
title: "The Psychology of Randomness: Why Humans Are Bad at Being Random"
description: "Discover the fascinating psychology behind why humans struggle to generate truly random choices and numbers."
pubDate: 2025-01-12
modDate: 2025-01-12
draft: false
lang: en
category: guide
tags: ["randomness", "psychology", "science"]
relatedGenerators: ["coin", "yesno", "number"]
---
This article will explore the cognitive biases that make humans surprisingly poor at producing random sequences, choices, and numbers. Our brains are wired to find patterns, which is the exact opposite of what true randomness requires. Stay tuned for the full guide.
We will look at famous experiments that reveal how people tend to alternate more than they should, avoid repetition, and unconsciously follow hidden rules when asked to act randomly. These findings have real implications for everything from jury selection to lottery number picking.
Finally, this guide will explain why relying on tools like coin flips, random number generators, and shufflers leads to fairer outcomes than trusting your intuition. When impartiality matters, letting an algorithm decide is often the smartest move.
+17
View File
@@ -0,0 +1,17 @@
---
title: "How to Use a Random Team Picker for Sports and Events"
description: "Make team selection fair and fun with random team pickers for sports, workshops, and group activities."
pubDate: 2025-01-10
modDate: 2025-01-10
draft: false
lang: en
category: guide
tags: ["teams", "sports", "events"]
relatedGenerators: ["teams", "list-shuffler"]
---
This article will explore how random team pickers can eliminate bias, speed up organization, and add a layer of excitement to any group activity. Whether you are coaching a youth league or running a corporate workshop, fair teams lead to better engagement. Stay tuned for the full guide.
We will discuss different methods for dividing groups, from simple random assignment to balanced algorithms that account for skill levels. You will learn when pure randomness is appropriate and when a little structure improves the experience.
Finally, this guide includes tips for communicating the process to participants so everyone buys into the fairness of the outcome. Transparency turns a potentially tense moment into a fun and trusted ritual.
+17
View File
@@ -0,0 +1,17 @@
---
title: "Random Number Generators in Gaming: Fairness and Algorithms"
description: "How RNG works in video games and casinos, and why fairness matters for player trust."
pubDate: 2025-01-11
modDate: 2025-01-11
draft: false
lang: en
category: guide
tags: ["gaming", "rng", "fairness"]
relatedGenerators: ["dice", "lottery", "wheel"]
---
This article will explore how random number generators power loot drops, card shuffles, and dice rolls in both video games and casino environments. Understanding the algorithms behind RNG helps players recognize fair systems and spot questionable ones. Stay tuned for the full guide.
We will compare pseudo-random number generators with hardware-based true randomness, and discuss why most games use the former for performance reasons. You will also learn about seed values and how they can make RNG deterministic for replay verification.
Finally, this guide covers regulatory standards for casino RNGs and what independent auditing looks like in practice. Fairness is not just a buzzword, it is a technical requirement backed by math and oversight.
+12
View File
@@ -0,0 +1,12 @@
---
title: "Test Post"
description: "A test blog post for verification."
pubDate: 2025-01-15
modDate: 2025-01-15
draft: false
lang: en
category: guide
tags: ["test"]
---
This is a test blog post.
@@ -0,0 +1,17 @@
---
title: "UUID vs Sequential IDs: When to Use What"
description: "Compare UUIDs and sequential IDs to choose the right identifier strategy for your application."
pubDate: 2025-01-08
modDate: 2025-01-08
draft: false
lang: en
category: guide
tags: ["uuid", "database", "programming"]
relatedGenerators: ["uuid", "hash"]
---
This article will explore the trade-offs between universally unique identifiers and simple sequential IDs in software architecture. Choosing the right identifier affects performance, security, and scalability in ways that are not always obvious at first glance. Stay tuned for the full guide.
We will compare storage size, indexing behavior, and collision probability across different ID strategies. You will learn why UUIDs excel in distributed systems while sequential IDs remain attractive for single-database applications.
Finally, this guide will provide decision criteria and practical recommendations based on your specific use case. Whether you are building a small internal tool or a high-scale microservices platform, the right ID strategy saves headaches down the road.
@@ -0,0 +1,17 @@
---
title: "What is Cryptographically Secure Randomness and Why It Matters"
description: "Learn why cryptographically secure random number generators are essential for passwords, gaming, and security."
pubDate: 2025-01-15
modDate: 2025-01-15
draft: false
lang: en
category: guide
tags: ["randomness", "security", "crypto"]
relatedGenerators: ["password", "hash", "uuid"]
---
This article will explore the fundamentals of cryptographically secure randomness and explain why it plays a critical role in modern digital security. From generating passwords to securing communications, understanding the difference between pseudo-random and truly secure randomness is essential. Stay tuned for the full guide.
We will look at how operating systems gather entropy from hardware events and why developers should never rely on simple Math.random() for anything security-related. The consequences of weak randomness can be severe, ranging from predictable passwords to compromised encryption keys.
Finally, this guide will walk you through practical examples and tools you can use to ensure your applications are using the strongest sources of randomness available. Whether you are building a web app or managing server infrastructure, these principles apply across the board.
+17
View File
@@ -0,0 +1,17 @@
---
title: "Теория цвета: как создавать гармоничные палитры"
description: "Изучите основы теории цвета и научитесь создавать красивые, гармоничные цветовые палитры для ваших проектов."
pubDate: 2025-01-22
modDate: 2025-01-22
draft: false
lang: ru
category: guide
tags: ["цвет", "дизайн", "палитра"]
relatedGenerators: ["colors", "gradient", "palette"]
---
В этой статье мы рассмотрим основы теории цвета, которая лежит в основе каждого великолепного дизайна. От цветового круга Иттена до современных цифровых палитр — понимание цвета открывает безграничные возможности для творчества. Следите за обновлениями.
Мы изучим различные схемы гармонии цветов: комплементарные, аналоговые, триадические и сплит-комплементарные. Каждая схема создает уникальное настроение и подходит для разных типов проектов.
Наконец, вы узнаете, как применять эти принципы на практике при создании веб-сайтов, брендинга и иллюстраций. Случайная генерация палитр может стать отличной отправной точкой для экспериментов и поиска неожиданных сочетаний.
@@ -0,0 +1,17 @@
---
title: "Нотация кубиков: от d4 до взрывающихся кубиков"
description: "Всё, что нужно знать о нотации кубиков для D&D, Pathfinder и настольных RPG."
pubDate: 2025-01-18
modDate: 2025-01-18
draft: false
lang: ru
category: guide
tags: ["кубики", "игры", "рпг"]
relatedGenerators: ["dice"]
---
В этой статье мы рассмотрим мир нотации кубиков, от базовых обозначений d4, d6 и d20 до продвинутых механик вроде взрывающихся кубиков и кубиков с преимуществом. Понимание этой системы открывает дверь к более глубокому погружению в настольные ролевые игры. Следите за обновлениями.
Мы разберем, как читать запись вида 3d6+2, что означают модификаторы и почему разные игры предпочитают разные типы кубиков. От D&D до Pathfinder и indie-RPG — нотация остается универсальным языком геймеров.
Наконец, вы узнаете о домашних правилах и вариациях, которые делают броски более интересными. Критические успехи, провалы и специальные механики — всё это становится понятнее, когда вы владеете языком кубиков.
@@ -0,0 +1,17 @@
---
title: "Как создавать надёжные пароли: полное руководство"
description: "Освойте искусство создания невзламываемых паролей с нашим подробным руководством по безопасности."
pubDate: 2025-01-17
modDate: 2025-01-17
draft: false
lang: ru
category: guide
tags: ["пароль", "безопасность", "гайд"]
relatedGenerators: ["password", "hash"]
---
В этой статье мы рассмотрим проверенные стратегии создания сильных, запоминающихся паролей, которые защитят ваши аккаунты от злоумышленников. Мы разберем распространенные ошибки и покажем, как длина, сложность и уникальность работают вместе. Следите за обновлениями.
Вы узнаете, почему парольные фразы могут быть одновременно легче запомнить и сложнее взломать, чем традиционные пароли. Мы также рассмотрим роль менеджеров паролей и двухфакторной аутентификации в построении многоуровневой защиты.
Наконец, это руководство включает практический чек-лист, которому вы можете следовать каждый раз при создании нового аккаунта или обновлении существующего пароля. Небольшие изменения в привычках могут привести к значительному улучшению вашей общей безопасности.
@@ -0,0 +1,17 @@
---
title: "Психология случайности: почему люди плохо справляются со случайностью"
description: "Откройте для себя увлекательную психологию того, почему люди не могут генерировать по-настоящему случайные выборы."
pubDate: 2025-01-19
modDate: 2025-01-19
draft: false
lang: ru
category: guide
tags: ["случайность", "психология", "наука"]
relatedGenerators: ["coin", "yesno", "number"]
---
В этой статье мы рассмотрим увлекательную психологию человеческого восприятия случайности. Несмотря на наши убеждения, человеческий мозг плохо приспособлен для генерации или распознавания истинно случайных последовательностей. Следите за обновлениями.
Мы изучим когнитивные искажения, которые заставляют нас видеть закономерности там, где их нет, и избегать повторений там, где они вполне естественны. Эти механизмы объясняют, почему люди при броске монетки чаще выбирают орел после серии решек.
Наконец, мы рассмотрим практические применения этих знаний: от улучшения методов шифрования до создания более справедливых игровых механик. Понимание наших ограничений помогает нам строить лучшие инструменты.
@@ -0,0 +1,17 @@
---
title: "Как использовать случайный выбор команд для спорта и мероприятий"
description: "Сделайте выбор команд честным и весёлым с помощью случайных распределений для спорта и групповых активностей."
pubDate: 2025-01-21
modDate: 2025-01-21
draft: false
lang: ru
category: guide
tags: ["команды", "спорт", "мероприятия"]
relatedGenerators: ["teams", "list-shuffler"]
---
В этой статье мы рассмотрим, как случайный выбор команд может сделать спортивные соревнования и групповые мероприятия более честными и увлекательными. Забудьте о спорах и субъективных решениях — случайность решает всё. Следите за обновлениями.
Мы покажем различные методы распределения участников по командам: от простого случайного разбиения до балансировки по навыкам. Каждый подход имеет свои преимущества в зависимости от типа активности.
Наконец, вы узнаете, как интегрировать инструменты случайного выбора в организацию школьных турниров, корпоративных тимбилдингов и дружеских встреч. Честное распределение повышает вовлеченность и уменьшает конфликты.
+17
View File
@@ -0,0 +1,17 @@
---
title: "Генераторы случайных чисел в играх: честность и алгоритмы"
description: "Как работает RNG в видеоиграх и казино, и почему честность важна для доверия игроков."
pubDate: 2025-01-20
modDate: 2025-01-20
draft: false
lang: ru
category: guide
tags: ["игры", "rng", "честность"]
relatedGenerators: ["dice", "lottery", "wheel"]
---
В этой статье мы рассмотрим, как генераторы случайных чисел (RNG) формируют современный игровой опыт. От выпадения редких предметов до раздачи карт в онлайн-казино — алгоритмы случайности находятся в центре внимания. Следите за обновлениями.
Мы разберем разницу между истинно случайными и псевдослучайными генераторами, и почему игровые компании тратят миллионы на сертификацию честности своих алгоритмов. Игроки заслуживают уверенности, что результат не подделан.
Наконец, мы обсудим прозрачность и регулирование в игровой индустрии. Независимые аудиты и открытые алгоритмы становятся стандартом, который защищает интересы как разработчиков, так и игроков.
@@ -0,0 +1,17 @@
---
title: "UUID против последовательных ID: когда что использовать"
description: "Сравните UUID и последовательные идентификаторы, чтобы выбрать правильную стратегию для вашего приложения."
pubDate: 2025-01-23
modDate: 2025-01-23
draft: false
lang: ru
category: guide
tags: ["uuid", "база-данных", "программирование"]
relatedGenerators: ["uuid", "hash"]
---
В этой статье мы сравним два популярных подхода к идентификации записей в базах данных: UUID и последовательные ID. Каждый метод имеет свои сильные стороны и компромиссы, которые влияют на производительность и безопасность. Следите за обновлениями.
Мы рассмотрим, когда UUID предпочтительнее: при распределенных системах, публичных API и необходимости скрыть объем данных. Последовательные ID, с другой стороны, проще, компактнее и эффективнее для индексации.
Наконец, мы обсудим гибридные подходы и лучшие практики выбора стратегии идентификации для разных типов приложений. Правильный выбор на раннем этапе проекта экономит время и ресурсы в будущем.
@@ -0,0 +1,17 @@
---
title: "Что такое криптографически стойкая случайность и почему это важно"
description: "Узнайте, почему криптографически стойкие генераторы случайных чисел необходимы для паролей, игр и безопасности."
pubDate: 2025-01-16
modDate: 2025-01-16
draft: false
lang: ru
category: guide
tags: ["случайность", "безопасность", "крипто"]
relatedGenerators: ["password", "hash", "uuid"]
---
В этой статье мы рассмотрим основы криптографически стойкой случайности и объясним, почему она играет решающую роль в современной цифровой безопасности. От генерации паролей до защиты коммуникаций, понимание разницы между псевдослучайной и по-настоящему безопасной случайностью имеет первостепенное значение. Следите за обновлениями.
Мы рассмотрим, как операционные системы собирают энтропию из аппаратных событий и почему разработчики никогда не должны полагаться на простой Math.random() для всего, что связано с безопасностью. Последствия слабой случайности могут быть серьезными: от предсказуемых паролей до скомпрометированных ключей шифрования.
Наконец, это руководство проведет вас через практические примеры и инструменты, которые вы можете использовать, чтобы убедиться, что ваши приложения используют самые надежные источники случайности. Независимо от того, создаете ли вы веб-приложение или управляете серверной инфраструктурой, эти принципы применимы повсеместно.
+20 -1
View File
@@ -1,4 +1,4 @@
import { defineCollection } from "astro:content";
import { defineCollection, z } from "astro:content";
import { generatorSchema } from "@/lib/generator-schema";
const generators = defineCollection({
@@ -6,6 +6,25 @@ const generators = defineCollection({
schema: generatorSchema,
});
const blog = defineCollection({
type: "content",
schema: ({ image }) =>
z.object({
title: z.string().max(120),
description: z.string().max(160),
pubDate: z.date(),
modDate: z.date().optional(),
draft: z.boolean().default(false),
lang: z.enum(["en", "ru"]),
category: z.enum(["tutorial", "guide", "news", "tips"]),
tags: z.array(z.string()).default([]),
ogImage: image().optional(),
relatedGenerators: z.array(z.string()).optional(),
relatedPosts: z.array(z.string()).optional(),
}),
});
export const collections = {
generators,
blog,
};
+20
View File
@@ -70,6 +70,16 @@ export const translations = {
backToAll: "All generators",
backToHome: "Back to home",
blog: "Blog",
blogDesc: "Tips, guides, and news about randomness and our generators.",
readMore: "Read more",
noPosts: "No posts yet.",
relatedGenerators: "Related generators",
relatedPosts: "Related posts",
catGaming: "Gaming",
catSecurity: "Security",
catDecisionMaking: "Decision Making",
catCreative: "Creative",
catUtility: "Utility",
notFoundTitle: "Page Not Found",
notFoundMessage:
@@ -170,6 +180,16 @@ export const translations = {
backToAll: "Все генераторы",
backToHome: "Вернуться на главную",
blog: "Блог",
blogDesc: "Советы, руководства и новости о случайности и наших генераторах.",
readMore: "Читать далее",
noPosts: "Пока нет записей.",
relatedGenerators: "Похожие генераторы",
relatedPosts: "Похожие статьи",
catGaming: "Игры",
catSecurity: "Безопасность",
catDecisionMaking: "Принятие решений",
catCreative: "Творчество",
catUtility: "Утилиты",
notFoundTitle: "Страница не найдена",
notFoundMessage:
+9 -2
View File
@@ -10,12 +10,15 @@ interface Props {
title?: string;
description?: string;
ogImage?: string;
ogType?: string;
articlePubDate?: Date;
articleModDate?: Date;
}
const lang = (Astro.currentLocale as Lang) || "en";
const T = useT(lang);
const { title = T.defaultTitle, description = T.defaultDesc, ogImage = "/og-default.jpg" } = Astro.props;
const { title = T.defaultTitle, description = T.defaultDesc, ogImage = "/og-default.jpg", ogType = "website", articlePubDate, articleModDate } = Astro.props;
const ogImageUrl = ogImage.startsWith("http") ? ogImage : `https://randify.pro${ogImage}`;
@@ -61,6 +64,8 @@ const orgSchema = {
<link rel="alternate" hreflang="en" href={enUrl} />
<link rel="alternate" hreflang="ru" href={ruUrl} />
<link rel="alternate" hreflang="x-default" href={enUrl} />
<link rel="alternate" type="application/rss+xml" title="Randify Blog (EN)" href="/rss.xml" />
<link rel="alternate" type="application/rss+xml" title="Randify Blog (RU)" href="/ru/rss.xml" />
<meta name="verification" content="er9ndnv9ih7agmh8" />
<script
type="application/ld+json"
@@ -80,7 +85,9 @@ const orgSchema = {
<title>{title}</title>
<meta property="og:site_name" content="Randify" />
<meta property="og:locale" content={lang === "ru" ? "ru_RU" : "en_US"} />
<meta property="og:type" content="website" />
<meta property="og:type" content={ogType} />
{articlePubDate && <meta property="article:published_time" content={articlePubDate.toISOString()} />}
{articleModDate && <meta property="article:modified_time" content={articleModDate.toISOString()} />}
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonicalUrl} />
+210
View File
@@ -0,0 +1,210 @@
---
import BaseLayout from "./BaseLayout.astro";
import Breadcrumbs from "../components/Breadcrumbs.astro";
import type { Lang } from "../i18n/translations";
interface PostLink {
slug: string;
title: string;
}
interface Props {
title: string;
description: string;
pubDate: Date | string;
modDate?: Date | string;
ogImage?: string;
lang?: Lang;
prevPost?: PostLink;
nextPost?: PostLink;
}
const {
title,
description,
pubDate,
modDate,
ogImage,
lang: langProp,
prevPost,
nextPost,
} = Astro.props;
const lang = langProp || (Astro.currentLocale as Lang) || "en";
const canonicalUrl = `https://randify.pro${Astro.url.pathname}`;
const blogBase = lang === "ru" ? "/ru/blog/" : "/blog/";
const pubDateIso = new Date(pubDate).toISOString();
const modDateIso = modDate ? new Date(modDate).toISOString() : pubDateIso;
const blogSchema = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: title,
description: description,
url: canonicalUrl,
inLanguage: lang,
author: {
"@type": "Organization",
name: "Randify",
},
publisher: {
"@type": "Organization",
name: "Randify",
logo: {
"@type": "ImageObject",
url: "https://randify.pro/favicon.png",
},
},
datePublished: pubDateIso,
dateModified: modDateIso,
};
---
<BaseLayout title={title} description={description} ogImage={ogImage}>
<Fragment slot="head">
<meta property="og:type" content="article" />
<meta property="article:published_time" content={pubDateIso} />
<meta property="article:modified_time" content={modDateIso} />
<script type="application/ld+json" set:html={JSON.stringify(blogSchema)} />
</Fragment>
<article class="max-w-2xl mx-auto px-4 py-12 sm:py-20">
<Breadcrumbs
items={[
{ label: lang === "ru" ? "Главная" : "Home", href: lang === "ru" ? "/ru/" : "/" },
{ label: lang === "ru" ? "Блог" : "Blog", href: blogBase },
{ label: title },
]}
/>
<header class="mb-10">
<div class="flex items-center gap-2 mb-4">
<span
class="inline-block w-2.5 h-2.5 rounded-full bg-accent"
aria-hidden="true"></span>
<span
class="text-sm font-medium text-zinc-400 uppercase tracking-widest"
>randify</span
>
</div>
<h1 class="text-3xl sm:text-4xl font-bold text-zinc-100">{title}</h1>
<div class="w-16 h-0.5 bg-accent/60 rounded-full mt-4" aria-hidden="true">
</div>
<div class="mt-4 flex items-center gap-3 text-sm text-zinc-500">
<time datetime={pubDateIso}>
{
new Date(pubDate).toLocaleDateString(lang === "ru" ? "ru-RU" : "en-US", {
year: "numeric",
month: "long",
day: "numeric",
})
}
</time>
{
modDate && modDate !== pubDate && (
<>
<span aria-hidden="true">·</span>
<span>
{lang === "ru" ? "Обновлено" : "Updated"}
<time datetime={modDateIso} class="ml-1">
{new Date(modDate).toLocaleDateString(
lang === "ru" ? "ru-RU" : "en-US",
{
year: "numeric",
month: "long",
day: "numeric",
}
)}
</time>
</span>
</>
)
}
</div>
</header>
<div class="prose prose-invert prose-zinc max-w-none">
<slot />
</div>
<slot name="related" />
{
(prevPost || nextPost) && (
<nav
aria-label={lang === "ru" ? "Навигация по статьям" : "Post navigation"}
class="mt-16 pt-10 border-t border-zinc-800/60"
>
<div class="flex flex-col sm:flex-row gap-6 justify-between">
{prevPost ? (
<a
href={`${blogBase}${prevPost.slug}/`}
class="group flex items-start gap-3 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded-lg p-2 -ml-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mt-0.5 text-zinc-500 group-hover:text-zinc-300 transition-colors shrink-0"
aria-hidden="true"
>
<path d="m15 18-6-6 6-6" />
</svg>
<div>
<span class="block text-xs text-zinc-500 uppercase tracking-wider mb-1">
{lang === "ru" ? "Предыдущая" : "Previous"}
</span>
<span class="block text-sm font-medium text-zinc-300 group-hover:text-zinc-100 transition-colors">
{prevPost.title}
</span>
</div>
</a>
) : (
<div />
)}
{nextPost ? (
<a
href={`${blogBase}${nextPost.slug}/`}
class="group flex items-start gap-3 text-right sm:flex-row-reverse focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded-lg p-2 -mr-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="mt-0.5 text-zinc-500 group-hover:text-zinc-300 transition-colors shrink-0"
aria-hidden="true"
>
<path d="m9 18 6-6-6-6" />
</svg>
<div>
<span class="block text-xs text-zinc-500 uppercase tracking-wider mb-1">
{lang === "ru" ? "Следующая" : "Next"}
</span>
<span class="block text-sm font-medium text-zinc-300 group-hover:text-zinc-100 transition-colors">
{nextPost.title}
</span>
</div>
</a>
) : (
<div />
)}
</div>
</nav>
)
}
</article>
</BaseLayout>
+29 -1
View File
@@ -3,9 +3,11 @@ import BaseLayout from "./BaseLayout.astro";
import YandexRTB from "../components/YandexRTB.astro";
import SeoBlock from "../components/SeoBlock.astro";
import FaqBlock from "../components/FaqBlock.astro";
import RelatedGenerators from "../components/RelatedGenerators.astro";
import { useT } from "../i18n/translations";
import type { Lang } from "../i18n/translations";
import type { Lang, T as TType } from "../i18n/translations";
import type { Generator } from "../data/generators";
import Breadcrumbs from "../components/Breadcrumbs.astro";
interface Props {
generator: Generator;
@@ -16,6 +18,15 @@ const lang = (Astro.currentLocale as Lang) || "en";
const T = useT(lang);
const isRu = lang === "ru";
const categoryKeyMap: Record<Generator["category"], keyof TType> = {
gaming: "catGaming",
security: "catSecurity",
"decision-making": "catDecisionMaking",
creative: "catCreative",
utility: "catUtility",
};
const categoryLabel = T[categoryKeyMap[generator.category]];
const canonicalUrl = `https://randify.pro${Astro.url.pathname}`;
const appSchema = {
@@ -90,6 +101,17 @@ const breadcrumbSchema = {
</a>
</nav>
<Breadcrumbs
items={[
{ label: isRu ? "Главная" : "Home", href: isRu ? "/ru/" : "/" },
{
label: categoryLabel,
href: `${isRu ? "/ru" : ""}/generators/category/${generator.category}/`,
},
{ label: isRu ? generator.ruTitle : generator.title },
]}
/>
<header class="mb-2">
<div class="flex items-center gap-2 mb-4">
<span
@@ -133,5 +155,11 @@ const breadcrumbSchema = {
/>
)
}
<RelatedGenerators
currentSlug={generator.slug}
category={generator.category}
lang={lang}
/>
</div>
</BaseLayout>
+49
View File
@@ -0,0 +1,49 @@
---
import { getCollection } from "astro:content";
import BlogLayout from "@/layouts/BlogLayout.astro";
import RelatedPosts from "@/components/RelatedPosts.astro";
export async function getStaticPaths() {
const posts = await getCollection("blog");
const enPosts = posts.filter((p) => p.data.lang === "en" && !p.data.draft);
const sorted = enPosts.sort(
(a, b) => a.data.pubDate.valueOf() - b.data.pubDate.valueOf(),
);
return sorted.map((post, index) => {
const prev = sorted[index - 1] ?? null;
const next = sorted[index + 1] ?? null;
return {
params: { slug: post.id.replace(/^en\//, "").replace(/\.md$/, "") },
props: {
post,
prev: prev
? { slug: prev.id.replace(/^en\//, "").replace(/\.md$/, ""), title: prev.data.title }
: null,
next: next
? { slug: next.id.replace(/^en\//, "").replace(/\.md$/, ""), title: next.data.title }
: null,
},
};
});
}
const { post, prev, next } = Astro.props;
const { Content } = await post.render();
---
<BlogLayout
title={post.data.title}
description={post.data.description}
pubDate={post.data.pubDate}
modDate={post.data.modDate}
ogImage={post.data.ogImage?.src}
lang="en"
prevPost={prev}
nextPost={next}
>
<article>
<Content />
</article>
<RelatedPosts post={post} slot="related" />
</BlogLayout>
+109
View File
@@ -0,0 +1,109 @@
---
import { getCollection } from "astro:content";
import BaseLayout from "@/layouts/BaseLayout.astro";
import { useT } from "@/i18n/translations";
import type { Lang } from "@/i18n/translations";
const lang = (Astro.currentLocale as Lang) || "en";
const T = useT(lang);
const allPosts = await getCollection("blog");
const posts = allPosts
.filter((post) => post.data.lang === "en" && post.data.draft !== true)
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
const pageTitle = `${T.blog} | Randify`;
const pageDesc = T.blogDesc;
const blogSchema = {
"@context": "https://schema.org",
"@type": "Blog",
name: pageTitle,
description: pageDesc,
url: `https://randify.pro${Astro.url.pathname}`,
inLanguage: lang,
blogPost: posts.map((post) => ({
"@type": "BlogPosting",
headline: post.data.title,
description: post.data.description,
datePublished: post.data.pubDate.toISOString(),
url: `https://randify.pro/blog/${post.slug}/`,
})),
};
---
<BaseLayout title={pageTitle} description={pageDesc}>
<script
type="application/ld+json"
set:html={JSON.stringify(blogSchema)}
slot="head"
/>
<div class="max-w-4xl mx-auto px-4 py-12 sm:py-20">
<header class="mb-12">
<div class="relative flex items-center gap-2 mb-6">
<div
class="absolute -left-16 -top-12 w-64 h-64 rounded-full bg-accent/20 blur-3xl pointer-events-none"
aria-hidden="true"
></div>
<span
class="inline-block w-3 h-3 rounded-full bg-accent"
aria-hidden="true"></span>
<span class="text-xl font-bold tracking-tight text-zinc-100"
>{T.brandLabel}</span
>
</div>
<h1 class="text-3xl sm:text-4xl font-bold text-zinc-100 leading-tight">
{T.blog}
</h1>
<p class="mt-3 text-base text-zinc-300 max-w-md">
{T.blogDesc}
</p>
</header>
<main class="mt-8">
{
posts.length === 0 ? (
<p class="text-zinc-400">{T.noPosts}</p>
) : (
<ul class="space-y-6" role="list" aria-label="Blog posts">
{posts.map((post) => (
<li>
<a
href={`/blog/${post.slug}/`}
class="group block rounded-xl border border-zinc-800/60 bg-zinc-900/40 p-6 hover:border-accent/40 hover:bg-zinc-900/60 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
>
<div class="flex flex-wrap items-center gap-2 mb-3">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent border border-accent/20">
{post.data.category}
</span>
{post.data.tags.map((tag) => (
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-zinc-800 text-zinc-400 border border-zinc-700/60">
{tag}
</span>
))}
</div>
<h2 class="text-xl font-semibold text-zinc-100 group-hover:text-accent transition-colors mb-2">
{post.data.title}
</h2>
<p class="text-zinc-400 text-sm leading-relaxed mb-3">
{post.data.description}
</p>
<time
class="text-xs text-zinc-500"
datetime={post.data.pubDate.toISOString()}
>
{post.data.pubDate.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
</a>
</li>
))}
</ul>
)
}
</main>
</div>
</BaseLayout>
@@ -0,0 +1,141 @@
---
import BaseLayout from "@/layouts/BaseLayout.astro";
import GeneratorCard from "@/components/GeneratorCard.astro";
import { generators } from "@/data/generators";
import { useT } from "@/i18n/translations";
import type { Lang } from "@/i18n/translations";
export function getStaticPaths() {
const categories = [
"gaming",
"security",
"decision-making",
"creative",
"utility",
] as const;
return categories.map((category) => ({
params: { category },
}));
}
const { category } = Astro.params;
const lang: Lang = "en";
const T = useT(lang);
const categoryDescriptions: Record<string, string> = {
gaming: "Gaming generators for tabletop RPGs, card games, and lotteries",
security: "Security tools for passwords, hashes, and unique identifiers",
"decision-making": "Decision-making helpers for coins, teams, and weighted choices",
creative: "Creative generators for colors, gradients, palettes, and typography",
utility: "Utility generators for dates, names, lists, and everyday randomness",
};
const categoryGenerators = generators.filter((g) => g.category === category);
function formatCategoryName(cat: string): string {
return cat
.split("-")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
}
const categoryName = formatCategoryName(category);
const description = categoryDescriptions[category] || "";
const title = `${categoryName} — Randify`;
const canonicalUrl = `https://randify.pro${Astro.url.pathname}`;
const breadcrumbSchema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Randify.pro",
item: "https://randify.pro/",
},
{
"@type": "ListItem",
position: 2,
name: "Categories",
item: "https://randify.pro/generators/category/",
},
{
"@type": "ListItem",
position: 3,
name: categoryName,
item: canonicalUrl,
},
],
};
---
<BaseLayout title={title} description={description}>
<Fragment slot="head">
<script
type="application/ld+json"
set:html={JSON.stringify(breadcrumbSchema)}
/>
</Fragment>
<div class="max-w-4xl mx-auto px-4 py-12 sm:py-20">
<nav aria-label="Breadcrumb" class="mb-10">
<ol class="flex items-center gap-2 text-sm text-zinc-400">
<li>
<a
href="/"
class="hover:text-zinc-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded"
>
Home
</a>
</li>
<li aria-hidden="true" class="text-zinc-600">/</li>
<li>
<span class="text-zinc-500">Categories</span>
</li>
<li aria-hidden="true" class="text-zinc-600">/</li>
<li>
<span class="text-zinc-200 font-medium">{categoryName}</span>
</li>
</ol>
</nav>
<header class="mb-12">
<div class="relative flex items-center gap-2 mb-6">
<div
class="absolute -left-16 -top-12 w-64 h-64 rounded-full bg-accent/20 blur-3xl pointer-events-none"
aria-hidden="true"
></div>
<span
class="inline-block w-3 h-3 rounded-full bg-accent"
aria-hidden="true"></span>
<span class="text-xl font-bold tracking-tight text-zinc-100"
>{T.brandLabel}</span
>
</div>
<h1 class="text-3xl sm:text-4xl font-bold text-zinc-100 leading-tight">
{categoryName}
</h1>
<p class="mt-3 text-base text-zinc-300 max-w-md">
{description}
</p>
</header>
<main class="mt-8">
<div
class="grid gap-3"
style="grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); grid-auto-rows: 1fr;"
role="list"
aria-label={`${categoryName} generators`}
>
{
categoryGenerators.map((generator) => (
<div role="listitem">
<GeneratorCard generator={generator} />
</div>
))
}
</div>
</main>
</div>
</BaseLayout>
+23
View File
@@ -47,6 +47,29 @@ const webSiteSchema = {
</p>
</header>
<nav aria-label="Categories" class="mb-8">
<ul class="flex flex-wrap gap-2">
{
[
{ slug: "gaming", label: T.catGaming },
{ slug: "security", label: T.catSecurity },
{ slug: "decision-making", label: T.catDecisionMaking },
{ slug: "creative", label: T.catCreative },
{ slug: "utility", label: T.catUtility },
].map((cat) => (
<li>
<a
href={`/generators/category/${cat.slug}/`}
class="inline-flex items-center px-4 py-2 rounded-full bg-zinc-800/60 border border-zinc-700/50 text-zinc-300 text-sm font-medium hover:bg-accent/20 hover:text-accent hover:border-accent/30 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
>
{cat.label} ({generators.filter((g) => g.category === cat.slug).length})
</a>
</li>
))
}
</ul>
</nav>
<main class="mt-8">
<div
class="grid gap-3"
+24
View File
@@ -0,0 +1,24 @@
import rss from "@astrojs/rss";
import { getCollection } from "astro:content";
import type { APIContext } from "astro";
export async function GET(context: APIContext) {
const posts = await getCollection("blog");
const enPosts = posts.filter(
(post) => post.data.lang === "en" && post.data.draft !== true
);
return rss({
title: "Randify Blog",
description:
"Latest updates, tutorials, and tips from Randify — tools that help you make random decisions.",
site: context.site!,
items: enPosts.map((post) => ({
title: post.data.title,
description: post.data.description,
pubDate: post.data.pubDate,
link: `/blog/${post.slug}/`,
})),
customData: "<language>en</language>",
});
}
+49
View File
@@ -0,0 +1,49 @@
---
import { getCollection } from "astro:content";
import BlogLayout from "@/layouts/BlogLayout.astro";
import RelatedPosts from "@/components/RelatedPosts.astro";
export async function getStaticPaths() {
const posts = await getCollection("blog");
const ruPosts = posts.filter((p) => p.data.lang === "ru" && !p.data.draft);
const sorted = ruPosts.sort(
(a, b) => a.data.pubDate.valueOf() - b.data.pubDate.valueOf(),
);
return sorted.map((post, index) => {
const prev = sorted[index - 1] ?? null;
const next = sorted[index + 1] ?? null;
return {
params: { slug: post.id.replace(/^ru\//, "").replace(/\.md$/, "") },
props: {
post,
prev: prev
? { slug: prev.id.replace(/^ru\//, "").replace(/\.md$/, ""), title: prev.data.title }
: null,
next: next
? { slug: next.id.replace(/^ru\//, "").replace(/\.md$/, ""), title: next.data.title }
: null,
},
};
});
}
const { post, prev, next } = Astro.props;
const { Content } = await post.render();
---
<BlogLayout
title={post.data.title}
description={post.data.description}
pubDate={post.data.pubDate}
modDate={post.data.modDate}
ogImage={post.data.ogImage?.src}
lang="ru"
prevPost={prev}
nextPost={next}
>
<article>
<Content />
</article>
<RelatedPosts post={post} slot="related" />
</BlogLayout>
+109
View File
@@ -0,0 +1,109 @@
---
import { getCollection } from "astro:content";
import BaseLayout from "@/layouts/BaseLayout.astro";
import { useT } from "@/i18n/translations";
import type { Lang } from "@/i18n/translations";
const lang = (Astro.currentLocale as Lang) || "en";
const T = useT(lang);
const allPosts = await getCollection("blog");
const posts = allPosts
.filter((post) => post.data.lang === "ru" && post.data.draft !== true)
.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());
const pageTitle = `${T.blog} | Randify`;
const pageDesc = T.blogDesc;
const blogSchema = {
"@context": "https://schema.org",
"@type": "Blog",
name: pageTitle,
description: pageDesc,
url: `https://randify.pro${Astro.url.pathname}`,
inLanguage: lang,
blogPost: posts.map((post) => ({
"@type": "BlogPosting",
headline: post.data.title,
description: post.data.description,
datePublished: post.data.pubDate.toISOString(),
url: `https://randify.pro/ru/blog/${post.slug}/`,
})),
};
---
<BaseLayout title={pageTitle} description={pageDesc}>
<script
type="application/ld+json"
set:html={JSON.stringify(blogSchema)}
slot="head"
/>
<div class="max-w-4xl mx-auto px-4 py-12 sm:py-20">
<header class="mb-12">
<div class="relative flex items-center gap-2 mb-6">
<div
class="absolute -left-16 -top-12 w-64 h-64 rounded-full bg-accent/20 blur-3xl pointer-events-none"
aria-hidden="true"
></div>
<span
class="inline-block w-3 h-3 rounded-full bg-accent"
aria-hidden="true"></span>
<span class="text-xl font-bold tracking-tight text-zinc-100"
>{T.brandLabel}</span
>
</div>
<h1 class="text-3xl sm:text-4xl font-bold text-zinc-100 leading-tight">
{T.blog}
</h1>
<p class="mt-3 text-base text-zinc-300 max-w-md">
{T.blogDesc}
</p>
</header>
<main class="mt-8">
{
posts.length === 0 ? (
<p class="text-zinc-400">{T.noPosts}</p>
) : (
<ul class="space-y-6" role="list" aria-label="Blog posts">
{posts.map((post) => (
<li>
<a
href={`/ru/blog/${post.slug}/`}
class="group block rounded-xl border border-zinc-800/60 bg-zinc-900/40 p-6 hover:border-accent/40 hover:bg-zinc-900/60 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
>
<div class="flex flex-wrap items-center gap-2 mb-3">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent border border-accent/20">
{post.data.category}
</span>
{post.data.tags.map((tag) => (
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-zinc-800 text-zinc-400 border border-zinc-700/60">
{tag}
</span>
))}
</div>
<h2 class="text-xl font-semibold text-zinc-100 group-hover:text-accent transition-colors mb-2">
{post.data.title}
</h2>
<p class="text-zinc-400 text-sm leading-relaxed mb-3">
{post.data.description}
</p>
<time
class="text-xs text-zinc-500"
datetime={post.data.pubDate.toISOString()}
>
{post.data.pubDate.toLocaleDateString("ru-RU", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
</a>
</li>
))}
</ul>
)
}
</main>
</div>
</BaseLayout>
@@ -0,0 +1,145 @@
---
import BaseLayout from "@/layouts/BaseLayout.astro";
import GeneratorCard from "@/components/GeneratorCard.astro";
import { generators } from "@/data/generators";
import { useT } from "@/i18n/translations";
import type { Lang } from "@/i18n/translations";
export function getStaticPaths() {
const categories = [
"gaming",
"security",
"decision-making",
"creative",
"utility",
] as const;
return categories.map((category) => ({
params: { category },
}));
}
const { category } = Astro.params;
const lang: Lang = "ru";
const T = useT(lang);
const categoryDescriptions: Record<string, string> = {
gaming: "Игровые генераторы для настольных RPG, карточных игр и лотерей",
security: "Инструменты безопасности для паролей, хешей и уникальных идентификаторов",
"decision-making": "Помощники в принятии решений: монетки, команды, взвешенный выбор",
creative: "Творческие генераторы для цветов, градиентов, палитр и типографики",
utility: "Утилитарные генераторы для дат, имён, списков и повседневной случайности",
};
const categoryGenerators = generators.filter((g) => g.category === category);
function formatCategoryName(cat: string): string {
const names: Record<string, string> = {
gaming: "Игры",
security: "Безопасность",
"decision-making": "Принятие решений",
creative: "Творчество",
utility: "Утилиты",
};
return names[cat] || cat;
}
const categoryName = formatCategoryName(category);
const description = categoryDescriptions[category] || "";
const title = `${categoryName} — Randify`;
const canonicalUrl = `https://randify.pro${Astro.url.pathname}`;
const breadcrumbSchema = {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Randify.pro",
item: "https://randify.pro/",
},
{
"@type": "ListItem",
position: 2,
name: "Категории",
item: "https://randify.pro/ru/generators/category/",
},
{
"@type": "ListItem",
position: 3,
name: categoryName,
item: canonicalUrl,
},
],
};
---
<BaseLayout title={title} description={description}>
<Fragment slot="head">
<script
type="application/ld+json"
set:html={JSON.stringify(breadcrumbSchema)}
/>
</Fragment>
<div class="max-w-4xl mx-auto px-4 py-12 sm:py-20">
<nav aria-label="Breadcrumb" class="mb-10">
<ol class="flex items-center gap-2 text-sm text-zinc-400">
<li>
<a
href="/ru/"
class="hover:text-zinc-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950 rounded"
>
Главная
</a>
</li>
<li aria-hidden="true" class="text-zinc-600">/</li>
<li>
<span class="text-zinc-500">Категории</span>
</li>
<li aria-hidden="true" class="text-zinc-600">/</li>
<li>
<span class="text-zinc-200 font-medium">{categoryName}</span>
</li>
</ol>
</nav>
<header class="mb-12">
<div class="relative flex items-center gap-2 mb-6">
<div
class="absolute -left-16 -top-12 w-64 h-64 rounded-full bg-accent/20 blur-3xl pointer-events-none"
aria-hidden="true"
></div>
<span
class="inline-block w-3 h-3 rounded-full bg-accent"
aria-hidden="true"></span>
<span class="text-xl font-bold tracking-tight text-zinc-100"
>{T.brandLabel}</span
>
</div>
<h1 class="text-3xl sm:text-4xl font-bold text-zinc-100 leading-tight">
{categoryName}
</h1>
<p class="mt-3 text-base text-zinc-300 max-w-md">
{description}
</p>
</header>
<main class="mt-8">
<div
class="grid gap-3"
style="grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); grid-auto-rows: 1fr;"
role="list"
aria-label={`Генераторы ${categoryName}`}
>
{
categoryGenerators.map((generator) => (
<div role="listitem">
<GeneratorCard generator={generator} />
</div>
))
}
</div>
</main>
</div>
</BaseLayout>
+23
View File
@@ -43,6 +43,29 @@ const webSiteSchema = {
</p>
</header>
<nav aria-label="Категории" class="mb-8">
<ul class="flex flex-wrap gap-2">
{
[
{ slug: "gaming", label: T.catGaming },
{ slug: "security", label: T.catSecurity },
{ slug: "decision-making", label: T.catDecisionMaking },
{ slug: "creative", label: T.catCreative },
{ slug: "utility", label: T.catUtility },
].map((cat) => (
<li>
<a
href={`/ru/generators/category/${cat.slug}/`}
class="inline-flex items-center px-4 py-2 rounded-full bg-zinc-800/60 border border-zinc-700/50 text-zinc-300 text-sm font-medium hover:bg-accent/20 hover:text-accent hover:border-accent/30 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-950"
>
{cat.label} ({generators.filter((g) => g.category === cat.slug).length})
</a>
</li>
))
}
</ul>
</nav>
<YandexRTB />
<main class="mt-8">
+24
View File
@@ -0,0 +1,24 @@
import rss from "@astrojs/rss";
import { getCollection } from "astro:content";
import type { APIContext } from "astro";
export async function GET(context: APIContext) {
const posts = await getCollection("blog");
const ruPosts = posts.filter(
(post) => post.data.lang === "ru" && post.data.draft !== true
);
return rss({
title: "Блог Randify",
description:
"Последние обновления, руководства и советы от Randify — инструментов, которые помогают принимать случайные решения.",
site: context.site!,
items: ruPosts.map((post) => ({
title: post.data.title,
description: post.data.description,
pubDate: post.data.pubDate,
link: `/ru/blog/${post.slug}/`,
})),
customData: "<language>ru</language>",
});
}