1
0
mirror of https://github.com/tabler/tabler.git synced 2026-08-03 17:04:39 +04:00

Extract Liquid filter helpers into shared typed lib modules (#2725)

This commit is contained in:
Paweł Kuna
2026-07-30 23:46:12 +02:00
committed by GitHub
parent c08e52a885
commit 0f8dcb0a3c
43 changed files with 456 additions and 264 deletions
@@ -0,0 +1,6 @@
---
"@tabler/preview": patch
"@tabler/docs": patch
---
Updated preview and docs Astro code to use shared `date-format`, `string-format`, `pseudo-random`, and `include-args` helpers instead of duplicated inline Liquid filter ports.
+6 -2
View File
@@ -256,11 +256,11 @@ importers:
specifier: ^0.4.0
version: 0.4.0
shared/astro:
shared:
dependencies:
'@tabler/core':
specifier: workspace:*
version: link:../../core
version: link:../core
js-beautify:
specifier: ^2.0.3
version: 2.0.3
@@ -270,6 +270,10 @@ importers:
shiki:
specifier: ^4.3.1
version: 4.3.1
devDependencies:
vitest:
specifier: 4.1.10
version: 4.1.10(@types/node@26.1.1)(@vitest/browser-playwright@4.1.10)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.1)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.1)(yaml@2.9.0))
packages:
+1 -3
View File
@@ -1,10 +1,8 @@
packages:
- core
- preview
- preview-astro
- docs
- docs-astro
- 'shared/*'
- shared
allowBuilds:
'@parcel/watcher': false
+1 -3
View File
@@ -10,6 +10,7 @@ import CardBody from '@shared/components/ui/CardBody.astro';
import CardTitle from '@shared/components/ui/CardTitle.astro';
import people from '@data/people.json';
import { site } from '@shared/lib/site';
import { firstLetters } from '@shared/lib/string-format';
const iconIcons = ['user', 'settings', 'car', 'balloon', 'users', 'users-group', 'apps', 'ghost'];
@@ -17,9 +18,6 @@ const iconIcons = ['user', 'settings', 'car', 'balloon', 'users', 'users-group',
// (blue..cyan), confirmed against the reference output.
const colors = site.themeColors;
// equivalent of the first_letters filter
const firstLetters = (s: string) => s.split(' ').map((w) => w.charAt(0)).join('');
const people8 = people.slice(0, 8);
const people5 = people.slice(0, 5);
const sizes = ['xxs', 'xs', 'sm', 'md', 'lg', 'xl'];
+1 -3
View File
@@ -11,12 +11,10 @@ import CardBody from '@shared/components/ui/CardBody.astro';
import CardTitle from '@shared/components/ui/CardTitle.astro';
import DropdownMenu from '@shared/components/ui/DropdownMenu.astro';
import site from '@data/site.json';
import { ucFirst } from '@shared/lib/string-format';
const colors = ['default', ...Object.keys(site.colors), 'dark', 'light'];
const sizes = ['sm', 'md', 'lg'];
// Liquid uc_first filter
const ucFirst = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);
---
<DefaultLayout title="Badges" pageHeader="Badges" pageMenu="base.badges">
+5 -6
View File
@@ -13,6 +13,7 @@ import SwitchIcon from '@shared/components/ui/SwitchIcon.astro';
import cryptoCurrencies from '@data/crypto-currencies.json';
import cryptoMarkets from '@data/crypto-markets.json';
import cryptoOrders from '@data/crypto-orders.json';
import { parseCurrency, roundTo } from '@shared/lib/string-format';
interface Currency {
symbol: string;
@@ -29,14 +30,12 @@ const eth = find('ETH');
const xmr = find('XMR');
const btcBalance = 2.3;
const num = (s: string) => parseFloat(s.replace(/[$,]/g, ''));
const btcPriceNum = num(btc.price);
const btcPriceNum = parseCurrency(btc.price);
const totalUsd = Math.round(btcPriceNum * btcBalance).toLocaleString('en-US');
// Liquid `divided_by` then `round: 8` (trailing zeros dropped by number output)
const r8 = (x: number) => parseFloat(x.toFixed(8));
const ltcBtc = r8(num(ltc.price) / btcPriceNum);
const ethBtc = r8(num(eth.price) / btcPriceNum);
const xmrBtc = r8(num(xmr.price) / btcPriceNum);
const ltcBtc = roundTo(parseCurrency(ltc.price) / btcPriceNum);
const ethBtc = roundTo(parseCurrency(eth.price) / btcPriceNum);
const xmrBtc = roundTo(parseCurrency(xmr.price) / btcPriceNum);
const markets = (cryptoMarkets as { coin: string; price: string; volume: string; change: string }[]).slice(0, 10);
const orders = cryptoOrders as { sell_orders: { price: string; btc: string; sum: string }[]; buy_orders: { price: string; btc: string; sum: string }[] };
+4 -19
View File
@@ -1,6 +1,7 @@
---
import DefaultLayout from '@shared/layouts/DefaultLayout.astro';
import { liquidUnixSeconds, liquidLongDate } from '@shared/lib/liquid-date';
import { toUnixSeconds, formatLongDate } from '@shared/lib/date-format';
import { randomNumber, randomDate } from '@shared/lib/pseudo-random';
import Card from '@shared/components/ui/Card.astro';
import CardBody from '@shared/components/ui/CardBody.astro';
import Progress from '@shared/components/ui/Progress.astro';
@@ -9,22 +10,6 @@ import rollercoasters from '@data/rollercoasters.json';
const id = 'default';
// Equivalent of the random_number / random_date filters from shared/e11ty/filters.mjs.
function randomNumber(x: number, min = 0, max = 100): number {
let value =
((x * x * Math.PI * Math.E * (max + 1) * (Math.sin(x) / Math.cos(x * x))) % (max + 1 - min)) + min;
value = value > max ? max : value;
value = value < min ? min : value;
return Math.floor(value);
}
function randomDate(x: number): Date {
const start = new Date('2024-01-01').getTime() / 1000;
const end = new Date('2024-12-30').getTime() / 1000;
return new Date(randomNumber(x, start, end) * 1000);
}
const rows = (rollercoasters as Record<string, any>[]).map((rc, i) => {
const index = i + 1;
const progress = randomNumber(index, 0, 100);
@@ -33,8 +18,8 @@ const rows = (rollercoasters as Record<string, any>[]).map((rc, i) => {
rc,
progress,
quantity: randomNumber(index, 1, 200),
dateSeconds: liquidUnixSeconds(date),
dateLabel: liquidLongDate(date),
dateSeconds: toUnixSeconds(date),
dateLabel: formatLongDate(date),
};
});
+1 -3
View File
@@ -1,5 +1,6 @@
---
import DefaultLayout from '@shared/layouts/DefaultLayout.astro';
import { ucFirst as capitalize } from '@shared/lib/string-format';
import Card from '@shared/components/ui/Card.astro';
import CardBody from '@shared/components/ui/CardBody.astro';
import Offcanvas from '@shared/components/ui/Offcanvas.astro';
@@ -7,9 +8,6 @@ import OffcanvasHeader from '@shared/components/ui/OffcanvasHeader.astro';
import OffcanvasBody from '@shared/components/ui/OffcanvasBody.astro';
const directions = ['start', 'end', 'top', 'bottom'];
// Liquid `capitalize` filter.
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
---
<DefaultLayout title="Offcanvas" pageHeader="Offcanvas" pageMenu="base.offcanvas">
+1 -3
View File
@@ -1,11 +1,9 @@
---
import DefaultLayout from '@shared/layouts/DefaultLayout.astro';
import { slugifyWord as slug } from '@shared/lib/string-format';
const headers = ['Home', 'Profile', 'Messages', 'Settings', 'About', 'Contact', 'Services', 'Team', 'Work'];
// Liquid `slugify`: lowercase; these single words reduce to lowercase.
const slug = (text: string) => text.toLowerCase();
const lorem =
'Lorem ipsum dolor sit amet, consectetur adipisicing elit. A accusantium, alias autem beatae blanditiis corporis debitis eligendi, enim error excepturi exercitationem odit porro quasi reiciendis saepe sapiente veritatis? Aliquam assumenda beatae, cumque delectus dolorem enim, eveniet facere fugit harum illum iure magnam nemo neque nisi omnis, pariatur tenetur vel? Accusantium aut cum deleniti dolor doloribus eum, molestiae nulla officiis quasi. At cupiditate dolor explicabo id nesciunt placeat unde voluptates. Asperiores cum doloremque esse fugit labore quia reprehenderit similique. Architecto est ipsum maiores odio perferendis quibusdam tempore velit? Accusantium aliquid consequatur corporis dignissimos distinctio eos eum fugiat impedit nam obcaecati officiis, porro, quia quibusdam repellendus sapiente suscipit temporibus ullam velit vitae voluptates? Aliquam consectetur consequatur consequuntur deleniti dicta dolores ducimus, excepturi ipsam iure molestias necessitatibus numquam optio quaerat quasi quo repudiandae sed. Ad aliquam animi beatae culpa delectus esse excepturi in incidunt ipsam iusto labore laboriosam minima, nam, nemo nisi nobis, nulla praesentium provident quae quaerat qui quia quibusdam quis quisquam quos repellendus sint suscipit tempora vero vitae! Animi assumenda dolorum eaque, explicabo laborum officia praesentium quia repudiandae. Aliquam asperiores cupiditate deserunt nobis nostrum reprehenderit voluptates? Dolorem doloremque ducimus magni, maxime sint tenetur totam. Accusamus atque beatae consequatur corporis, dignissimos dolore dolores dolorum earum error eum eveniet, facere impedit incidunt minima molestias nemo non nostrum placeat quasi qui ratione repudiandae suscipit tenetur ullam vel velit voluptatibus. Accusantium alias assumenda blanditiis consectetur cupiditate delectus dolor dolores dolorum, ducimus eaque enim, error esse eum fugiat fugit id ipsam ipsum laboriosam laudantium minus modi molestias mollitia necessitatibus nihil odio officia praesentium quaerat quis quisquam quos reiciendis tempora tempore ut velit vitae voluptas voluptatem! Accusantium adipisci architecto assumenda atque aut consectetur consequuntur cum, deserunt doloribus ea excepturi exercitationem expedita explicabo facere fuga fugit impedit iste iusto laboriosam molestiae nihil officiis perferendis porro possimus provident quae quaerat qui quibusdam quos reiciendis repellendus vel vero, voluptatem! Ab amet aperiam assumenda aut error eveniet, id inventore laudantium molestias mollitia natus neque nulla officiis, porro quam quas quisquam repellendus repudiandae saepe sapiente ut voluptas, voluptate. Ab ad alias, aliquam atque consequatur culpa deserunt distinctio eius, enim est ex exercitationem facere facilis itaque magni maiores modi nemo neque perferendis placeat quam quas quia quis quod quos reiciendis sequi sunt tempore vero vitae! Earum explicabo nam quaerat quam quos sed voluptatem. Asperiores debitis dolorum, eaque eligendi optio ullam velit? Aperiam beatae cumque earum et explicabo maxime modi molestias odit, omnis placeat quasi quibusdam, ratione sapiente vel voluptas? A, aliquid beatae dolore eaque eos excepturi expedita facere facilis fugit ipsam iure molestiae molestias natus necessitatibus, nesciunt nulla, numquam obcaecati officia officiis pariatur quaerat quas quisquam rerum sapiente veniam. A aperiam beatae distinctio et illum laboriosam necessitatibus obcaecati porro sed vero. Accusantium at aut consequatur corporis culpa cupiditate delectus dolores eius eligendi, enim error esse est, et excepturi fugit id ipsam ipsum itaque modi mollitia necessitatibus neque non nulla obcaecati officia placeat qui quia saepe sit temporibus totam ut voluptas voluptatibus? Ad consectetur eos est illum laboriosam minus molestiae officia placeat quas tenetur.';
---
@@ -4,6 +4,7 @@ import ChartSparkline from '../ui/ChartSparkline.astro';
import Icon from '../ui/Icon.astro';
import Avatar from '../ui/Avatar.astro';
import commits from '@data/commits.json';
import { formatCommitDate } from '@shared/lib/date-format';
interface Commit {
hash: string;
@@ -13,12 +14,6 @@ interface Commit {
description: string;
}
// Equivalent of the Liquid date_to_string filter ("Thu Nov 28 08:48:33 2025 +0100" → "28 Nov 2025")
const dateToString = (date: string) => {
const m = date.match(/^\w+ (\w+) (\d+) [\d:]+ (\d+)/);
return m ? `${m[2]} ${m[1]} ${m[3]}` : date;
};
const rows = (commits as Commit[]).slice(0, 5);
---
@@ -59,7 +54,7 @@ const rows = (commits as Commit[]).slice(0, 5);
{commit.description}
</div>
</td>
<td class="text-nowrap text-secondary">{dateToString(commit.date)}</td>
<td class="text-nowrap text-secondary">{formatCommitDate(commit.date)}</td>
</tr>
))}
</tbody>
@@ -4,6 +4,7 @@
// in Astro they are passed explicitly as the `photo` and `index` props.
import Avatar from '../ui/Avatar.astro';
import Icon from '../ui/Icon.astro';
import { randomNumber, timeagoLabel } from '@shared/lib/pseudo-random';
interface Photo {
file: string;
@@ -26,26 +27,6 @@ interface Props {
const { photo, person, index, hideLikes } = Astro.props;
// Equivalent of the random_number filter from shared/e11ty/filters.mjs (deterministic).
function randomNumber(x: number, min = 0, max = 100): number {
let value =
((x * x * Math.PI * Math.E * (max + 1) * (Math.sin(x) / Math.cos(x * x))) % (max + 1 - min)) +
min;
value = value > max ? max : value;
value = value < min ? min : value;
return Math.floor(value);
}
// Equivalent of `forloop.index | random_date_ago: 10 | timeago` — a date N days ago
// (N = randomNumber(index, 0, 10)) formatted by timeago gives "now" / "N day(s) ago".
function timeagoLabel(index: number): string {
const days = randomNumber(index, 0, 10);
if (days === 0) return 'now';
return `${days} day${days > 1 ? 's' : ''} ago`;
}
// Liquid: {% if forloop.index > 2 and forloop.index < 9 or forloop.index == 10 %}
const heartClass = (index > 2 && index < 9) || index === 10 ? 'icon-filled text-red' : undefined;
---
@@ -57,7 +38,7 @@ const heartClass = (index > 2 && index < 9) || index === 10 ? 'icon-filled text-
<Avatar person={person} class="me-3 rounded" />
<div>
<div>{person.full_name}</div>
<div class="text-secondary">{timeagoLabel(index)}</div>
<div class="text-secondary">{timeagoLabel(index, 10)}</div>
</div>
{
!hideLikes && (
@@ -3,9 +3,7 @@ import Icon from '../ui/Icon.astro';
import ChartSparkline from '../ui/ChartSparkline.astro';
import CardTitle from '../ui/CardTitle.astro';
import urls from '@data/urls.json';
// equivalent of the format_number filter from shared/e11ty/filters.mjs
const formatNumber = (value: number) => value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
import { formatNumber } from '@shared/lib/string-format';
---
<div class="card">
@@ -1,4 +1,6 @@
---
// Port cards/social-traffic.html
import { formatNumber } from '@shared/lib/string-format';
import CardTitle from '../ui/CardTitle.astro';
const services = 'Instagram:3550,Twitter:1798,Facebook:1245,TikTok:986,Pinterest:854,VK:650,Pinterest:420'
@@ -7,9 +9,6 @@ const services = 'Instagram:3550,Twitter:1798,Facebook:1245,TikTok:986,Pinterest
const [name, visitors] = service.split(':');
return { name, visitors: Number(visitors) };
});
// equivalent of the format_number filter from shared/e11ty/filters.mjs
const formatNumber = (value: number) => value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
---
<div class="card">
+3 -44
View File
@@ -3,49 +3,8 @@ import Icon from '../ui/Icon.astro';
import Avatar from '../ui/Avatar.astro';
import CardTitle from '../ui/CardTitle.astro';
import tasksList from '@data/tasks-list.json';
// Equivalent of the random_number / random_date filters from shared/e11ty/filters.mjs
// (deterministic pseudo-random depending on the loop index).
function randomNumber(x: number, min = 0, max = 100, round = 0): number {
let value =
((x * x * Math.PI * Math.E * (max + 1) * (Math.sin(x) / Math.cos(x * x))) % (max + 1 - min)) + min;
value = value > max ? max : value;
value = value < min ? min : value;
if (round !== 0) {
value = parseFloat(value.toFixed(round));
} else {
value = Math.floor(value);
}
return value;
}
function randomDate(x: number): Date {
const start = new Date('2024-01-01').getTime() / 1000;
const end = new Date('2024-12-30').getTime() / 1000;
return new Date(randomNumber(x, start, end) * 1000);
}
// equivalent of the Liquid date: '%B %d, %Y'
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
const formatDate = (date: Date) =>
`${months[date.getMonth()]} ${String(date.getDate()).padStart(2, '0')}, ${date.getFullYear()}`;
import { randomNumber, randomDate } from '@shared/lib/pseudo-random';
import { formatLongDate } from '@shared/lib/date-format';
---
<div class="card">
@@ -66,7 +25,7 @@ const formatDate = (date: Date) =>
</td>
<td class="text-nowrap text-secondary">
<Icon name="calendar" />
{formatDate(randomDate(index))}
{formatLongDate(randomDate(index))}
</td>
<td class="text-nowrap">
<a href="#" class="text-secondary">
+1 -10
View File
@@ -7,6 +7,7 @@ import ListGroupItem from '../ui/ListGroupItem.astro';
import CardTitle from '../ui/CardTitle.astro';
import people from '@data/people.json';
import commits from '@data/commits.json';
import { randomNumber } from '@shared/lib/pseudo-random';
interface Person {
full_name?: string;
@@ -42,16 +43,6 @@ const {
class: className,
} = Astro.props;
// Equivalent of the random_number filter from shared/e11ty/filters.mjs (deterministic).
function randomNumber(x: number, min = 0, max = 100): number {
let value =
((x * x * Math.PI * Math.E * (max + 1) * (Math.sin(x) / Math.cos(x * x))) % (max + 1 - min)) +
min;
value = value > max ? max : value;
value = value < min ? min : value;
return Math.floor(value);
}
const colors = ['green', 'red', 'yellow', 'x', 'x'];
const commitList = commits as { description: string }[];
+2 -18
View File
@@ -3,6 +3,7 @@
import Avatar from '../ui/Avatar.astro';
import CardTitle from '../ui/CardTitle.astro';
import people from '@data/people.json';
import { randomNumber, timeagoLabel } from '@shared/lib/pseudo-random';
interface Person {
full_name?: string;
@@ -19,23 +20,6 @@ interface Props {
const { limit = 10, offset = 0, title = 'Top users', class: className } = Astro.props;
// Equivalent of the random_number filter from shared/e11ty/filters.mjs (deterministic).
function randomNumber(x: number, min = 0, max = 100): number {
let value =
((x * x * Math.PI * Math.E * (max + 1) * (Math.sin(x) / Math.cos(x * x))) % (max + 1 - min)) +
min;
value = value > max ? max : value;
value = value < min ? min : value;
return Math.floor(value);
}
// Equivalent of `forloop.index | random_date_ago: 6 | timeago`.
function timeagoLabel(index: number): string {
const days = randomNumber(index, 0, 6);
if (days === 0) return 'now';
return `${days} day${days > 1 ? 's' : ''} ago`;
}
const colors = 'green,red,yellow,x,x'.split(',');
const rows = (people as Person[]).slice(offset, offset + limit).map((person, idx) => {
@@ -43,7 +27,7 @@ const rows = (people as Person[]).slice(offset, offset + limit).map((person, idx
return {
person,
status: colors[randomNumber(index + 5, 0, colors.length - 1)],
timeago: timeagoLabel(index),
timeago: timeagoLabel(index, 6),
};
});
---
@@ -7,6 +7,7 @@ import ListGroupHeader from '../ui/ListGroupHeader.astro';
import CardTitle from '../ui/CardTitle.astro';
import people from '@data/people.json';
import commits from '@data/commits.json';
import { sortBy } from '@shared/lib/string-format';
interface Person {
full_name?: string;
@@ -24,11 +25,7 @@ const { title = 'People' } = Astro.props;
const commitList = commits as { description: string }[];
// Equivalent of `people | sort: 'last_name'` (LiquidJS case-sensitive sort).
const sorted = [...(people as Person[])].sort((a, b) => {
const l = a.last_name ?? '';
const r = b.last_name ?? '';
return l < r ? -1 : l > r ? 1 : 0;
});
const sorted = sortBy(people as Person[], (p) => p.last_name ?? '');
// offset is unset in the include, so `forloop.index | plus: offset` == forloop.index.
let prevLetter = '';
@@ -8,13 +8,7 @@ import DropdownMenu from '../../ui/DropdownMenu.astro';
import ListGroup from '../../ui/ListGroup.astro';
import ListGroupItem from '../../ui/ListGroupItem.astro';
import tracks from '@data/tracks.json';
// equivalent of the miliseconds_to_minutes filter from shared/e11ty/filters.mjs
const milisecondsToMinutes = (value: number) => {
const minutes = Math.floor(value / 60000);
const seconds = ((value % 60000) / 1000).toFixed(0);
return `${minutes}:${Number(seconds) < 10 ? '0' : ''}${seconds}`;
};
import { millisecondsToMinutes } from '@shared/lib/string-format';
// {% for track in tracks limit: 12 %}
const items = tracks.slice(0, 12);
@@ -38,7 +32,7 @@ const items = tracks.slice(0, 12);
</div>
</div>
<div class="col-auto text-secondary">
{milisecondsToMinutes(track.duration_ms)}
{millisecondsToMinutes(track.duration_ms)}
</div>
<div class="col-auto">
<a href="#" class="link-secondary">
+1 -3
View File
@@ -6,6 +6,7 @@
import Subheader from '@shared/components/ui/Subheader.astro';
import docs from '@shared/data/docs.json';
import { pathSlug as slug } from '@shared/lib/string-format';
interface MenuLeaf {
title: string;
@@ -28,9 +29,6 @@ interface Props {
const { url } = Astro.props;
const menu = docs.menu as MenuSection[];
// Equivalent of `level2.url | slug`: "/ui/getting-started/" -> "uigetting-started".
const slug = (u: string) => u.split('/').filter(Boolean).join('');
---
<nav class="space-y space-y-5" id="menu">
+2 -5
View File
@@ -1,16 +1,13 @@
---
import Icon from '../ui/Icon.astro';
import { site } from '@shared/lib/site';
import { formatUtcTimestamp } from '@shared/lib/date-format';
// The PoC mirrors the development build (as in BaseLayout).
const environment: string = 'development';
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
// Equivalent of {{ 'now' | date: '%Y-%m-%d %H:%M %Z' }} — the Eleventy build formats in UTC.
const generatedAt = `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(
now.getUTCDate(),
)} ${pad(now.getUTCHours())}:${pad(now.getUTCMinutes())} +0000`;
const generatedAt = formatUtcTimestamp(now);
---
<!-- BEGIN FOOTER -->
+1 -13
View File
@@ -1,28 +1,16 @@
---
// Equivalent of parts/nav/nav-aside.html (search filter aside, no parameters).
// `{{ page | relative }}/` resolves to "./" for root-level pages.
import Subheader from '../ui/Subheader.astro';
import InputGroup from '../ui/InputGroup.astro';
import Button from '../ui/Button.astro';
import ListGroup from '../ui/ListGroup.astro';
import ListGroupItem from '../ui/ListGroupItem.astro';
import { randomNumber } from '@shared/lib/pseudo-random';
const categories = ['Games', 'Clothing', 'Jewelery', 'Toys'];
const ratings = ['5 stars', '4 stars', '3 stars', '2 and less stars'];
const tags = ['business', 'evening', 'leisure', 'party'];
// Equivalent of the random_number filter from shared/e11ty/filters.mjs (deterministic).
function randomNumber(x: number, min = 0, max = 100): number {
let value =
((x * x * Math.PI * Math.E * (max + 1) * (Math.sin(x) / Math.cos(x * x))) % (max + 1 - min)) +
min;
value = value > max ? max : value;
value = value < min ? min : value;
return Math.floor(value);
}
---
<form action="./" method="get" autocomplete="off" novalidate>
+2 -21
View File
@@ -3,6 +3,7 @@
import Avatar from './Avatar.astro';
import activity from '@data/activity.json';
import people from '@data/people.json';
import { timeagoLabel } from '@shared/lib/pseudo-random';
interface Props {
limit?: number;
@@ -17,26 +18,6 @@ interface Person {
[key: string]: unknown;
}
// Equivalent of the random_number filter from shared/e11ty/filters.mjs (deterministic).
function randomNumber(x: number, min = 0, max = 100): number {
let value =
((x * x * Math.PI * Math.E * (max + 1) * (Math.sin(x) / Math.cos(x * x))) % (max + 1 - min)) +
min;
value = value > max ? max : value;
value = value < min ? min : value;
return Math.floor(value);
}
// Equivalent of `forloop.index | random_date_ago: 4 | timeago` — a date N days ago
// (N = randomNumber(index, 0, 4)) formatted by timeago gives "now" / "N day(s) ago".
function timeagoLabel(index: number): string {
const days = randomNumber(index, 0, 4);
if (days === 0) return 'now';
return `${days} day${days > 1 ? 's' : ''} ago`;
}
const items = (activity as { text: string; icon?: string }[])
.slice(0, limit)
.map((item, i) => {
@@ -49,7 +30,7 @@ const items = (activity as { text: string; icon?: string }[])
.join(person?.full_name ?? '')
.split('%c')
.join(person?.company ?? ''),
timeago: timeagoLabel(index),
timeago: timeagoLabel(index, 4),
badge: index < 5,
};
});
+4 -22
View File
@@ -1,8 +1,9 @@
---
// Port of ui/advanced-table.html
import BadgesList from './BadgesList.astro';
import Icon from './Icon.astro';
import { liquidLongDate } from '@shared/lib/liquid-date';
import { formatLongDate } from '@shared/lib/date-format';
import { randomNumber, randomItem, randomDate } from '@shared/lib/pseudo-random';
import Avatar from './Avatar.astro';
import Button from './Button.astro';
import ButtonList from './ButtonList.astro';
@@ -26,31 +27,12 @@ const headers = (tableProperties as Record<string, any>)['advanced-table'].heade
name: string;
}[];
// Equivalent of the random_number / random_date filters from shared/e11ty/filters.mjs.
function randomNumber(x: number, min = 0, max = 100): number {
let value =
((x * x * Math.PI * Math.E * (max + 1) * (Math.sin(x) / Math.cos(x * x))) % (max + 1 - min)) + min;
value = value > max ? max : value;
value = value < min ? min : value;
return Math.floor(value);
}
function randomItem<T>(x: number, items: T[]): T {
return items[randomNumber(x, 0, items.length - 1)];
}
function randomDate(x: number): Date {
const start = new Date('2024-01-01').getTime() / 1000;
const end = new Date('2024-12-30').getTime() / 1000;
return new Date(randomNumber(x, start, end) * 1000);
}
const rows = (people as Record<string, any>[]).map((person, i) => {
const index = i + 1;
const status = randomItem(index + 5, statuses);
const itemTags = randomItem(index + 5, tags).split(',');
const category = randomItem(index, categories);
const date = liquidLongDate(randomDate(index));
const date = formatLongDate(randomDate(index));
return { person, index, status, itemTags, category, date };
});
+1 -3
View File
@@ -2,6 +2,7 @@
// Equivalent of ui/avatar.html
import Icon from './Icon.astro';
import people from '@data/people.json';
import { firstLetters } from '@shared/lib/string-format';
interface Person {
full_name?: string;
@@ -55,9 +56,6 @@ const {
...rest
} = Astro.props;
// equivalent of the first_letters filter
const firstLetters = (s: string) => (s || '').split(' ').map((w) => w.charAt(0)).join('');
let src = srcProp;
let placeholder = placeholderProp;
+1
View File
@@ -3,6 +3,7 @@
import Icon from './Icon.astro';
import Avatar from './Avatar.astro';
import people from '@data/people.json';
import { firstLetters } from '@shared/lib/string-format';
interface Person {
full_name?: string;
@@ -1,4 +1,5 @@
---
// Port of ui/breadcrumb.html.
import Icon from './Icon.astro';
interface Props {
@@ -1,5 +1,7 @@
---
// Port of ui/nav-segmented.html
import Icon from './Icon.astro';
import { includeArgCount } from '@shared/lib/include-args';
interface Props {
items?: string[];
@@ -33,8 +35,7 @@ const {
// `nav-N` junk classes are NOT stripped by the DOM diff, so they must be
// reproduced verbatim. An explicit size stays (nav-sm/nav-lg).
const includeArgKeys = ['items', 'icons', 'disabled', 'hover', 'default', 'vertical', 'size', 'fullWidth', 'class', 'name'] as const;
const props = Astro.props as Record<string, unknown>;
const argCount = includeArgKeys.filter((k) => props[k] !== undefined).length;
const argCount = includeArgCount(Astro.props as Record<string, unknown>, includeArgKeys);
const sizeToken = size ? `nav-${size}` : `nav-${argCount}`;
const itemsArr = items ?? [];
+2 -2
View File
@@ -2,6 +2,7 @@
// Equivalent of ui/progressbg.html
import Progress from './Progress.astro';
import Flag from './Flag.astro';
import { parsePercentage } from '@shared/lib/string-format';
interface Props {
value?: number | string;
@@ -21,8 +22,7 @@ const {
showValue,
} = Astro.props;
// Liquid: {% assign percentage = include.value | replace: '%', '' | default: 0 %}
const percentage = `${value ?? ''}`.split('%').join('') || '0';
const percentage = parsePercentage(value);
---
<div class={`progressbg${className ? ` ${className}` : ''}`}>
+1 -2
View File
@@ -10,6 +10,7 @@ import selects from '@data/selects.json';
import people from '@data/people.json';
import { addPageScript } from '@shared/lib/page-scripts';
import InlineScript from '@shared/components/InlineScript.astro';
import { firstLetters } from '@shared/lib/string-format';
interface Props {
id?: string;
@@ -51,8 +52,6 @@ const selectClass = [
const isMultiple = Boolean(multiple || data.multiple);
const firstLetters = (s: string) => (s || '').split(' ').map((w) => w.charAt(0)).join('');
interface Person {
id: string;
full_name: string;
+3 -2
View File
@@ -5,6 +5,8 @@
// `spinner-border-2`. Unlike icon-N/btn-N these `spinner-{type}-N` classes are
// NOT stripped by the DOM diff (only icon|btn|avatar|badge|progress|flag|payment|
// steps|chart|form-switch|modal|nav are), so they must be reproduced verbatim.
import { includeArgCount } from '@shared/lib/include-args';
interface Props {
element?: string;
type?: string;
@@ -16,8 +18,7 @@ interface Props {
const { element: Element = 'div', type = 'border', color, size, class: className } = Astro.props;
const includeArgKeys = ['element', 'type', 'color', 'size', 'class'] as const;
const props = Astro.props as Record<string, unknown>;
const argCount = includeArgKeys.filter((k) => props[k] !== undefined).length;
const argCount = includeArgCount(Astro.props as Record<string, unknown>, includeArgKeys);
// include['size'] → explicit value, else the arg count meta-property.
const sizeValue = size !== undefined ? size : argCount || undefined;
+1 -9
View File
@@ -4,6 +4,7 @@ import Button from './Button.astro';
import ButtonList from './ButtonList.astro';
import DropdownMenu from './DropdownMenu.astro';
import people from '@data/people.json';
import { randomNumber } from '@shared/lib/pseudo-random';
interface Props {
card?: boolean;
@@ -29,15 +30,6 @@ const {
const roles = ['User', 'Admin', 'Owner'];
// Equivalent of the random_number filter from shared/e11ty/filters.mjs (deterministic).
function randomNumber(x: number, min = 0, max = 100): number {
let value =
((x * x * Math.PI * Math.E * (max + 1) * (Math.sin(x) / Math.cos(x * x))) % (max + 1 - min)) + min;
value = value > max ? max : value;
value = value < min ? min : value;
return Math.floor(value);
}
// {% for person in people limit: limit offset: include.offset %}
const start = offset ?? 0;
const rows = (people as Record<string, any>[]).slice(start, start + limit);
+4 -2
View File
@@ -1,4 +1,7 @@
---
// Port of ui/form/check.html
import { capitalize } from '@shared/lib/string-format';
interface Props {
type?: string;
checked?: boolean;
@@ -40,8 +43,7 @@ if (!title) {
t += ' ' + type;
t += ' input';
t = t.replace(/^\s+/, '');
// Liquid `capitalize`: uppercase first char, lowercase the rest.
t = t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
t = capitalize(t);
title = t;
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { formatCommitDate, formatLongDate, formatUtcTimestamp, toUnixSeconds } from './date-format'
describe('formatCommitDate', () => {
it('reformats a full timestamp to "D Mon YYYY"', () => {
expect(formatCommitDate('Thu Nov 28 08:48:33 2025 +0100')).toBe('28 Nov 2025')
})
it('returns the input unchanged when it does not match the expected shape', () => {
expect(formatCommitDate('not a date')).toBe('not a date')
})
})
describe('toUnixSeconds', () => {
it('shifts the wall-clock epoch by the local timezone offset', () => {
const date = new Date(Date.UTC(2024, 0, 1, 0, 0, 0))
expect(toUnixSeconds(date)).toBe(Math.floor(date.getTime() / 1000) + date.getTimezoneOffset() * 60)
})
})
describe('formatLongDate', () => {
it('formats a date using its UTC components', () => {
const date = new Date(Date.UTC(2025, 10, 28))
expect(formatLongDate(date)).toBe('November 28, 2025')
})
it('pads single-digit days', () => {
const date = new Date(Date.UTC(2025, 0, 5))
expect(formatLongDate(date)).toBe('January 05, 2025')
})
})
describe('formatUtcTimestamp', () => {
it('formats a date as "YYYY-MM-DD HH:MM +0000"', () => {
const date = new Date(Date.UTC(2025, 10, 28, 8, 5))
expect(formatUtcTimestamp(date)).toBe('2025-11-28 08:05 +0000')
})
})
+31
View File
@@ -0,0 +1,31 @@
// Reproduces the wall-clock/UTC date formatting quirks the app's demo data
// depends on. Two things matter for byte-identical output:
// - unix seconds are shifted by the local timezone offset, so it is NOT the
// true epoch (the value is derived from the rendered wall clock).
// - the long-date format uses the UTC wall-clock components of the timestamp.
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
/** Unix seconds shifted by the local timezone offset (not the true epoch). */
export function toUnixSeconds(date: Date): number {
return Math.floor(date.getTime() / 1000) + date.getTimezoneOffset() * 60
}
/** Formats a date as "Month DD, YYYY" using its UTC components. */
export function formatLongDate(date: Date): string {
return `${MONTHS[date.getUTCMonth()]} ${String(date.getUTCDate()).padStart(2, '0')}, ${date.getUTCFullYear()}`
}
/** Reformats a commit-style timestamp ("Thu Nov 28 08:48:33 2025 +0100") to "28 Nov 2025". */
export function formatCommitDate(date: string): string {
const m = date.match(/^\w+ (\w+) (\d+) [\d:]+ (\d+)/)
return m ? `${m[2]} ${m[1]} ${m[3]}` : date
}
function pad(n: number): string {
return String(n).padStart(2, '0')
}
/** Formats a date as "YYYY-MM-DD HH:MM +0000" in UTC. */
export function formatUtcTimestamp(date: Date): string {
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())} +0000`
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { includeArgCount } from './include-args'
describe('includeArgCount', () => {
const keys = ['a', 'b', 'c'] as const
it('counts only the keys present with a defined value', () => {
expect(includeArgCount({ a: 1, b: undefined, c: 'x' }, keys)).toBe(2)
})
it('returns 0 when no keys are present', () => {
expect(includeArgCount({}, keys)).toBe(0)
})
it('counts falsy-but-defined values (0, "", false) as present', () => {
expect(includeArgCount({ a: 0, b: '', c: false }, keys)).toBe(3)
})
})
+11
View File
@@ -0,0 +1,11 @@
/**
* Reproduces the LiquidJS `include['x']` meta-property bug: when a param isn't
* explicitly passed, referencing it as a bare property (rather than a lookup)
* falls back to the include's argument COUNT instead of undefined. Some ported
* components (ui/spinner.html, ui/nav-segmented.html) rely on this to emit
* "junk" classes like `spinner-border-2` / `nav-2` that the DOM parity diff
* does not strip, so they must be reproduced verbatim.
*/
export function includeArgCount(props: Record<string, unknown>, keys: readonly string[]): number {
return keys.filter((k) => props[k] !== undefined).length
}
-17
View File
@@ -1,17 +0,0 @@
// Replicates the Eleventy Liquid `date` filter under `timezoneOffset: 0`
// (shared/e11ty/config.mjs). Two quirks matter for parity:
// - `%s` (Unix seconds) is shifted by the local timezone offset, so it is NOT
// the true epoch (LiquidJS renders the wall clock, then reads its seconds).
// - `%B %d, %Y` uses the UTC wall-clock components of the original timestamp.
// Both are reproduced against the reference build on the same machine/timezone.
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
/** Equivalent of `{{ date | date: '%s' }}` with timezoneOffset: 0. */
export function liquidUnixSeconds(date: Date): number {
return Math.floor(date.getTime() / 1000) + date.getTimezoneOffset() * 60
}
/** Equivalent of `{{ date | date: '%B %d, %Y' }}` with timezoneOffset: 0. */
export function liquidLongDate(date: Date): string {
return `${MONTHS[date.getUTCMonth()]} ${String(date.getUTCDate()).padStart(2, '0')}, ${date.getUTCFullYear()}`
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { randomDate, randomItem, randomNumber, timeagoLabel } from './pseudo-random'
describe('randomNumber', () => {
it('is deterministic for a given index', () => {
expect(randomNumber(5, 0, 100)).toBe(randomNumber(5, 0, 100))
})
it('stays within [min, max]', () => {
for (let i = 1; i <= 50; i++) {
const value = randomNumber(i, 10, 20)
expect(value).toBeGreaterThanOrEqual(10)
expect(value).toBeLessThanOrEqual(20)
}
})
it('floors the result when round is 0 (the default)', () => {
const value = randomNumber(7, 0, 100)
expect(Number.isInteger(value)).toBe(true)
})
it('rounds to the given number of digits when round is set', () => {
const value = randomNumber(7, 0, 1, 8)
expect(value).toBe(parseFloat(value.toFixed(8)))
})
})
describe('randomDate', () => {
it('returns a deterministic date within the 2024 range', () => {
const date = randomDate(3)
expect(date.getUTCFullYear()).toBe(2024)
expect(date).toEqual(randomDate(3))
})
})
describe('randomItem', () => {
it('deterministically picks one of the given items', () => {
const items = ['a', 'b', 'c', 'd']
const picked = randomItem(4, items)
expect(items).toContain(picked)
expect(randomItem(4, items)).toBe(picked)
})
})
describe('timeagoLabel', () => {
it('returns "now" when the random offset is 0 days', () => {
// index=0 -> randomNumber(0, 0, N) hits the x=0 edge case (sin/cos of 0).
expect(timeagoLabel(0, 5)).toBe('now')
})
it('pluralizes "day(s) ago" based on the offset', () => {
const label = timeagoLabel(2, 10)
expect(label === 'now' || /^\d+ days? ago$/.test(label)).toBe(true)
})
})
+35
View File
@@ -0,0 +1,35 @@
// Deterministic pseudo-random values seeded by a loop index, used to generate
// stable-looking demo data (numbers, dates, "time ago" labels) without a real RNG.
export function randomNumber(x: number, min = 0, max = 100, round = 0): number {
let value = ((x * x * Math.PI * Math.E * (max + 1) * (Math.sin(x) / Math.cos(x * x))) % (max + 1 - min)) + min
value = value > max ? max : value
value = value < min ? min : value
if (round !== 0) {
value = parseFloat(value.toFixed(round))
} else {
value = Math.floor(value)
}
return value
}
export function randomDate(x: number): Date {
const start = new Date('2024-01-01').getTime() / 1000
const end = new Date('2024-12-30').getTime() / 1000
return new Date(randomNumber(x, start, end) * 1000)
}
export function randomItem<T>(x: number, items: T[]): T {
return items[randomNumber(x, 0, items.length - 1)]
}
/** A date up to `maxDays` days ago, formatted as "now" / "N day(s) ago". */
export function timeagoLabel(index: number, maxDays: number): string {
const days = randomNumber(index, 0, maxDays)
if (days === 0) return 'now'
return `${days} day${days > 1 ? 's' : ''} ago`
}
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import { capitalize, firstLetters, formatNumber, millisecondsToMinutes, parseCurrency, parsePercentage, pathSlug, roundTo, slugifyWord, sortBy, splitDropTrailingEmpty, ucFirst } from './string-format'
describe('capitalize', () => {
it('uppercases the first char and lowercases the rest', () => {
expect(capitalize('DISABLED')).toBe('Disabled')
expect(capitalize('checkbox input')).toBe('Checkbox input')
})
})
describe('ucFirst', () => {
it('uppercases only the first char, leaving the rest untouched', () => {
expect(ucFirst('default')).toBe('Default')
expect(ucFirst('dark')).toBe('Dark')
})
})
describe('firstLetters', () => {
it('takes the first letter of every word', () => {
expect(firstLetters('Paweł Kuna')).toBe('PK')
})
it('handles undefined/empty input', () => {
expect(firstLetters('')).toBe('')
expect(firstLetters(undefined as unknown as string)).toBe('')
})
})
describe('formatNumber', () => {
it('adds thousands separators', () => {
expect(formatNumber(1234567)).toBe('1,234,567')
expect(formatNumber(42)).toBe('42')
})
})
describe('splitDropTrailingEmpty', () => {
it('drops trailing empty strings', () => {
expect(splitDropTrailingEmpty('Tabler,Pages,', ',')).toEqual(['Tabler', 'Pages'])
})
it('keeps internal empty segments', () => {
expect(splitDropTrailingEmpty('a,,b', ',')).toEqual(['a', '', 'b'])
})
})
describe('slugifyWord', () => {
it('lowercases the input', () => {
expect(slugifyWord('Settings')).toBe('settings')
})
})
describe('pathSlug', () => {
it('strips slashes and joins segments', () => {
expect(pathSlug('/ui/getting-started/')).toBe('uigetting-started')
})
})
describe('parsePercentage', () => {
it('strips a trailing "%"', () => {
expect(parsePercentage('42%')).toBe('42')
})
it('defaults to "0" when empty or undefined', () => {
expect(parsePercentage(undefined)).toBe('0')
expect(parsePercentage('')).toBe('0')
})
it('passes through numeric input', () => {
expect(parsePercentage(75)).toBe('75')
})
})
describe('millisecondsToMinutes', () => {
it('formats sub-minute durations', () => {
expect(millisecondsToMinutes(45000)).toBe('0:45')
})
it('pads seconds under 10', () => {
expect(millisecondsToMinutes(65000)).toBe('1:05')
})
it('formats multi-minute durations', () => {
expect(millisecondsToMinutes(225000)).toBe('3:45')
})
})
describe('parseCurrency', () => {
it('strips "$" and "," before parsing', () => {
expect(parseCurrency('$23,077.05')).toBeCloseTo(23077.05)
})
})
describe('roundTo', () => {
it('rounds to the given number of digits, dropping trailing zeros', () => {
expect(roundTo(1 / 3, 4)).toBe(0.3333)
expect(roundTo(2, 8)).toBe(2)
})
})
describe('sortBy', () => {
it('sorts by the given key using case-sensitive string comparison', () => {
const people = [{ last_name: 'Smith' }, { last_name: 'Adams' }, { last_name: 'jones' }]
expect(sortBy(people, (p) => p.last_name).map((p) => p.last_name)).toEqual(['Adams', 'Smith', 'jones'])
})
it('does not mutate the input array', () => {
const people = [{ last_name: 'Smith' }, { last_name: 'Adams' }]
sortBy(people, (p) => p.last_name)
expect(people[0].last_name).toBe('Smith')
})
})
+72
View File
@@ -0,0 +1,72 @@
// String/number formatting helpers used by the demo data across components.
/** Uppercases the first char, lowercases the rest. */
export function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase()
}
/** Uppercases only the first char, leaves the rest untouched. */
export function ucFirst(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1)
}
/** Takes the first letter of each space-separated word (e.g. avatar initials). */
export function firstLetters(s: string): string {
return (s || '')
.split(' ')
.map((w) => w.charAt(0))
.join('')
}
/** Adds thousands separators to a number. */
export function formatNumber(value: number): string {
return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
/** Splits a string on `sep`, dropping trailing empty segments (e.g. "a,b," -> ["a", "b"]). */
export function splitDropTrailingEmpty(str: string, sep: string): string[] {
const items = str.split(sep)
while (items.length > 0 && items[items.length - 1] === '') items.pop()
return items
}
/** Lowercases a single-word string for use as an anchor slug. */
export function slugifyWord(text: string): string {
return text.toLowerCase()
}
/** Strips slashes and joins segments: "/ui/getting-started/" -> "uigetting-started". */
export function pathSlug(u: string): string {
return u.split('/').filter(Boolean).join('')
}
/** Strips a trailing "%" from a value, defaulting to "0" when empty/undefined. */
export function parsePercentage(value: number | string | undefined): string {
return `${value ?? ''}`.split('%').join('') || '0'
}
/** Formats a millisecond duration as "M:SS". */
export function millisecondsToMinutes(value: number): string {
const minutes = Math.floor(value / 60000)
const seconds = ((value % 60000) / 1000).toFixed(0)
return `${minutes}:${Number(seconds) < 10 ? '0' : ''}${seconds}`
}
/** Parses a "$1,234.56"-style currency string into a plain number. */
export function parseCurrency(s: string): number {
return parseFloat(s.replace(/[$,]/g, ''))
}
/** Rounds to the given number of decimal digits, dropping trailing zeros. */
export function roundTo(x: number, digits = 8): number {
return parseFloat(x.toFixed(digits))
}
/** Sorts items by a case-sensitive string key, without mutating the input array. */
export function sortBy<T>(items: T[], key: (item: T) => string): T[] {
return [...items].sort((a, b) => {
const l = key(a)
const r = key(b)
return l < r ? -1 : l > r ? 1 : 0
})
}
@@ -4,10 +4,17 @@
"version": "0.0.1",
"type": "module",
"description": "Astro components and lib shared between preview-astro and docs-astro (imported via the @shared vite alias; this package.json exists so npm imports resolve from shared sources)",
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"js-beautify": "^2.0.3",
"shiki": "^4.3.1",
"@tabler/core": "workspace:*",
"markdown-it": "^14.3.0"
},
"devDependencies": {
"vitest": "4.1.10"
}
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['astro/lib/**/*.test.ts'],
globals: true,
},
});