Merge remote-tracking branch 'origin/master' into material3-text-auto-resize
This commit is contained in:
commit
5b363f503a
26
CHANGELOG.md
26
CHANGELOG.md
|
|
@ -35,19 +35,6 @@
|
|||
- Move MCP connections into their own Settings destination instead of presenting them as part of model configuration.
|
||||
- Pan horizontally with Shift+wheel while preserving native horizontal trackpad movement.
|
||||
|
||||
### Performance
|
||||
|
||||
- Scope automation and Figma API layout reconciliation to graph nodes and parent containers actually changed by each mutation.
|
||||
- Keep rapid trackpad zoom reversals and effect-heavy document navigation responsive by cancelling obsolete reconstruction and reusing safe raster snapshots.
|
||||
- Show the FIG page list from a lightweight Kiwi scan before materializing the full document.
|
||||
- Avoid redundant collaboration writes when synchronized node fields have not changed.
|
||||
- Release obsolete streamed Markdown parser history after each AI response completes, preventing chat memory from multiplying with every streamed chunk. (#544)
|
||||
- Open large documents faster by using cached world positions while finding layers under the pointer. (#527)
|
||||
- Coalesce writable-document autosaves that overlap an active `.fig` export while preserving a trailing save for newer edits. (#528)
|
||||
- Defer JSX generation and syntax highlighting until the Code panel is active, keeping large canvas selections responsive. (#500)
|
||||
- Index Figma clipboard children once during import instead of rescanning every pasted node, keeping large flat pastes linear. (#500)
|
||||
- Reduce peak memory during `.fig` export by sharing immutable binary resources with the isolated export graph.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Preserve implicit fixed-size text inside imported Figma auto-layout frames across save and reload.
|
||||
|
|
@ -95,6 +82,19 @@
|
|||
- Preserve component links when pasting Figma instances so later component edits continue to update them.
|
||||
- Stop local MCP servers after the app disconnects instead of leaving orphaned background processes. (#494)
|
||||
|
||||
### Performance
|
||||
|
||||
- Scope automation and Figma API layout reconciliation to graph nodes and parent containers actually changed by each mutation.
|
||||
- Keep rapid trackpad zoom reversals and effect-heavy document navigation responsive by cancelling obsolete reconstruction and reusing safe raster snapshots.
|
||||
- Show the FIG page list from a lightweight Kiwi scan before materializing the full document.
|
||||
- Avoid redundant collaboration writes when synchronized node fields have not changed.
|
||||
- Release obsolete streamed Markdown parser history after each AI response completes, preventing chat memory from multiplying with every streamed chunk. (#544)
|
||||
- Open large documents faster by using cached world positions while finding layers under the pointer. (#527)
|
||||
- Coalesce writable-document autosaves that overlap an active `.fig` export while preserving a trailing save for newer edits. (#528)
|
||||
- Defer JSX generation and syntax highlighting until the Code panel is active, keeping large canvas selections responsive. (#500)
|
||||
- Index Figma clipboard children once during import instead of rescanning every pasted node, keeping large flat pastes linear. (#500)
|
||||
- Reduce peak memory during `.fig` export by sharing immutable binary resources with the isolated export graph.
|
||||
|
||||
|
||||
## 0.14.0 — 2026-08-10
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@
|
|||
"lint:structure": "oxlint -c oxlint.json vite.config.ts vite/ .storybook/ src/ packages/scene-graph/src/ packages/scene-graph/scripts/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ packages/harness/src/ packages/harness/tests/ packages/dom-css/src/ packages/dom-css/tests/ packages/dom-css/scripts/ packages/pen/src/ packages/pen/scripts/ packages/kiwi/src/ packages/kiwi/tests/ packages/kiwi/scripts/ packages/fig/src/ packages/fig/tests/ packages/fig/scripts/ tests/ scripts/ tools/",
|
||||
"format": "oxfmt --write .oxfmtrc.json vite.config.ts vite/ .storybook/ src/ packages/scene-graph/src/ packages/scene-graph/scripts/ packages/core/src/ packages/cli/src/ packages/mcp/src/ packages/harness/src/ packages/harness/tests/ packages/vue/src/ packages/dom-css/src/ packages/dom-css/tests/ packages/dom-css/scripts/ packages/pen/src/ packages/pen/scripts/ packages/kiwi/src/ packages/kiwi/tests/ packages/kiwi/scripts/ packages/fig/src/ packages/fig/tests/ packages/fig/scripts/ tests scripts/ tools/",
|
||||
"format:check": "bun run format && status=$(git status --porcelain -uall) && test -z \"$status\" || (echo \"$status\" && exit 1)",
|
||||
"check": "bun run build:packages && bun run lint && tsgo --noEmit && bun run check:vue && bun run check:native-test && bun run check:i18n && bun run check:docs && bun run check:packages && bun run check:deps && bun run check:audit && bun run check:secrets && bun run check:monorepo && bun run check:arch && bun run test:type-shapes && bun run test:tools && bun run test:dupes",
|
||||
"check": "bun run build:packages && bun run lint && tsgo --noEmit && bun run check:vue && bun run check:native-test && bun run check:i18n && bun run check:docs && bun run check:changelog && bun run check:packages && bun run check:deps && bun run check:audit && bun run check:secrets && bun run check:monorepo && bun run check:arch && bun run test:type-shapes && bun run test:tools && bun run test:dupes",
|
||||
"check:changelog": "bun tools/release-packages/src/check-changelog.ts",
|
||||
"check:deps": "knip --include unlisted,unresolved,binaries",
|
||||
"check:docs": "bun --filter @open-pencil/docs check",
|
||||
"check:audit": "bun audit --audit-level=critical",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check-changelog": "bun src/check-changelog.ts",
|
||||
"prepare-publish-dirs": "bun src/prepare-publish-dirs.ts",
|
||||
"validate-tarballs": "bun src/validate-tarballs.ts",
|
||||
"test": "bun test tests"
|
||||
|
|
|
|||
191
tools/release-packages/src/changelog.ts
Normal file
191
tools/release-packages/src/changelog.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
import type { Heading, List, ListItem, Root, RootContent } from 'mdast'
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
|
||||
const ALLOWED_CATEGORIES = [
|
||||
'Breaking changes',
|
||||
'Added',
|
||||
'Changed',
|
||||
'Fixed',
|
||||
'Performance',
|
||||
'Security'
|
||||
] as const
|
||||
|
||||
const CATEGORY_INDEX = new Map(ALLOWED_CATEGORIES.map((category, index) => [category, index]))
|
||||
const RELEASE_HEADING = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)? — (\d{4}-\d{2}-\d{2})$/
|
||||
|
||||
export interface ChangelogValidationResult {
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
interface PositionedNode {
|
||||
position?: { start: { line: number } }
|
||||
}
|
||||
|
||||
function headingText(heading: Heading): string {
|
||||
return heading.children
|
||||
.map((child) => ('value' in child && typeof child.value === 'string' ? child.value : ''))
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function line(node: PositionedNode): number {
|
||||
return node.position?.start.line ?? 0
|
||||
}
|
||||
|
||||
function listItemSource(source: string, item: ListItem): string {
|
||||
const start = item.position?.start.offset
|
||||
const end = item.position?.end.offset
|
||||
if (start === undefined || end === undefined) return ''
|
||||
return source.slice(start, end).trim().replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
function sectionEnd(children: RootContent[], start: number): number {
|
||||
for (let index = start + 1; index < children.length; index++) {
|
||||
const node = children[index]
|
||||
if (node?.type === 'heading' && node.depth === 2) return index
|
||||
}
|
||||
return children.length
|
||||
}
|
||||
|
||||
function categoryHasItems(children: RootContent[], start: number, end: number): boolean {
|
||||
for (let index = start + 1; index < end; index++) {
|
||||
const node = children[index]
|
||||
if (node?.type === 'heading' && node.depth <= 3) break
|
||||
if (node?.type === 'list' && !node.ordered && node.children.length > 0) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isValidDate(value: string): boolean {
|
||||
const [year, month, day] = value.split('-').map(Number)
|
||||
if (year === undefined || month === undefined || day === undefined) return false
|
||||
const date = new Date(Date.UTC(year, month - 1, day))
|
||||
return (
|
||||
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
||||
)
|
||||
}
|
||||
|
||||
function validateReleaseHeading(heading: Heading, errors: string[]): void {
|
||||
const text = headingText(heading)
|
||||
if (text === 'Unreleased') return
|
||||
const match = RELEASE_HEADING.exec(text)
|
||||
if (!match) {
|
||||
errors.push(
|
||||
`Line ${line(heading)}: invalid release heading "${text}"; expected "## x.y.z — YYYY-MM-DD".`
|
||||
)
|
||||
return
|
||||
}
|
||||
const date = match[1]
|
||||
if (date && !isValidDate(date)) {
|
||||
errors.push(`Line ${line(heading)}: invalid release date "${date}".`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateUnreleased(
|
||||
source: string,
|
||||
children: RootContent[],
|
||||
start: number,
|
||||
end: number,
|
||||
errors: string[]
|
||||
): void {
|
||||
const seenCategories = new Set<string>()
|
||||
const seenBullets = new Map<string, number>()
|
||||
let currentCategory: string | null = null
|
||||
let previousCategoryIndex = -1
|
||||
|
||||
for (let index = start + 1; index < end; index++) {
|
||||
const node = children[index]
|
||||
if (!node) continue
|
||||
|
||||
if (node.type === 'heading') {
|
||||
if (node.depth !== 3) {
|
||||
errors.push(
|
||||
`Line ${line(node)}: Unreleased content may only use level-three category headings.`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const category = headingText(node)
|
||||
const categoryIndex = CATEGORY_INDEX.get(category as (typeof ALLOWED_CATEGORIES)[number])
|
||||
if (categoryIndex === undefined) {
|
||||
currentCategory = null
|
||||
errors.push(
|
||||
`Line ${line(node)}: unsupported Unreleased category "${category}"; use ${ALLOWED_CATEGORIES.join(', ')}.`
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (seenCategories.has(category)) {
|
||||
errors.push(`Line ${line(node)}: duplicate Unreleased category "${category}".`)
|
||||
}
|
||||
if (categoryIndex < previousCategoryIndex) {
|
||||
errors.push(`Line ${line(node)}: Unreleased category "${category}" is out of order.`)
|
||||
}
|
||||
if (!categoryHasItems(children, index, end)) {
|
||||
errors.push(`Line ${line(node)}: Unreleased category "${category}" is empty.`)
|
||||
}
|
||||
seenCategories.add(category)
|
||||
currentCategory = category
|
||||
previousCategoryIndex = Math.max(previousCategoryIndex, categoryIndex)
|
||||
continue
|
||||
}
|
||||
|
||||
if (node.type === 'list' && !node.ordered) {
|
||||
if (currentCategory === null) {
|
||||
errors.push(
|
||||
`Line ${line(node)}: Unreleased bullets must follow an allowed category heading.`
|
||||
)
|
||||
}
|
||||
const list = node as List
|
||||
for (const item of list.children) {
|
||||
const bullet = listItemSource(source, item)
|
||||
if (!bullet) continue
|
||||
const firstLine = seenBullets.get(bullet)
|
||||
if (firstLine !== undefined) {
|
||||
errors.push(
|
||||
`Line ${line(item)}: duplicate Unreleased bullet (first seen on line ${firstLine}).`
|
||||
)
|
||||
} else {
|
||||
seenBullets.set(bullet, line(item))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
errors.push(`Line ${line(node)}: unexpected content in Unreleased.`)
|
||||
}
|
||||
}
|
||||
|
||||
export function validateChangelog(source: string): ChangelogValidationResult {
|
||||
const tree = fromMarkdown(source) as Root
|
||||
const errors: string[] = []
|
||||
const releaseHeadings: Array<{ index: number; node: Heading }> = []
|
||||
for (const [index, node] of tree.children.entries()) {
|
||||
if (node.type === 'heading' && node.depth === 2) releaseHeadings.push({ index, node })
|
||||
}
|
||||
|
||||
const unreleased = releaseHeadings.filter(({ node }) => headingText(node) === 'Unreleased')
|
||||
if (unreleased.length !== 1) {
|
||||
errors.push(`Expected exactly one "## Unreleased" section; found ${unreleased.length}.`)
|
||||
}
|
||||
|
||||
for (const { node } of releaseHeadings) validateReleaseHeading(node, errors)
|
||||
|
||||
const current = unreleased[0]
|
||||
if (current) {
|
||||
validateUnreleased(
|
||||
source,
|
||||
tree.children,
|
||||
current.index,
|
||||
sectionEnd(tree.children, current.index),
|
||||
errors
|
||||
)
|
||||
}
|
||||
|
||||
return { errors }
|
||||
}
|
||||
|
||||
export async function validateChangelogFile(path: string): Promise<ChangelogValidationResult> {
|
||||
return validateChangelog(await readFile(path, 'utf8'))
|
||||
}
|
||||
15
tools/release-packages/src/check-changelog.ts
Normal file
15
tools/release-packages/src/check-changelog.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { resolve } from 'node:path'
|
||||
|
||||
import { validateChangelogFile } from './changelog'
|
||||
|
||||
const path = resolve(process.argv[2] ?? 'CHANGELOG.md')
|
||||
const { errors } = await validateChangelogFile(path)
|
||||
|
||||
if (errors.length > 0) {
|
||||
process.stderr.write(
|
||||
`Changelog validation failed:\n${errors.map((error) => `- ${error}`).join('\n')}\n`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.stdout.write(`Changelog validation passed: ${path}\n`)
|
||||
146
tools/release-packages/tests/changelog.test.ts
Normal file
146
tools/release-packages/tests/changelog.test.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { validateChangelog } from '../src/changelog'
|
||||
|
||||
const validChangelog = `# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- Replace the old API.
|
||||
|
||||
### Added
|
||||
|
||||
- Add **rich** release notes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Preserve user data.
|
||||
|
||||
## 1.2.0 — 2026-08-16
|
||||
|
||||
### Added
|
||||
|
||||
- Ship the previous feature.
|
||||
|
||||
## 0.1.0-alpha — 2026-03-01
|
||||
|
||||
### Added
|
||||
|
||||
- Publish the alpha.
|
||||
`
|
||||
|
||||
function errors(source: string): string[] {
|
||||
return validateChangelog(source).errors
|
||||
}
|
||||
|
||||
describe('validateChangelog', () => {
|
||||
test('accepts canonical Unreleased and release sections', () => {
|
||||
expect(errors(validChangelog)).toEqual([])
|
||||
})
|
||||
|
||||
test('requires exactly one Unreleased section', () => {
|
||||
expect(errors(validChangelog.replace('## Unreleased', '## 1.3.0 — 2026-09-01'))).toContain(
|
||||
'Expected exactly one "## Unreleased" section; found 0.'
|
||||
)
|
||||
expect(errors(`${validChangelog}\n## Unreleased\n`)).toContain(
|
||||
'Expected exactly one "## Unreleased" section; found 2.'
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects duplicate, unsupported, empty, and out-of-order categories', () => {
|
||||
const source = `# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix one issue.
|
||||
|
||||
### Added
|
||||
|
||||
- Add one feature.
|
||||
|
||||
### Added
|
||||
|
||||
### Internal
|
||||
|
||||
- Refactor internals.
|
||||
|
||||
## 1.0.0 — 2026-01-01
|
||||
|
||||
### Added
|
||||
|
||||
- Ship version one.
|
||||
`
|
||||
const result = errors(source)
|
||||
expect(result.some((error) => error.includes('category "Added" is out of order'))).toBe(true)
|
||||
expect(result.some((error) => error.includes('duplicate Unreleased category "Added"'))).toBe(
|
||||
true
|
||||
)
|
||||
expect(result.some((error) => error.includes('category "Added" is empty'))).toBe(true)
|
||||
expect(
|
||||
result.some((error) => error.includes('unsupported Unreleased category "Internal"'))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects duplicate Unreleased bullets while preserving Markdown distinctions', () => {
|
||||
const source = `# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Add **one** feature.
|
||||
- Add **one** feature.
|
||||
- Add one feature.
|
||||
|
||||
## 1.0.0 — 2026-01-01
|
||||
|
||||
### Added
|
||||
|
||||
- Add **one** feature.
|
||||
`
|
||||
const result = errors(source)
|
||||
expect(result.filter((error) => error.includes('duplicate Unreleased bullet'))).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('rejects malformed release headings and impossible dates', () => {
|
||||
const malformed = validChangelog.replace('## 1.2.0 — 2026-08-16', '## v1.2.0 - August 16, 2026')
|
||||
expect(errors(malformed).some((error) => error.includes('invalid release heading'))).toBe(true)
|
||||
|
||||
const impossible = validChangelog.replace('2026-08-16', '2026-02-31')
|
||||
expect(errors(impossible).some((error) => error.includes('invalid release date'))).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects nested headings and uncategorized content inside Unreleased', () => {
|
||||
const nested = validChangelog.replace(
|
||||
'- Add **rich** release notes.',
|
||||
'#### Details\n\n- Add **rich** release notes.'
|
||||
)
|
||||
expect(
|
||||
errors(nested).some((error) =>
|
||||
error.includes('Unreleased content may only use level-three category headings')
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
const uncategorized = validChangelog.replace(
|
||||
'## Unreleased\n\n### Breaking changes',
|
||||
'## Unreleased\n\n- Uncategorized.\n\n### Breaking changes'
|
||||
)
|
||||
expect(
|
||||
errors(uncategorized).some((error) =>
|
||||
error.includes('Unreleased bullets must follow an allowed category heading')
|
||||
)
|
||||
).toBe(true)
|
||||
|
||||
const prose = validChangelog.replace(
|
||||
'## Unreleased\n\n### Breaking changes',
|
||||
'## Unreleased\n\nUnexpected prose.\n\n### Breaking changes'
|
||||
)
|
||||
expect(errors(prose).some((error) => error.includes('unexpected content in Unreleased'))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue