diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7ea308e..bf79c32 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/apps/web/src/components/public-content/code-block.tsx b/apps/web/src/components/public-content/code-block.tsx
index d034275..16d59eb 100644
--- a/apps/web/src/components/public-content/code-block.tsx
+++ b/apps/web/src/components/public-content/code-block.tsx
@@ -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,
@@ -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'
)
@@ -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
>
-
+
{block.language}
-
- {copyState === 'copied' ? (
-
- ) : (
-
- )}
- {copyState === 'copied'
- ? 'Copied'
- : copyState === 'failed'
- ? 'Copy failed'
- : 'Copy'}
-
+
+
+
+ }
+ >
+
+
+
+ {wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
+
+
+
+
+ }
+ >
+ {copyState === 'copied' ? (
+
+ ) : copyState === 'failed' ? (
+
+ ) : (
+
+ )}
+
+
+ {copyState === 'copied'
+ ? 'Copied'
+ : copyState === 'failed'
+ ? 'Copy failed'
+ : 'Copy code'}
+
+
+
{copyState === 'copied'
? 'Code copied to clipboard.'
@@ -81,7 +120,10 @@ export function PublicCodeBlock({ block }: { block: PreparedCodeBlock }) {
)
}
+ 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 && }
+ {children}
+ >
+ )
if (!target.internal) {
return (
- {children}
+ {linkContent}
- {children}
+ {linkContent}
)
}
+function LinkFavicon({ src }: { src: string }) {
+ const [failed, setFailed] = useState(false)
+ if (failed) return null
+ return (
+ {
+ if (element?.complete) {
+ void element.decode().catch(() => setFailed(true))
+ }
+ }}
+ onError={() => setFailed(true)}
+ />
+ )
+}
+
function InternalContentLink({
url,
title,
diff --git a/apps/web/src/components/public-content/index.tsx b/apps/web/src/components/public-content/index.tsx
index df1eb1f..f970004 100644
--- a/apps/web/src/components/public-content/index.tsx
+++ b/apps/web/src/components/public-content/index.tsx
@@ -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"
>
{
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) => {
@@ -46,8 +55,8 @@ async function highlight(node: Code): Promise {
}
})
)
- 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 },
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
index 43d6ae3..ff0caab 100644
--- a/apps/web/src/styles.css
+++ b/apps/web/src/styles.css
@@ -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);
@@ -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);
diff --git a/apps/web/src/utils/shiki.bundle.ts b/apps/web/src/utils/shiki.bundle.ts
index 756bceb..05e2f51 100644
--- a/apps/web/src/utils/shiki.bundle.ts
+++ b/apps/web/src/utils/shiki.bundle.ts
@@ -4,6 +4,7 @@ import {
} from '@shikijs/core'
import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript'
import oneDarkProTheme from '@shikijs/themes/one-dark-pro'
+import oneLightTheme from '@shikijs/themes/one-light'
/* Generate by @shikijs/codegen */
import type {
DynamicImportLanguageRegistration,
@@ -14,6 +15,7 @@ import type {
} from '@shikijs/types'
export const CODE_BLOCK_SHIKI_THEME = 'altstack-code-dark'
+export const CODE_BLOCK_SHIKI_LIGHT_THEME = 'altstack-code-light'
type BundledLanguage =
| 'angular-html'
@@ -64,6 +66,8 @@ type BundledLanguage =
| 'php'
| 'plsql'
| 'postcss'
+ | 'powershell'
+ | 'ps1'
| 'prisma'
| 'python'
| 'py'
@@ -99,6 +103,7 @@ type BundledTheme =
| 'github-light'
| 'github-dark'
| typeof CODE_BLOCK_SHIKI_THEME
+ | typeof CODE_BLOCK_SHIKI_LIGHT_THEME
type Highlighter = HighlighterGeneric
const CODE_BLOCK_BOLD_SCOPES = [
@@ -163,6 +168,7 @@ export const SHIKI_BUNDLED_LANGUAGE_GROUPS = [
{ id: 'php', name: 'PHP' },
{ id: 'plsql', name: 'PL/SQL' },
{ id: 'postcss', name: 'PostCSS' },
+ { id: 'powershell', name: 'PowerShell', aliases: ['ps1'] },
{ id: 'prisma', name: 'Prisma' },
{ id: 'python', name: 'Python', aliases: ['py'] },
{ id: 'ruby', name: 'Ruby', aliases: ['rb'] },
@@ -207,19 +213,30 @@ export type ShikiBundledLanguageCoverage = AssertNever<
Exclude
>
-const codeBlockShikiTheme = Object.freeze({
- ...oneDarkProTheme,
- name: CODE_BLOCK_SHIKI_THEME,
- tokenColors: [
- ...(oneDarkProTheme.tokenColors ?? []),
- {
- scope: [...CODE_BLOCK_BOLD_SCOPES],
- settings: {
- fontStyle: 'bold',
+function withCodeBlockBoldTokens(theme: ThemeRegistration, name: string) {
+ return Object.freeze({
+ ...theme,
+ name,
+ tokenColors: [
+ ...(theme.tokenColors ?? []),
+ {
+ scope: [...CODE_BLOCK_BOLD_SCOPES],
+ settings: {
+ fontStyle: 'bold',
+ },
},
- },
- ],
-}) satisfies ThemeRegistration
+ ],
+ }) satisfies ThemeRegistration
+}
+
+const codeBlockShikiTheme = withCodeBlockBoldTokens(
+ oneDarkProTheme,
+ CODE_BLOCK_SHIKI_THEME
+)
+const codeBlockShikiLightTheme = withCodeBlockBoldTokens(
+ oneLightTheme,
+ CODE_BLOCK_SHIKI_LIGHT_THEME
+)
const bundledLanguages = {
'angular-html': () => import('@shikijs/langs/angular-html'),
@@ -270,6 +287,8 @@ const bundledLanguages = {
php: () => import('@shikijs/langs/php'),
plsql: () => import('@shikijs/langs/plsql'),
postcss: () => import('@shikijs/langs/postcss'),
+ powershell: () => import('@shikijs/langs/powershell'),
+ ps1: () => import('@shikijs/langs/powershell'),
prisma: () => import('@shikijs/langs/prisma'),
python: () => import('@shikijs/langs/python'),
py: () => import('@shikijs/langs/python'),
@@ -310,6 +329,10 @@ const bundledThemes = {
Promise.resolve({
default: codeBlockShikiTheme,
}),
+ [CODE_BLOCK_SHIKI_LIGHT_THEME]: () =>
+ Promise.resolve({
+ default: codeBlockShikiLightTheme,
+ }),
} as Record
function getFontStyleHtmlStyle(token: ThemedToken) {
diff --git a/apps/web/tests/admin-blog-editor.test.tsx b/apps/web/tests/admin-blog-editor.test.tsx
index 0f9487c..8dff0b0 100644
--- a/apps/web/tests/admin-blog-editor.test.tsx
+++ b/apps/web/tests/admin-blog-editor.test.tsx
@@ -16,6 +16,7 @@ import {
render,
screen,
waitFor,
+ within,
} from '@testing-library/react'
import { useImperativeHandle, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
@@ -93,6 +94,43 @@ async function mount(existing?: typeof initial) {
return router
}
describe('admin blog editor', () => {
+ it('previews link favicons and saves the original Markdown after changing a failed destination', async () => {
+ await mount({
+ ...initial,
+ content: '[Docs](https://example.com/guide) [About](/about)',
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Preview content' }))
+ const preview = screen.getByRole('region', { name: 'Markdown preview' })
+ const docs = within(preview).getByRole('link', { name: /Docs/ })
+ expect(docs.querySelector('img')?.getAttribute('src')).toContain(
+ 'domain=example.com'
+ )
+ expect(
+ within(preview)
+ .getByRole('link', { name: 'About' })
+ .querySelector('img')
+ ?.getAttribute('src')
+ ).toBe('/favicon.svg')
+ fireEvent.error(docs.querySelector('img')!)
+ expect(docs.querySelector('img')).toBeNull()
+
+ const content = '[Docs](https://docs.example.org/start) [About](/about)'
+ fireEvent.change(screen.getByLabelText('Markdown editor'), {
+ target: { value: content },
+ })
+ expect(
+ within(preview)
+ .getByRole('link', { name: /Docs/ })
+ .querySelector('img')
+ ?.getAttribute('src')
+ ).toContain('domain=docs.example.org')
+ fireEvent.click(screen.getByRole('button', { name: 'Save draft' }))
+ await screen.findByText('Blog directory')
+ expect(
+ rpc.mock.calls.find(([path]) => path.at(-1) === 'update')?.[1]
+ ).toMatchObject({ body: { content } })
+ })
+
it('confirms deletion with the displayed revision and refreshes the admin list', async () => {
let posts = [initial]
rpc.mockImplementation((path) => {
diff --git a/apps/web/tests/blog.test.tsx b/apps/web/tests/blog.test.tsx
index 35ca6d5..5e55c77 100644
--- a/apps/web/tests/blog.test.tsx
+++ b/apps/web/tests/blog.test.tsx
@@ -87,7 +87,7 @@ describe('blog article HTML and anchors', () => {
container.innerHTML = renderToString( )
expect(
container
- .querySelector('[data-code-block] button')
+ .querySelector('[data-code-block] button[aria-label="Copy code"]')
?.getAttribute('aria-label')
).toBe('Copy code')
expect(
diff --git a/apps/web/tests/browser/about.browser.tsx b/apps/web/tests/browser/about.browser.tsx
index c4698ae..cd2afd9 100644
--- a/apps/web/tests/browser/about.browser.tsx
+++ b/apps/web/tests/browser/about.browser.tsx
@@ -172,4 +172,51 @@ describe('About page in Chromium', () => {
await screen.findByRole('heading', { name: defaultAboutPage.title })
expect(screen.queryByRole('alertdialog')).toBeNull()
})
+ it.each(['pointer', 'keyboard'] as const)(
+ 'opens the block style menu for a partial selection with %s and saves the heading',
+ async (interaction) => {
+ await page.viewport(1440, 900)
+ await mount(true, interaction === 'pointer' ? 'light' : 'dark')
+ const editor = document.querySelector(
+ '.bn-editor[contenteditable="true"]'
+ )!
+ editor.focus()
+ await userEvent.keyboard(
+ '{Control>}{End}{/Control}{Shift>}{ArrowLeft}{ArrowLeft}{ArrowLeft}{/Shift}'
+ )
+ expect(window.getSelection()?.toString()).toMatch(/^\w{1,3}\.$/)
+ const blockStyle = page.getByRole('toolbar').getByRole('combobox')
+ if (interaction === 'pointer') {
+ await blockStyle.click()
+ } else {
+ blockStyle.element().focus()
+ await userEvent.keyboard('{ArrowDown}')
+ }
+ const list = page.getByRole('listbox')
+ await expect.element(list).toBeVisible()
+ expect(editor.closest('.bn-container')?.contains(list.element())).toBe(
+ true
+ )
+ if (interaction === 'pointer') {
+ await page
+ .getByRole('option', { name: 'Heading 2', exact: true })
+ .click()
+ } else {
+ await userEvent.keyboard('{Home}{ArrowDown}{ArrowDown}{Enter}')
+ }
+ await waitFor(() => {
+ expect(editor.querySelector('h2')?.textContent).toBe('Our directory')
+ expect(editor.querySelectorAll('h2')).toHaveLength(2)
+ expect(editor.querySelectorAll('h2')[1]?.textContent).toBe(
+ 'Find useful software.'
+ )
+ })
+ await page.getByRole('button', { name: 'Save changes' }).click()
+ await screen.findByText('About us saved. Your changes are live.')
+ const request = z
+ .object({ body: aboutUpdateSchema })
+ .parse(rpc.mock.calls[0]?.[1])
+ expect(request.body.content).toContain('## Find useful software.')
+ }
+ )
})
diff --git a/apps/web/tests/public-content-prepare.test.ts b/apps/web/tests/public-content-prepare.test.ts
index f4d5dcd..eb153f0 100644
--- a/apps/web/tests/public-content-prepare.test.ts
+++ b/apps/web/tests/public-content-prepare.test.ts
@@ -115,7 +115,21 @@ describe('public Markdown preparation', () => {
})
it('highlights with both themes and preserves code including tabs and trailing blank lines', async () => {
- const code = '\tconst value = "