Skip to content
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,38 @@ have not been reconstructed. See [release note guidance](CONTRIBUTING.md#documen

## [Unreleased]

### Added

- Favicons beside ordinary internal and external Markdown links in public content
and admin previews, with a text-only fallback when an icon cannot load.
- PowerShell syntax highlighting, including the `ps1` alias, in the shared editor
language picker and public Markdown renderer.
- An independent word-wrap toggle on every public code block, including short
blocks. Wrapping changes presentation without changing copied code.

### Changed

- Public Markdown links reveal their underline on hover with a 300 ms transition.
Project and collection mentions retain their existing badges and icons.
- Public code blocks use custom One Light and One Dark Pro syntax palettes with
matching bold-token rules, transparent bodies and headers, and equally sized
icon-only copy and wrap controls with hover and keyboard-focus tooltips.
- Update oRPC to `2.0.0-beta.49` and tsrs to `0.23.0-ts7.1.0-beta`.

### Fixed

- Keep BlockNote's block-style select inside its editor portal so selecting part
of a paragraph and opening the toolbar menu does not dismiss it. Heading
changes remain available through the pointer and keyboard.
- Generate a name-based avatar when an account has no image.

### Upgrade notes

- Deploy matching API and web revisions. Changes since `v1.1.0` add no database
migrations or environment variables; existing Markdown needs no migration.
- External-link favicons use Google S2 with only the destination hostname and no
referrer. If that service is unavailable, links remain usable without icons.

## [1.1.0] - 2026-11-05

### Added
Expand Down
84 changes: 63 additions & 21 deletions apps/web/src/components/public-content/code-block.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { IconCheck, IconCopy } from '@tabler/icons-react'
import { IconCheck, IconCopy, IconTextWrap, IconX } from '@tabler/icons-react'
import { cn } from 'cn'
import { Fragment, useEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react'

import { Button } from '@altstack/ui/components/button'
import { Toggle } from '@altstack/ui/components/toggle'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@altstack/ui/components/tooltip'

import type {
CodeTokenStyle,
Expand All @@ -26,6 +33,7 @@ function themeStyles(light: CodeTokenStyle, dark: CodeTokenStyle) {
}

export function PublicCodeBlock({ block }: { block: PreparedCodeBlock }) {
const [wordWrap, setWordWrap] = useState(false)
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle'
)
Expand All @@ -50,28 +58,59 @@ export function PublicCodeBlock({ block }: { block: PreparedCodeBlock }) {
className="not-prose my-6 overflow-hidden rounded-lg border border-border"
data-code-block
>
<div className="flex items-center justify-between gap-3 border-b border-border bg-muted px-3 py-2">
<div className="flex items-center justify-between gap-3 border-b border-border bg-transparent px-3 py-2">
<span className="truncate font-mono text-xs text-muted-foreground">
{block.language}
</span>
<Button
type="button"
variant="ghost"
size="xs"
onClick={copyCode}
aria-label="Copy code"
>
{copyState === 'copied' ? (
<IconCheck data-icon="inline-start" />
) : (
<IconCopy data-icon="inline-start" />
)}
{copyState === 'copied'
? 'Copied'
: copyState === 'failed'
? 'Copy failed'
: 'Copy'}
</Button>
<div className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger
render={
<Toggle
size="sm"
className="size-7 p-0"
pressed={wordWrap}
onPressedChange={setWordWrap}
aria-label="Word wrap"
/>
}
>
<IconTextWrap aria-hidden="true" />
</TooltipTrigger>
<TooltipContent>
{wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
variant="ghost"
size="sm"
className="size-7 p-0"
onClick={copyCode}
aria-label="Copy code"
/>
}
>
{copyState === 'copied' ? (
<IconCheck aria-hidden="true" />
) : copyState === 'failed' ? (
<IconX aria-hidden="true" />
) : (
<IconCopy aria-hidden="true" />
)}
</TooltipTrigger>
<TooltipContent>
{copyState === 'copied'
? 'Copied'
: copyState === 'failed'
? 'Copy failed'
: 'Copy code'}
</TooltipContent>
</Tooltip>
</div>
<output className="sr-only">
{copyState === 'copied'
? 'Code copied to clipboard.'
Expand All @@ -81,7 +120,10 @@ export function PublicCodeBlock({ block }: { block: PreparedCodeBlock }) {
</output>
</div>
<pre
className="public-code m-0 overflow-x-auto p-4 text-sm leading-6"
className={cn(
'public-code m-0 overflow-x-auto p-4 text-sm leading-6',
wordWrap ? 'wrap-anywhere whitespace-pre-wrap' : 'whitespace-pre'
)}
style={
block.colors
? themeStyles(block.colors.light, block.colors.dark)
Expand Down
40 changes: 37 additions & 3 deletions apps/web/src/components/public-content/content-link.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { IconExternalLink, IconFolders } from '@tabler/icons-react'
import { Link, useRouter } from '@tanstack/react-router'
import type { Element, ElementContent } from 'hast'
import { useState } from 'react'
import type { ReactNode } from 'react'

import { env } from '@altstack/env/web'
Expand Down Expand Up @@ -47,10 +48,20 @@ export function ContentLink({
</a>
)
}
const imageLink = node && containsImage(node)
const faviconSrc = target.internal
? '/favicon.svg'
: `https://www.google.com/s2/favicons?domain=${encodeURIComponent(target.url.hostname)}&sz=32`
const linkContent = (
<>
{!imageLink && <LinkFavicon key={faviconSrc} src={faviconSrc} />}
{children}
</>
)
if (!target.internal) {
return (
<a href={href} title={title} target="_blank" rel="noopener noreferrer">
{children}
{linkContent}
<IconExternalLink
className="ml-0.5 inline size-3.5 align-baseline"
aria-hidden="true"
Expand All @@ -67,19 +78,42 @@ export function ContentLink({
target.collectionSlug && Object.hasOwn(collections, target.collectionSlug)
? collections[target.collectionSlug]
: undefined
const imageLink = node && containsImage(node)
return (
<InternalContentLink
url={target.url}
title={title}
project={imageLink ? undefined : project}
collection={imageLink ? undefined : collection}
>
{children}
{linkContent}
</InternalContentLink>
)
}

function LinkFavicon({ src }: { src: string }) {
const [failed, setFailed] = useState(false)
if (failed) return null
return (
<img
src={src}
alt=""
aria-hidden="true"
width={16}
height={16}
loading="lazy"
decoding="async"
referrerPolicy="no-referrer"
className="not-prose me-1 inline-block size-4 object-contain align-[-0.125em]"
ref={(element) => {
if (element?.complete) {
void element.decode().catch(() => setFailed(true))
}
}}
onError={() => setFailed(true)}
/>
)
}

function InternalContentLink({
url,
title,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/public-content/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function PublicContent({
aria-label={
headingMode === 'article' ? 'Article content' : 'Project documentation'
}
className="prose max-w-none min-w-0 wrap-anywhere prose-neutral dark:prose-invert"
className="prose max-w-none min-w-0 wrap-anywhere prose-neutral dark:prose-invert prose-a:decoration-transparent prose-a:transition-all prose-a:duration-300 prose-a:ease-in-out prose-a:hover:decoration-current prose-a:motion-reduce:transition-none"
>
<Markdown
remarkPlugins={
Expand Down
19 changes: 14 additions & 5 deletions apps/web/src/features/public-content/prepare.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,19 @@ import {
emptyPublicContent,
} from '#/features/public-content/model'
import type { PreparedCodeBlock } from '#/features/public-content/model'
import { bundledLanguages, getSingletonHighlighter } from '#/utils/shiki.bundle'
import {
CODE_BLOCK_SHIKI_LIGHT_THEME,
CODE_BLOCK_SHIKI_THEME,
bundledLanguages,
getSingletonHighlighter,
} from '#/utils/shiki.bundle'
import type { BundledLanguage } from '#/utils/shiki.bundle'

const parser = unified().use(remarkParse).use(remarkGfm)
const codeThemes = {
light: CODE_BLOCK_SHIKI_LIGHT_THEME,
dark: CODE_BLOCK_SHIKI_THEME,
} as const

function isBundledLanguage(language: string): language is BundledLanguage {
return Object.hasOwn(bundledLanguages, language)
Expand All @@ -30,12 +39,12 @@ async function highlight(node: Code): Promise<PreparedCodeBlock> {
if (!isBundledLanguage(language)) return block
try {
const highlighter = await getSingletonHighlighter({
themes: ['github-light', 'github-dark'],
themes: Object.values(codeThemes),
langs: [language],
})
const tokens = highlighter.codeToTokensWithThemes(node.value, {
lang: language,
themes: { light: 'github-light', dark: 'github-dark' },
themes: codeThemes,
})
block.tokens = tokens.map((line) =>
line.map((token) => {
Expand All @@ -46,8 +55,8 @@ async function highlight(node: Code): Promise<PreparedCodeBlock> {
}
})
)
const light = highlighter.getTheme('github-light')
const dark = highlighter.getTheme('github-dark')
const light = highlighter.getTheme(codeThemes.light)
const dark = highlighter.getTheme(codeThemes.dark)
block.colors = {
light: { color: light.fg, bgColor: light.bg },
dark: { color: dark.fg, bgColor: dark.bg },
Expand Down
3 changes: 1 addition & 2 deletions apps/web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
.public-code,
.public-code-token {
color: var(--code-light-color, var(--foreground));
background-color: var(--code-light-bg, transparent);
background-color: transparent;
font-style: var(--code-light-style, normal);
font-weight: var(--code-light-weight, 400);
text-decoration: var(--code-light-decoration, none);
Expand All @@ -15,7 +15,6 @@
.dark .public-code,
.dark .public-code-token {
color: var(--code-dark-color, var(--foreground));
background-color: var(--code-dark-bg, transparent);
font-style: var(--code-dark-style, normal);
font-weight: var(--code-dark-weight, 400);
text-decoration: var(--code-dark-decoration, none);
Expand Down
Loading
Loading