release(tach-typography): v0.3.0
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
"<script>alert(1)</script>",
|
||||
);
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./types";
|
||||
export * from "./classnames";
|
||||
export * from "./ellipsis";
|
||||
export * from "./markdown";
|
||||
|
||||
65
packages/tach-typography/src/core/markdown.ts
Normal file
65
packages/tach-typography/src/core/markdown.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user