release(tach-typography): v0.3.0

This commit is contained in:
2026-03-31 18:36:20 +03:00
parent a8c2eaa2fd
commit cacbc016ec
35 changed files with 799 additions and 85 deletions

View File

@@ -1,4 +1,4 @@
import { NgStyle, NgSwitch, NgSwitchCase, NgSwitchDefault } from "@angular/common";
import { NgIf, NgStyle, NgSwitch, NgSwitchCase, NgSwitchDefault } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
@@ -19,6 +19,7 @@ import {
tachTypographyClassList,
tachTypographyClassName,
tachTypographyEllipsisStyle,
tachTypographyMarkdownToHtml,
type EllipsisOptions,
type TypographyClassOptions,
type TypographyColor,
@@ -291,6 +292,7 @@ export class TachTypographyHostPropsDirective implements OnChanges {
NgSwitch,
NgSwitchCase,
NgSwitchDefault,
NgIf,
NgStyle,
],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -310,7 +312,12 @@ export class TachTypographyHostPropsDirective implements OnChanges {
[ngStyle]="preserveStyle"
(click)="handleClick($event)"
>
<ng-content />
<ng-container *ngIf="markdownEnabled; else tachTypographyContentP">
<span [innerHTML]="renderedMarkdown"></span>
</ng-container>
<ng-template #tachTypographyContentP>
<ng-content />
</ng-template>
</p>
<a
*ngSwitchCase="'a'"
@@ -326,7 +333,12 @@ export class TachTypographyHostPropsDirective implements OnChanges {
[ngStyle]="preserveStyle"
(click)="handleClick($event)"
>
<ng-content />
<ng-container *ngIf="markdownEnabled; else tachTypographyContentA">
<span [innerHTML]="renderedMarkdown"></span>
</ng-container>
<ng-template #tachTypographyContentA>
<ng-content />
</ng-template>
</a>
<h1
*ngSwitchCase="'h1'"
@@ -342,7 +354,12 @@ export class TachTypographyHostPropsDirective implements OnChanges {
[ngStyle]="preserveStyle"
(click)="handleClick($event)"
>
<ng-content />
<ng-container *ngIf="markdownEnabled; else tachTypographyContentH1">
<span [innerHTML]="renderedMarkdown"></span>
</ng-container>
<ng-template #tachTypographyContentH1>
<ng-content />
</ng-template>
</h1>
<h2
*ngSwitchCase="'h2'"
@@ -358,7 +375,12 @@ export class TachTypographyHostPropsDirective implements OnChanges {
[ngStyle]="preserveStyle"
(click)="handleClick($event)"
>
<ng-content />
<ng-container *ngIf="markdownEnabled; else tachTypographyContentH2">
<span [innerHTML]="renderedMarkdown"></span>
</ng-container>
<ng-template #tachTypographyContentH2>
<ng-content />
</ng-template>
</h2>
<h3
*ngSwitchCase="'h3'"
@@ -374,7 +396,12 @@ export class TachTypographyHostPropsDirective implements OnChanges {
[ngStyle]="preserveStyle"
(click)="handleClick($event)"
>
<ng-content />
<ng-container *ngIf="markdownEnabled; else tachTypographyContentH3">
<span [innerHTML]="renderedMarkdown"></span>
</ng-container>
<ng-template #tachTypographyContentH3>
<ng-content />
</ng-template>
</h3>
<h4
*ngSwitchCase="'h4'"
@@ -390,7 +417,12 @@ export class TachTypographyHostPropsDirective implements OnChanges {
[ngStyle]="preserveStyle"
(click)="handleClick($event)"
>
<ng-content />
<ng-container *ngIf="markdownEnabled; else tachTypographyContentH4">
<span [innerHTML]="renderedMarkdown"></span>
</ng-container>
<ng-template #tachTypographyContentH4>
<ng-content />
</ng-template>
</h4>
<span
*ngSwitchDefault
@@ -406,17 +438,24 @@ export class TachTypographyHostPropsDirective implements OnChanges {
[ngStyle]="preserveStyle"
(click)="handleClick($event)"
>
<ng-content />
<ng-container *ngIf="markdownEnabled; else tachTypographyContentSpan">
<span [innerHTML]="renderedMarkdown"></span>
</ng-container>
<ng-template #tachTypographyContentSpan>
<ng-content />
</ng-template>
</span>
</ng-container>
`,
})
export class TachTypographyComponent {
export class TachTypographyComponent implements OnChanges {
@Input("as") hostTag: TachTypographyHostTag = "span";
@Input() variant: TypographyVariant = "Body";
@Input() color: TypographyColor = "primary";
@Input() weight: TypographyWeight = "normal";
@Input() clickable = false;
@Input() markdownEnabled = false;
@Input() markdown: string | undefined;
@Input() className: string | undefined;
@Input() ellipsis: EllipsisOptions | undefined;
@Input() nzProps: TachTypographyNzProps | undefined;
@@ -425,6 +464,14 @@ export class TachTypographyComponent {
@Output() readonly tachClick = new EventEmitter<MouseEvent>();
renderedMarkdown = "";
ngOnChanges(_changes: SimpleChanges): void {
this.renderedMarkdown = this.markdownEnabled
? tachTypographyMarkdownToHtml(this.markdown ?? "")
: "";
}
handleClick(event: MouseEvent): void {
this.tachClick.emit(event);
}

View File

@@ -4,6 +4,7 @@ import {
tachTypographyClassList,
tachTypographyClassName,
tachTypographyEllipsisStyle,
tachTypographyMarkdownToHtml,
} from "./index";
describe("tachTypographyClassName", () => {
@@ -54,3 +55,27 @@ describe("tachTypographyEllipsisStyle", () => {
});
});
});
describe("tachTypographyMarkdownToHtml", () => {
it("parses inline markdown tags", () => {
expect(
tachTypographyMarkdownToHtml("**bold** *italic* ~~strike~~ `code`"),
).toBe(
"<strong>bold</strong> <em>italic</em> <del>strike</del> <code class=\"tach-typography__md-code\">code</code>",
);
});
it("sanitizes html", () => {
expect(tachTypographyMarkdownToHtml("<script>alert(1)</script>")).toBe(
"&lt;script&gt;alert(1)&lt;/script&gt;",
);
});
it("keeps only safe link protocols", () => {
expect(
tachTypographyMarkdownToHtml("[safe](https://example.com) [unsafe](javascript:alert-1)"),
).toBe(
"<a class=\"tach-typography__md-link\" href=\"https://example.com\" target=\"_blank\" rel=\"noopener noreferrer\">safe</a> unsafe",
);
});
});

View File

@@ -1,3 +1,4 @@
export * from "./types";
export * from "./classnames";
export * from "./ellipsis";
export * from "./markdown";

View File

@@ -0,0 +1,65 @@
const TOKEN_PREFIX = "TACHMDTOKEN";
const SAFE_HREF_PATTERN = /^(https?:|mailto:|tel:|\/|#)/i;
const escapeHtml = (value: string): string =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const normalizeMarkdown = (value: string): string => value.replace(/\r\n?/g, "\n");
const sanitizeHref = (value: string): string | null => {
const href = value.trim();
if (!href || !SAFE_HREF_PATTERN.test(href)) {
return null;
}
return href;
};
export const tachTypographyMarkdownToHtml = (markdown: string): string => {
const source = normalizeMarkdown(markdown);
const tokenMap = new Map<string, string>();
let tokenId = 0;
const tokenized = source
.replace(/`([^`\n]+)`/g, (_match, code: string) => {
const token = `${TOKEN_PREFIX}${tokenId++}`;
tokenMap.set(
token,
`<code class="tach-typography__md-code">${escapeHtml(code)}</code>`,
);
return token;
})
.replace(/\[([^\]\n]+)\]\(([^)\n]+)\)/g, (_match, label: string, hrefRaw: string) => {
const token = `${TOKEN_PREFIX}${tokenId++}`;
const href = sanitizeHref(hrefRaw);
if (!href) {
tokenMap.set(token, escapeHtml(label));
return token;
}
tokenMap.set(
token,
`<a class="tach-typography__md-link" href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(label)}</a>`,
);
return token;
});
let html = escapeHtml(tokenized)
.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>")
.replace(/__([^_\n]+)__/g, "<strong>$1</strong>")
.replace(/\*([^*\n]+)\*/g, "<em>$1</em>")
.replace(/_([^_\n]+)_/g, "<em>$1</em>")
.replace(/~~([^~\n]+)~~/g, "<del>$1</del>")
.replace(/\n/g, "<br />");
html = html.replace(new RegExp(`${TOKEN_PREFIX}\\d+`, "g"), token => tokenMap.get(token) || token);
return html;
};

View File

@@ -7,6 +7,7 @@ import type { TextProps } from "antd/lib/typography/Text";
import type { TitleProps } from "antd/lib/typography/Title";
import {
tachTypographyMarkdownToHtml,
tachTypographyClassName,
type TypographyColor,
type TypographyVariant,
@@ -18,6 +19,8 @@ interface AdditionalProps {
weight?: TypographyWeight;
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
className?: string | undefined;
markdownEnabled?: boolean;
children?: React.ReactNode;
}
const createTypographyVariant = <P extends object>(
@@ -25,20 +28,45 @@ const createTypographyVariant = <P extends object>(
variant: TypographyVariant,
) => {
const Variant = React.forwardRef<HTMLElement, P & AdditionalProps>(
({ color = "primary", weight = "normal", className, onClick, ...rest }, ref) => (
<Component
ref={ref as never}
className={tachTypographyClassName({
variant,
color,
weight,
className,
clickable: Boolean(onClick),
})}
onClick={onClick}
{...(rest as P)}
/>
),
(
{
color = "primary",
weight = "normal",
className,
onClick,
markdownEnabled = false,
children,
...rest
},
ref,
) => {
const markdownHtml =
markdownEnabled && typeof children === "string"
? tachTypographyMarkdownToHtml(children)
: undefined;
const renderedChildren = markdownHtml ? (
<span dangerouslySetInnerHTML={{ __html: markdownHtml }} />
) : (
children
);
const contentProps = { children: renderedChildren } as unknown as P;
return (
<Component
ref={ref as never}
className={tachTypographyClassName({
variant,
color,
weight,
className,
clickable: Boolean(onClick),
})}
onClick={onClick}
{...(rest as P)}
{...contentProps}
/>
);
},
);
Variant.displayName = String(variant);

View File

@@ -0,0 +1,157 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import {
TYPOGRAPHY_COLORS,
TYPOGRAPHY_VARIANTS,
type TypographyColor,
type TypographyVariant,
type TypographyWeight,
} from "../core";
import { TachTypography } from "../react";
type TypographyNamespace = keyof typeof TachTypography;
type VariantComponentProps = {
children?: React.ReactNode;
className?: string;
color?: TypographyColor;
ellipsis?: boolean | { rows?: number };
href?: string;
level?: 1 | 2 | 3 | 4 | 5;
markdownEnabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
weight?: TypographyWeight;
};
type VariantComponent = React.ComponentType<VariantComponentProps>;
const getVariantComponent = (
namespace: TypographyNamespace,
variant: TypographyVariant,
): VariantComponent => {
return (TachTypography[namespace] as unknown as Record<TypographyVariant, VariantComponent>)[variant];
};
interface MarkdownArgs {
namespace: TypographyNamespace;
variant: TypographyVariant;
color: TypographyColor;
weight: TypographyWeight;
markdownEnabled: boolean;
content: string;
href: string;
titleLevel: 1 | 2 | 3 | 4 | 5;
}
const renderMarkdownTypography = (args: MarkdownArgs) => {
const Component = getVariantComponent(args.namespace, args.variant);
const componentProps: VariantComponentProps = {
color: args.color,
weight: args.weight,
markdownEnabled: args.markdownEnabled,
};
if (args.namespace === "Link") {
componentProps.href = args.href;
}
if (args.namespace === "Title") {
componentProps.level = args.titleLevel;
}
return (
<div className="tach-story-surface tach-story-stack">
<Component {...componentProps}>{args.content}</Component>
</div>
);
};
const meta: Meta<MarkdownArgs> = {
title: "TachTypography/Markdown",
tags: ["autodocs"],
render: renderMarkdownTypography,
args: {
namespace: "Text",
variant: "Body",
color: "primary",
weight: "normal",
markdownEnabled: true,
content: "**Bold** _italic_ ~~strike~~ `code` [Docs](https://example.com)",
href: "https://example.com",
titleLevel: 3,
},
argTypes: {
namespace: {
control: "select",
options: ["Text", "Paragraph", "Link", "Title"],
},
variant: {
control: "select",
options: TYPOGRAPHY_VARIANTS,
},
color: {
control: "select",
options: TYPOGRAPHY_COLORS,
},
weight: {
control: "inline-radio",
options: ["normal", "bold"],
},
markdownEnabled: {
control: "boolean",
},
content: {
control: "text",
},
href: {
control: "text",
description: "Works for Link namespace",
},
titleLevel: {
control: "inline-radio",
options: [1, 2, 3, 4, 5],
description: "Works for Title namespace",
},
},
parameters: {
docs: {
description: {
component:
"Markdown rendering in existing TachTypography components via the markdownEnabled prop.",
},
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const ReactMarkdown: Story = {};
export const MarkdownDisabled: Story = {
args: {
markdownEnabled: false,
content: "**Bold** _italic_ markdown is shown as plain text",
},
};
export const AngularUsage: Story = {
render: () => (
<div className="tach-story-surface tach-story-stack">
<h4>Angular usage</h4>
<pre>
<code>
{`<tach-typography
variant="Body"
[markdownEnabled]="true"
[markdown]="'**Bold** _italic_ [Docs](https://example.com)'"
></tach-typography>`}
</code>
</pre>
</div>
),
};

View File

@@ -67,6 +67,20 @@ export const PropsMatrix: Story = {
<td>tachTypographyClassName</td>
<td>Merges with token classes</td>
</tr>
<tr>
<td>markdownEnabled</td>
<td>boolean</td>
<td>markdownEnabled</td>
<td>[markdownEnabled]</td>
<td>Enables markdown rendering from content string</td>
</tr>
<tr>
<td>markdown</td>
<td>string</td>
<td>children string</td>
<td>[markdown]</td>
<td>Angular markdown source string</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -88,6 +88,17 @@
.tach-typography--AppMediumSubtextUnderline,
.ant-typography.tach-typography--AppMediumSubtextUnderline { font-family: Inter, sans-serif; font-size: 11px; font-weight: 400; line-height: 17px; text-decoration: underline; }
.tach-typography .tach-typography__md-link,
.ant-typography.tach-typography .tach-typography__md-link {
color: inherit;
text-decoration: underline;
}
.tach-typography .tach-typography__md-code,
.ant-typography.tach-typography .tach-typography__md-code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
@media (max-width: 575px) {
.tach-typography--AccentLargeTtl,
.ant-typography.tach-typography--AccentLargeTtl {