diff --git a/.changeset/blocks-registry.md b/.changeset/blocks-registry.md new file mode 100644 index 00000000..2220cc65 --- /dev/null +++ b/.changeset/blocks-registry.md @@ -0,0 +1,5 @@ +--- +'@sanring/cli': minor +--- + +Add a `blocks` registry category so `sanring add block/login` can install page-level templates, and accept `github:owner/repo` as a registry source. diff --git a/.changeset/dialog-header-field-textarea.md b/.changeset/dialog-header-field-textarea.md new file mode 100644 index 00000000..14ece0c2 --- /dev/null +++ b/.changeset/dialog-header-field-textarea.md @@ -0,0 +1,5 @@ +--- +'@sanring/cli': patch +--- + +Let dialog headers opt into start/center alignment, and project textareas into the field control slot so character-count descriptions sit below the control. diff --git a/DEVLOG.md b/DEVLOG.md index 6509ceb2..4b47bfab 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -953,3 +953,36 @@ P30 先前已收完 15 個必修缺口;這輪把剩下 15 組建議項目逐 **測試抓到並修正的真實發布缺陷**:第一次走到 production build 時,乾淨專案無法 resolve `@angular/cdk/a11y`。根因是所有使用 `shared/utils.ts` 的元件都會間接需要 CDK(`uniqueId()` 使用 `_IdGenerator`),但 `registry.json` 的 `utils.peerDependencies` 只列 `clsx`/`tailwind-merge`;repo 與既有 mock tests 本身早已有 CDK,所以缺口一直被遮住。已補 `@angular/cdk: ^22.0.0`,因此 `sanring add button` 現在會自動安裝它。進一步把 `build.test.ts` golden fixture 從只比較 52 個 component metadata 擴大到所有 shared entries 後,又抓出 `collection-controller.ts` 同樣直接 import CDK 卻沒宣告,一併校正;新的 shared peer-dependency comparison 會阻止這一類 drift 再發生。CLI patch changeset 已加入。 **驗證**:本機 fresh run 實際安裝 Angular 22.1.3/CLI 22.1.5,完整 `packed CLI → npm install → sanring init → sanring add button → import installed source → production ng build` 連續通過,production bundle 142.33 kB。`build.test.ts` targeted golden fixture **11/11 passed**,shared/component metadata 零已知落差;完整 CLI suite **19 files / 240 tests passed**。repo 全域 lint、CLI main/schematics TypeScript、registry sync/parity、Changesets status、CI YAML 與 `git diff --check` 皆通過。 + +--- + +## P19 — Blocks 起手三個(`dashboard-shell` / `login` / `table-page`) + +- [x] `registry/blocks/` + `registry.json` `blocks[]` + CLI `block/` prefix +- [x] 起手三個:`dashboard-shell`(shell)、`login`(page)、`table-page`(page) +- [x] Docs `/blocks` 頁與 `sanring add block/login` 說明 + +**已完成**:blocks 是獨立的 registry 類別,不進 `packages/ui`(否則會被 8-way `check-registry-sync` 當正式元件抓漏)。名稱扁平(`dashboard-shell` / `login` / `table-page`),CLI 用 `block/` prefix 跟 alias 的 `:` 分開。安裝後落到跟元件一樣的 `src/app/components/ui//`,互相用 `../card` 這種相對路徑。docs preview 用 `@sanring/ui` 重畫,不能直接 compile registry blocks(因為 `../card` 對不到 `registry/components/card`)。 + +**踩過的坑**:`login` 不能同時 import `FieldLabelDirective` 與獨立的 `LabelDirective`(同一個 selector)。`add.test.ts` 的 commander singleton 會把前一個測試的 `--dry-run` 留到下一個,block 安裝看起來成功但其實沒寫檔——`beforeEach` 現在會重設 `dryRun`/`check`/`diff`/`view`/`force`/`yes`/`registry`。 + +**未做**:其餘六個 page block 仍在 TODOLIST。 + +--- + +## P22 — Docs component 頁面 Open in StackBlitz + +- [x] 每個 component 頁面的 code previewer 旁加「Open in StackBlitz」 + +**已完成**:共用 `ComponentPageCodePreviewer` 在 URL 是 `/components/:id` 時顯示按鈕,runtime fetch `/registry/registry.json` 與對應 source(`angular.json` 把 `registry/` 當 docs assets),再用 `@stackblitz/sdk` 的 `sdk.openProject({ template: 'node' })` 開一份最小 Angular 22 + Tailwind 專案。不用 EngineBlock 的 `angular-cli` template,那個太舊。原本規劃的 `generate:stackblitz-registry` 預產生腳本沒寫也沒需要,已從 `package.json` 拿掉。 + +**刻意不做**:docs 頁內嵌可編輯 editor(跟 shadcn 一樣打平);blocks 頁的 previewer 不顯示按鈕(URL 不是 `/components/:id`)。 + +--- + +## P25 — GitHub Registries + registry.json API Reference + +- [x] CLI `github:owner/repo`(`#ref` / `@ref`)展開成 raw `registry.json` +- [x] Docs Registry 頁補完整欄位定義(型別、必要/選用) + +**已完成**:`expandGithubRegistrySource` 在 `fetchRegistry` / `fetchFile` 進路徑判斷前先 normalize,避免 `github:` 被當成本地路徑。Docs 加了 GitHub 範例、root / item / shared / group / migration 五張 API 表。Directory、namespaces、auth、search API、docs 多頁拆分仍在 TODOLIST,這輪不做。 diff --git a/ROADMAP.md b/ROADMAP.md index f92ea2b4..fdceebb3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,18 +6,20 @@ This is a snapshot, not a commitment or a timeline. Items move, get reprioritize ## Adoption experience -- **Blocks** — installable page-level templates (login page, dashboard shell, settings page) via `sanring add block/dashboard-shell`, so you're not always assembling pages from individual components. This is the biggest adoption-experience gap compared to shadcn today. -- **Interactive theme builder** — a live color/radius preview on the docs site with copy-to-clipboard CSS. (The named starting points this was paired with — Slate, Warm, High-Contrast — already shipped via `sanring init --theme `.) -- **Try without installing** — an "Open in StackBlitz" shortcut on each component's docs page. +- **More blocks** — remaining page templates (`register`, `forgot-password`, `settings-page`, `detail-page`, `wizard`, `pricing-page`) on top of the starter three. ## Ecosystem / team use - **Registry Directory** — a docs page listing community/third-party registries, so teams can discover each other's component sets. -- **GitHub registries** — point the CLI at `github:/` directly, without hosting a raw `registry.json` yourself. - **Private registry authentication** — Bearer-token support for company-internal or private-repo registries. ## Recently shipped +- Blocks starter set — `sanring add block/login` (and `dashboard-shell`, `table-page`) installs a page-level template plus its component dependencies +- Open in StackBlitz — each component docs previewer can open a minimal Angular 22 + Tailwind project with that example +- GitHub registries — `--registry github:owner/repo` (optional `#ref` / `@ref`) expands to the raw `registry.json` at the repo root +- `registry.json` API reference — field, type, and required/optional docs on the Registry page +- Interactive theme builder — a live color/radius preview on the docs site with copy-to-clipboard CSS - Packaged CLI end-to-end quality gate — CI installs the local tarball into a freshly scaffolded Angular app, runs `sanring init` and `sanring add button`, imports the installed source, and requires a successful production build diff --git a/TODOLIST.md b/TODOLIST.md index 5959e76e..471358a1 100644 --- a/TODOLIST.md +++ b/TODOLIST.md @@ -6,36 +6,22 @@ --- -## P19 — Blocks:可直接安裝的頁面級組合模板 +## P19 — Blocks:其餘六個頁面模板 -- [ ] 設計 `blocks/` registry 類別,讓 `sanring add block/dashboard-shell` 可以一次安裝完整頁面片段(login page、settings page、data table page、dashboard layout 等) +起手三個(`dashboard-shell`、`login`、`table-page`)已出貨,見 [DEVLOG.md](DEVLOG.md)。剩下是覆蓋率,不是機制。 -**現況**:目前 registry 只有 `components/` 和 `shared/`,沒有 blocks 概念。使用者必須自己把元件組裝成頁面。 +- [ ] `register`、`forgot-password`、`settings-page`、`detail-page`、`wizard`、`pricing-page` -**影響**:這是目前與 shadcn 最大的採用體驗差距。開發者的採用決策通常不是「這個 Button 好不好」,而是「我能不能 30 分鐘內搭出一個看起來像樣的登入頁」。Blocks 直接回答這個問題。shadcn blocks 是近兩年對採用率貢獻最大的功能之一。 +**頁面類型與元件組合**(2026-08-22 盤點): -**實作方向**: - -- `registry/blocks/` 目錄,每個 block 是一個 Angular component(可含多個 child component) -- `registry.json` 加入 `blocks` 陣列(類似 `components`),每筆有 `name`、`description`、`componentDeps`、`files` -- CLI 的 `add` 指令識別 `block/` prefix,路由到 blocks registry -- Block 分兩類,架構不同:**shell**(包住整個 app 的持久性 chrome,例如 `layout/dashboard-shell`,包一個 ``/router-outlet,不是「一頁內容」)與 **page**(一頁完整內容組合,裝進某個 route) - -**頁面類型與元件組合**(2026-08-22 盤點,已對照現有 52 個 component 逐一核對,全部可用現有元件組成,沒有缺元件擋路,不必先補元件才能動工): - -- `layout/dashboard-shell`(shell):`sidebar` + `dropdown-menu`(user menu)+ `avatar` + `breadcrumb` + `badge` -- `auth/login`(page):`card` + `field` + `input` + `label` + `button` + `checkbox`(remember me)+ `link`(forgot password)+ `divider`(or)+ `alert`(錯誤訊息) - `auth/register`(page):同 login + `select`(可選:角色/國家) - `auth/forgot-password`(page):`card` + `field` + `input` + `button` + `alert` - `layout/settings-page`(page):`tabs`(分區)+ `field` + `input` + `avatar`(頭像上傳)+ `switch` + `select` + `divider` + `button` + `alert-dialog`(刪除確認) -- `data/table-page`(page):`table` + `pagination` + `input`(搜尋)+ `select`(篩選)+ `dropdown-menu`(列操作)+ `checkbox`(批次選取)+ `badge`(狀態)+ `sheet`(新增/編輯抽屜)+ `skeleton` + `toast` - `content/detail-page`(page):`card` + `avatar` + `badge` + `tabs` + `breadcrumb` + `timeline` + `tag` - `form/wizard`(page):`stepper` + `field` + `input` + `select` + `date-picker` + `radio` + `file-upload` + `button` + `progress` - `billing/pricing-page`(page):`card` + `badge` + `table` + `toggle`(月/年切換)+ `button` + `tag` -**起手三個**(驗證 CLI 機制,而非追求覆蓋率):`layout/dashboard-shell`(驗證 shell 型 block 的安裝機制跟一般 component block 不同)、`auth/login`(元件最單純,驗證 `blocks/` 目錄結構/`registry.json` blocks schema/CLI routing 三件事都跑得通)、`data/table-page`(驗證單一 block 內多個子元件互相協作的複雜組合)。其餘六個(`register`、`forgot-password`、`settings-page`、`detail-page`、`wizard`、`pricing-page`)待前三個跑通機制後再逐步擴充。 - -**成本**:高。單個 block 的設計/實作本身不難,但要做出夠多、夠有代表性的 blocks 讓功能有意義,需要持續投入。先做上述 3 個驗證 CLI 流程,再逐步擴充其餘 6 個。 +**成本**:中。CLI `block/` prefix、`registry.json` `blocks[]`、docs `/blocks` 頁都已就位,剩下是逐個組裝。 --- @@ -46,27 +32,13 @@ **子項目(依實作順序)**: - [ ] **Registry Directory**:Docs 站新增第三方 registry 目錄頁,列出社群維護的 registry(類似 shadcn 的 Registry Directory);初期可由人工審核提交 -- [ ] **GitHub Registries**:CLI 支援直接以 `github:/` 格式作為 registry source,省去 host 步驟(背後解析為 `https://raw.githubusercontent.com///main/registry.json`) - [ ] **Namespaces**:解決多 registry 同名元件衝突,定義 namespace 規則(目前 alias 機制已部分解決,需形式化) - [ ] **Authentication**:CLI 支援 private registry 的 Bearer token 認證(`sanring.config.json` 加入 `auth` 欄位),讓企業內網或 private GitHub repo 可用 - [ ] **Dynamic Search API**:registry 可選擇暴露搜尋 endpoint(而非只靠靜態 JSON 全量掃描),`sanring search` 優先呼叫 endpoint -- [ ] **API Reference 頁面**:補 `registry.json` 完整 schema 文件(目前 docs registry 頁只有範例,缺欄位定義、型別、必要/選用標記) - [ ] **Docs 多頁拆分**:registry 頁面從目前的單頁拆成多頁(Introduction、Getting Started、GitHub Registries、Authentication、API Reference 等) -**現況**:目前 registry 頁只有一頁,涵蓋基本的 `sanring build` 工作流程與 `registries` config 設定。CLI 的 multi-registry 支援(alias:name 語法)已完成,但生態系的其餘部分(directory、GitHub source、auth)尚未實作。 +**現況**:`github:/` source 與 `registry.json` API Reference 已出貨,見 [DEVLOG.md](DEVLOG.md)。CLI 的 multi-registry 支援(alias:name 語法)與 GitHub source 可用。生態系其餘部分(directory、namespaces、auth、search API、docs 多頁拆分)尚未實作。 **影響**:shadcn 的 registry 生態是目前採用率的核心驅動之一——開發者能找到、安裝、分享社群元件,讓整個 UI library 不只靠官方維護。Angular 生態目前沒有等價物,這是 Sanring 差異化的機會。 -**成本**:高。各子項目可獨立交付,建議從 GitHub Registries(低實作成本、高使用者價值)和 API Reference 開始,再推進 Directory 和 Auth。 - ---- - -## P22 — Docs component 頁面加入 StackBlitz 快捷連結 - -- [ ] 每個 component 頁面的 code previewer 旁加一個「Open in StackBlitz」按鈕,讓使用者不用本地安裝就能試用 - -**現況**:Docs 的 code previewer 是靜態展示,使用者若想動手試要先本地建好 Angular 專案並跑完 `sanring init` + `sanring add`。 - -**差異**:這裡的目標是「一鍵開啟含有該元件的最小 Angular 專案」,而非在 docs 頁面內嵌入可編輯 editor(已確認 shadcn 自己的 docs 也不這樣做,兩邊打平)。StackBlitz 支援從 URL params 或 POST 預填專案內容,可以把 component 程式碼預先注入。 - -**成本**:中。StackBlitz SDK 有 `sdk.openProject()` API,需要為每個元件準備一份最小化的 Angular 專案 template + 注入對應的元件程式碼。可以先做成通用 template,再逐元件補範例程式碼。 +**成本**:高。各子項目可獨立交付。GitHub Registries 與 API Reference 已出貨;下一步是 Directory 和 Auth。 diff --git a/angular.json b/angular.json index 1e50f498..cc113d6c 100644 --- a/angular.json +++ b/angular.json @@ -77,6 +77,11 @@ "glob": "CHANGELOG.md", "input": "packages/cli", "output": "/data/cli-changelog" + }, + { + "glob": "**/*", + "input": "registry", + "output": "/registry" } ], "styles": ["apps/docs/src/styles.css"] diff --git a/apps/docs/src/app/app.routes.ts b/apps/docs/src/app/app.routes.ts index 11018194..6c0da791 100644 --- a/apps/docs/src/app/app.routes.ts +++ b/apps/docs/src/app/app.routes.ts @@ -44,6 +44,11 @@ export const routes: Routes = [ (m) => m.RegistryPageComponent, ), }, + { + path: 'blocks', + loadComponent: () => + import('./pages/blocks/blocks-page.component').then((m) => m.BlocksPageComponent), + }, { path: 'changelog', redirectTo: 'version-notes', diff --git a/apps/docs/src/app/i18n/locales/en/common.ts b/apps/docs/src/app/i18n/locales/en/common.ts index ba78cf6c..ef266622 100644 --- a/apps/docs/src/app/i18n/locales/en/common.ts +++ b/apps/docs/src/app/i18n/locales/en/common.ts @@ -16,6 +16,8 @@ export const commonTranslations = { 'actions.copyPageOptions': 'Copy page options', 'actions.viewAsMarkdown': 'View as Markdown', 'actions.openFailed': 'Open failed', + 'actions.openInStackBlitz': 'Open in StackBlitz', + 'actions.openingStackBlitz': 'Opening…', 'actions.clearSearch': 'Clear search', 'actions.previousPage': 'Previous page', 'actions.nextPage': 'Next page', @@ -37,6 +39,7 @@ export const commonTranslations = { 'sidebar.skills': 'Skills', 'sidebar.mcpServer': 'MCP Server', 'sidebar.registry': 'Registry', + 'sidebar.blocks': 'Blocks', 'sidebar.forms': 'Forms', 'sidebar.changelog': 'Version Notes', 'sidebar.roadmap': 'Roadmap', diff --git a/apps/docs/src/app/i18n/locales/en/components/dialog.ts b/apps/docs/src/app/i18n/locales/en/components/dialog.ts index 1df6edfa..5578f571 100644 --- a/apps/docs/src/app/i18n/locales/en/components/dialog.ts +++ b/apps/docs/src/app/i18n/locales/en/components/dialog.ts @@ -8,6 +8,7 @@ export const dialogTranslations = { 'dialog.demo.noClose': 'No Close Button', 'dialog.demo.stickyFooter': 'Sticky Footer', 'dialog.demo.scrollable': 'Scrollable Content', + 'dialog.demo.header': 'Header alignment', 'dialog.examples.description': 'Common dialog patterns for custom actions, hidden close controls, sticky actions, and dense scrollable content.', 'dialog.examples.basic.description': @@ -32,6 +33,10 @@ export const dialogTranslations = { 'dialog.api.closeResult.description': 'Optional result value emitted when sanringDialogClose closes the dialog.', 'dialog.api.mediaClass.description': 'Additional classes merged with the dialog media container.', + 'dialog.api.headerAlign.description': + 'Header text alignment. start is left, center is centered at every breakpoint. The default stays centered on small screens and left-aligned from sm up.', + 'dialog.api.titleClass.description': + 'Additional classes merged with the title styles. Use this to change title color, for example text-[var(--sanring-primary-70)].', 'dialog.accessibility.description': "The CDK Dialog container receives role='dialog' and aria-modal='true'. Projected titles and descriptions are wired automatically; ariaLabel provides a fallback name for untitled content. Angular CDK's FocusTrap keeps Tab and Shift+Tab cycling within the open dialog.", 'dialog.keyboard.description': 'Focus is trapped inside the dialog while it is open.', diff --git a/apps/docs/src/app/i18n/locales/en/components/input.ts b/apps/docs/src/app/i18n/locales/en/components/input.ts index 705aab82..8a7f0f12 100644 --- a/apps/docs/src/app/i18n/locales/en/components/input.ts +++ b/apps/docs/src/app/i18n/locales/en/components/input.ts @@ -4,6 +4,7 @@ export const inputTranslations = { 'input.demo.disabled': 'Disabled', 'input.demo.file': 'File', 'input.demo.validation': 'Validation state', + 'input.demo.characterCount': 'Character count', 'input.examples.description': 'Common input patterns for editable text, disabled fields, and file uploads.', 'input.examples.basic.description': diff --git a/apps/docs/src/app/i18n/locales/en/components/textarea.ts b/apps/docs/src/app/i18n/locales/en/components/textarea.ts index 467d1381..38ec9414 100644 --- a/apps/docs/src/app/i18n/locales/en/components/textarea.ts +++ b/apps/docs/src/app/i18n/locales/en/components/textarea.ts @@ -8,6 +8,7 @@ export const textareaTranslations = { 'Use sanringTextarea on native textarea elements and keep value, disabled, rows, and form bindings native.', 'textarea.demo.disabled': 'Disabled', 'textarea.demo.resize': 'Resizable', + 'textarea.demo.characterCount': 'Character count', 'textarea.api.description': 'Inputs supported by the sanringTextarea directive.', 'textarea.api.class.description': 'Additional classes merged with the base textarea styles.', 'textarea.accessibility.description': 'A transparent styling directive that preserves native +

+ {{ bio.length }}/500 +

+ + + + @@ -106,6 +134,7 @@ export class TextareaPageComponent { protected readonly page = textareaPage; protected readonly examples = textareaPageExamples; protected readonly i18n = inject(I18nService); + protected bio = 'Sanring UI demo text'; protected section(id: string) { return getComponentPageSection(this.page, id); diff --git a/apps/docs/src/app/pages/components/textarea/textarea.docs.ts b/apps/docs/src/app/pages/components/textarea/textarea.docs.ts index e0206fee..2cda738b 100644 --- a/apps/docs/src/app/pages/components/textarea/textarea.docs.ts +++ b/apps/docs/src/app/pages/components/textarea/textarea.docs.ts @@ -44,6 +44,11 @@ export const textareaPage = { titleKey: 'textarea.demo.resize', level: 3, }, + { + id: 'example-character-count', + titleKey: 'textarea.demo.characterCount', + level: 3, + }, ], }, { @@ -90,4 +95,11 @@ export const textareaPageExamples = { usageMain: ``, disabled: ``, resize: ``, + characterCount: ` + + +

+ {{ bio.length }}/500 +

+
`, } as const; diff --git a/apps/docs/src/app/pages/home/home-hero-section.component.ts b/apps/docs/src/app/pages/home/home-hero-section.component.ts index 144bd62f..9b696308 100644 --- a/apps/docs/src/app/pages/home/home-hero-section.component.ts +++ b/apps/docs/src/app/pages/home/home-hero-section.component.ts @@ -3,6 +3,7 @@ import { RouterLink } from '@angular/router'; import { LucideChevronRight, LucideRocket } from '@lucide/angular'; import { ButtonDirective } from '@sanring/ui'; import { I18nService } from '../../i18n/i18n.service'; +import { cliVersionChangelog } from '../changelog/component-changelog'; import { HomeHeroDemoPanelComponent } from './home-hero-demo-panel.component'; @Component({ @@ -76,5 +77,5 @@ import { HomeHeroDemoPanelComponent } from './home-hero-demo-panel.component'; }) export class HomeHeroSectionComponent { protected readonly i18n = inject(I18nService); - protected readonly releaseVersion = 'v0.24.0'; + protected readonly releaseVersion = `v${cliVersionChangelog[0].version}`; } diff --git a/apps/docs/src/app/pages/registry/registry-page.component.ts b/apps/docs/src/app/pages/registry/registry-page.component.ts index 26c87b3c..c7c0b52c 100644 --- a/apps/docs/src/app/pages/registry/registry-page.component.ts +++ b/apps/docs/src/app/pages/registry/registry-page.component.ts @@ -1,8 +1,12 @@ import { Component, effect, inject } from '@angular/core'; -import { ComponentPageSectionDefinition } from '../../docs-schema/component-page.types'; +import { + ComponentPageApiRow, + ComponentPageSectionDefinition, +} from '../../docs-schema/component-page.types'; import { I18nService } from '../../i18n/i18n.service'; import { SeoService } from '../../seo/seo.service'; import { + ComponentPageApiTableComponent, ComponentPageCodeBlock, ComponentPageComponent, ComponentPageSectionComponent, @@ -15,6 +19,7 @@ const INLINE_CODE_CLASS = @Component({ selector: 'app-registry-page', imports: [ + ComponentPageApiTableComponent, ComponentPageCodeBlock, ComponentPageComponent, ComponentPageSectionComponent, @@ -31,7 +36,7 @@ const INLINE_CODE_CLASS =

REGISTRY MODEL

One schema, three dependency layers.

registry.json
-
registry.json
├─ components[]
├─ files[]
├─ componentDeps[]
└─ sharedDeps[]
└─ shared[]
+
registry.json
├─ components[]
├─ files[]
├─ componentDeps[]
└─ sharedDeps[]
├─ blocks[]
└─ shared[]

SOURCE GRAPH

resolved locally

component

button.ts
dialog.ts

shared

utils.ts
sharedDeps[]
→

block

account-settings surface

the app-owned composition

@@ -51,16 +56,43 @@ const INLINE_CODE_CLASS = - + +

+ {{ i18n.t('registry.api.body') }} +

+

+ {{ i18n.t('registry.api.root.heading') }} +

+ +

+ {{ i18n.t('registry.api.item.heading') }} +

+ +

+ {{ i18n.t('registry.api.sharedItem.heading') }} +

+ +

+ {{ i18n.t('registry.api.group.heading') }} +

+ +

+ {{ i18n.t('registry.api.migration.heading') }} +

+ +
+ + +

{{ i18n.t('registry.structure.body') }}

- - + +

{{ i18n.t('registry.build.body') }}

@@ -87,16 +119,25 @@ const INLINE_CODE_CLASS =
- - + +

{{ i18n.t('registry.hosting.body') }}

- - + + +

+ {{ i18n.t('registry.github.body') }} +

+ + +
+ + +

{{ i18n.t('registry.consuming.body') }}

@@ -123,14 +164,61 @@ export class RegistryPageComponent { protected readonly sections: readonly ComponentPageSectionDefinition[] = [ { id: 'overview', titleKey: 'registry.overview.title' }, { id: 'schema', titleKey: 'registry.schema.title' }, + { id: 'api', titleKey: 'registry.api.title' }, { id: 'structure', titleKey: 'registry.structure.title' }, { id: 'build', titleKey: 'registry.build.title' }, { id: 'hosting', titleKey: 'registry.hosting.title' }, + { id: 'github', titleKey: 'registry.github.title' }, { id: 'consuming', titleKey: 'registry.consuming.title' }, ]; + protected readonly api: { + root: readonly ComponentPageApiRow[]; + item: readonly ComponentPageApiRow[]; + shared: readonly ComponentPageApiRow[]; + group: readonly ComponentPageApiRow[]; + migration: readonly ComponentPageApiRow[]; + } = { + root: [ + { property: 'name', type: 'string', defaultValue: '—', descriptionKey: 'registry.api.root.name' }, + { property: 'shared', type: 'Shared[]', defaultValue: '—', descriptionKey: 'registry.api.root.shared' }, + { property: 'components', type: 'Item[]', defaultValue: '—', descriptionKey: 'registry.api.root.components' }, + { property: 'blocks', type: 'Item[]', defaultValue: '—', descriptionKey: 'registry.api.root.blocks' }, + { property: 'groups', type: 'Group[]', defaultValue: '—', descriptionKey: 'registry.api.root.groups' }, + ], + item: [ + { property: 'name', type: 'string', defaultValue: '—', descriptionKey: 'registry.api.item.name' }, + { property: 'description', type: 'string', defaultValue: '—', descriptionKey: 'registry.api.item.description' }, + { property: 'files', type: 'string[]', defaultValue: '—', descriptionKey: 'registry.api.item.files' }, + { property: 'componentDeps', type: 'string[]', defaultValue: '[]', descriptionKey: 'registry.api.item.componentDeps' }, + { property: 'sharedDeps', type: 'string[]', defaultValue: '[]', descriptionKey: 'registry.api.item.sharedDeps' }, + { property: 'peerDependencies', type: 'Record', defaultValue: '{}', descriptionKey: 'registry.api.item.peerDependencies' }, + { property: 'since', type: 'string', defaultValue: '—', descriptionKey: 'registry.api.item.since' }, + { property: 'tags', type: 'string[]', defaultValue: '[]', descriptionKey: 'registry.api.item.tags' }, + { property: 'migrations', type: 'Migration[]', defaultValue: '[]', descriptionKey: 'registry.api.item.migrations' }, + ], + shared: [ + { property: 'name', type: 'string', defaultValue: '—', descriptionKey: 'registry.api.shared.name' }, + { property: 'description', type: 'string', defaultValue: '—', descriptionKey: 'registry.api.shared.description' }, + { property: 'file', type: 'string', defaultValue: '—', descriptionKey: 'registry.api.shared.file' }, + { property: 'peerDependencies', type: 'Record', defaultValue: '{}', descriptionKey: 'registry.api.shared.peerDependencies' }, + ], + group: [ + { property: 'id', type: 'string', defaultValue: '—', descriptionKey: 'registry.api.group.id' }, + { property: 'title', type: 'string', defaultValue: '—', descriptionKey: 'registry.api.group.title' }, + { property: 'description', type: 'string', defaultValue: '—', descriptionKey: 'registry.api.group.description' }, + { property: 'components', type: 'string[]', defaultValue: '—', descriptionKey: 'registry.api.group.components' }, + ], + migration: [ + { property: 'fromVersion', type: 'string', defaultValue: '—', descriptionKey: 'registry.api.migration.fromVersion' }, + { property: 'breaking', type: 'boolean', defaultValue: '—', descriptionKey: 'registry.api.migration.breaking' }, + { property: 'steps', type: 'string[]', defaultValue: '—', descriptionKey: 'registry.api.migration.steps' }, + ], + }; + protected readonly examples = { schema: `{ + "name": "acme", "components": [ { "name": "my-button", @@ -147,6 +235,15 @@ export class RegistryPageComponent { } } ], + "blocks": [ + { + "name": "login", + "description": "Sign-in page", + "files": ["login/login.ts"], + "componentDeps": ["card", "field", "input"], + "sharedDeps": ["utils"] + } + ], "shared": [ { "name": "utils", @@ -181,9 +278,21 @@ npx serve . # or point the CLI directly at a local path — no server needed npx @sanring/cli@latest add mylib:my-button --registry ./registry.json`, + github: `# one-off install from a public GitHub repo +npx @sanring/cli@latest add button --registry github:acme/ui + +# pin a branch, tag, or commit +npx @sanring/cli@latest add button --registry github:acme/ui#v1.2.0 +npx @sanring/cli@latest add button --registry github:acme/ui@release`, + githubConfig: `{ + "registries": { + "acme": "github:acme/ui" + } +}`, config: `{ "registries": { - "mylib": "https://your-org.github.io/my-lib/registry.json" + "mylib": "https://your-org.github.io/my-lib/registry.json", + "acme": "github:acme/ui" } }`, consuming: `# install a component from your registry diff --git a/apps/docs/src/app/pages/roadmap/roadmap-page.component.ts b/apps/docs/src/app/pages/roadmap/roadmap-page.component.ts index c31d6468..ea3569aa 100644 --- a/apps/docs/src/app/pages/roadmap/roadmap-page.component.ts +++ b/apps/docs/src/app/pages/roadmap/roadmap-page.component.ts @@ -2,6 +2,7 @@ import { Component, effect, inject } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; import { ComponentPageSectionDefinition } from '../../docs-schema/component-page.types'; import { I18nService } from '../../i18n/i18n.service'; +import { cliVersionChangelog } from '../changelog/component-changelog'; import { SeoService } from '../../seo/seo.service'; import { ComponentPageComponent, @@ -89,7 +90,7 @@ interface RoadmapComponentRow { />
-

DELIVERY MAP

What is shipped, next, and forming.

v0.24.0
+

DELIVERY MAP

What is shipped, next, and forming.

{{ releaseVersion }}

SHIPPED

{{ shipped.length }}

available now

TIER 1

{{ tier1.length }}

next in line

TIER 2

{{ tier2.length }}

in planning

TIER 3+

{{ tier3.length + tier4.length }}

forming

@@ -202,6 +203,7 @@ interface RoadmapComponentRow { }) export class RoadmapPageComponent { protected readonly i18n = inject(I18nService); + protected readonly releaseVersion = `v${cliVersionChangelog[0].version}`; private readonly seo = inject(SeoService); constructor() { diff --git a/apps/docs/src/app/stackblitz/open-stackblitz.ts b/apps/docs/src/app/stackblitz/open-stackblitz.ts new file mode 100644 index 00000000..8596ed32 --- /dev/null +++ b/apps/docs/src/app/stackblitz/open-stackblitz.ts @@ -0,0 +1,250 @@ +import sdk from '@stackblitz/sdk'; + +interface RegistryShared { + name: string; + file: string; + peerDependencies?: Record; +} + +interface RegistryComponent { + name: string; + files: string[]; + sharedDeps?: string[]; + componentDeps?: string[]; + peerDependencies?: Record; +} + +interface Registry { + shared: RegistryShared[]; + components: RegistryComponent[]; + blocks?: RegistryComponent[]; +} + +const REGISTRY_BASE = '/registry'; + +function collectInstallSet(name: string, registry: Registry): RegistryComponent[] { + const byName = new Map([ + ...registry.components.map((item) => [item.name, item] as const), + ...(registry.blocks ?? []).map((item) => [item.name, item] as const), + ]); + const seen = new Set(); + const queue = [name]; + const items: RegistryComponent[] = []; + while (queue.length > 0) { + const current = queue.shift()!; + if (seen.has(current)) continue; + seen.add(current); + const item = byName.get(current); + if (!item) continue; + items.push(item); + for (const dep of item.componentDeps ?? []) queue.push(dep); + } + return items; +} + +async function fetchText(path: string): Promise { + const response = await fetch(path); + if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${path}`); + return response.text(); +} + +function projectFiles(options: { + componentId: string; + example: string; + items: RegistryComponent[]; + shared: RegistryShared[]; + sources: Record; + theme: string; +}): Record { + const files: Record = {}; + const peerDependencies: Record = { + '@angular/common': '^22.0.0', + '@angular/compiler': '^22.0.0', + '@angular/core': '^22.0.0', + '@angular/forms': '^22.0.0', + '@angular/platform-browser': '^22.0.0', + '@angular/router': '^22.0.0', + '@angular/cdk': '^22.0.0', + '@angular/aria': '^22.0.0', + '@lucide/angular': '^1.18.0', + clsx: '^2.1.1', + 'tailwind-merge': '^3.6.0', + rxjs: '~7.8.0', + tslib: '^2.3.0', + }; + + for (const item of options.items) { + for (const file of item.files) { + const sourcePath = options.sources[`components/${file}`] + ? `components/${file}` + : `blocks/${file}`; + files[`src/app/components/ui/${file}`] = options.sources[sourcePath]; + } + Object.assign(peerDependencies, item.peerDependencies); + for (const depName of item.sharedDeps ?? []) { + const shared = options.shared.find((entry) => entry.name === depName); + if (!shared) continue; + Object.assign(peerDependencies, shared.peerDependencies); + const fileName = shared.file.replace(/^shared\//, ''); + files[`src/app/components/ui/shared/${fileName}`] = options.sources[shared.file]; + } + } + + files['src/app/components/ui/shared/theme.css'] = options.theme; + files['src/sanring-theme.css'] = options.theme; + files['src/app/app.ts'] = `import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import * as Sanring from './components/ui/${options.componentId}'; + +const sanringImports = Object.values(Sanring).filter((value) => typeof value === 'function'); + +@Component({ + selector: 'app-root', + imports: [FormsModule, ...sanringImports], + template: \` +
+${options.example} +
+ \`, +}) +export class App {} +`; + files['src/main.ts'] = `import { bootstrapApplication } from '@angular/platform-browser'; +import { App } from './app/app'; + +bootstrapApplication(App).catch((error) => console.error(error)); +`; + files['src/index.html'] = ` + + + + Sanring ${options.componentId} + + + + + + +`; + files['src/styles.css'] = `@import "tailwindcss"; +@import "./sanring-theme.css"; +`; + files['tsconfig.json'] = JSON.stringify( + { + compileOnSave: false, + compilerOptions: { + strict: true, + skipLibCheck: true, + isolatedModules: true, + module: 'preserve', + target: 'ES2022', + experimentalDecorators: true, + }, + }, + null, + 2, + ); + files['tsconfig.app.json'] = JSON.stringify( + { + extends: './tsconfig.json', + compilerOptions: { outDir: './out-tsc/app', types: [] }, + files: ['src/main.ts'], + }, + null, + 2, + ); + files['angular.json'] = JSON.stringify( + { + version: 1, + projects: { + demo: { + projectType: 'application', + root: '', + sourceRoot: 'src', + architect: { + build: { + builder: '@angular/build:application', + options: { + browser: 'src/main.ts', + tsConfig: 'tsconfig.app.json', + styles: ['src/styles.css'], + }, + }, + serve: { + builder: '@angular/build:dev-server', + defaultConfiguration: 'development', + options: { buildTarget: 'demo:build' }, + }, + }, + }, + }, + }, + null, + 2, + ); + files['postcss.config.mjs'] = `export default { plugins: { '@tailwindcss/postcss': {} } };\n`; + files['package.json'] = JSON.stringify( + { + name: `sanring-${options.componentId}`, + private: true, + scripts: { start: 'ng serve --host 0.0.0.0' }, + dependencies: peerDependencies, + devDependencies: { + '@angular/build': '^22.0.1', + '@angular/cli': '^22.0.1', + '@angular/compiler-cli': '^22.0.0', + '@tailwindcss/postcss': '^4.3.1', + postcss: '^8.5.15', + tailwindcss: '^4.3.1', + typescript: '~6.0.2', + }, + }, + null, + 2, + ); + + return files; +} + +export async function openComponentInStackBlitz(componentId: string, example: string): Promise { + const registry = (await (await fetch(`${REGISTRY_BASE}/registry.json`)).json()) as Registry; + const items = collectInstallSet(componentId, registry); + if (items.length === 0) throw new Error(`Unknown component: ${componentId}`); + + const sharedNeeded = new Set(items.flatMap((item) => item.sharedDeps ?? [])); + const shared = registry.shared.filter((entry) => sharedNeeded.has(entry.name)); + const sources: Record = {}; + + await Promise.all([ + ...items.flatMap((item) => + item.files.map(async (file) => { + const componentPath = `${REGISTRY_BASE}/components/${file}`; + const blockPath = `${REGISTRY_BASE}/blocks/${file}`; + const componentResponse = await fetch(componentPath); + if (componentResponse.ok) { + sources[`components/${file}`] = await componentResponse.text(); + return; + } + sources[`blocks/${file}`] = await fetchText(blockPath); + }), + ), + ...shared.map(async (entry) => { + sources[entry.file] = await fetchText(`${REGISTRY_BASE}/${entry.file}`); + }), + ]); + + const theme = await fetchText(`${REGISTRY_BASE}/shared/theme.css`); + const files = projectFiles({ componentId, example, items, shared: registry.shared, sources, theme }); + + // StackBlitz WebContainers (`node`) run a real Angular 22 + Tailwind project. + // Source: https://developer.stackblitz.com/guides/integration/create-with-sdk + sdk.openProject( + { + title: `Sanring ${componentId}`, + description: `Minimal Angular app with the ${componentId} example from the Sanring docs.`, + template: 'node', + files, + }, + { newWindow: true, openFile: 'src/app/app.ts' }, + ); +} diff --git a/package.json b/package.json index e3481a9d..368c36b6 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "test:e2e:cli": "pnpm --filter @sanring/cli test:e2e", "lint": "eslint .", "lint:ng": "ng lint", - "generate:stackblitz-registry": "node scripts/generate-stackblitz-registry.mjs", "changeset": "changeset", "version-packages": "changeset version", "release": "pnpm --filter @sanring/cli build && pnpm --filter @sanring/cli publish --access public" diff --git a/packages/cli/README.md b/packages/cli/README.md index 499e30d6..2053aef3 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -27,6 +27,7 @@ Commands are grouped by what stage of your workflow they belong to: # Install npx @sanring/cli@latest init # set up sanring.config.json + theme npx @sanring/cli@latest add date-picker # copy a component into your project +npx @sanring/cli@latest add block/login # copy a page-level block + its components npx @sanring/cli@latest remove date-picker # remove an installed component # Explore @@ -43,7 +44,7 @@ npx @sanring/cli@latest doctor # check environment + project h `build` generates a `registry.json` for publishing your own registry, and `mcp` starts an MCP server for AI coding agents — see below. -Every command accepts `--registry ` to point at a custom registry. `sanring.config.json` also supports `registries` and `defaultRegistry` for permanent alias configuration. Run any command with `--help` for the full flag list, or see [ui.sanring.dev/cli](https://ui.sanring.dev/cli) for complete documentation. +Every command accepts `--registry ` to point at a custom registry. A public GitHub repo with `registry.json` at the root can be passed as `github:owner/repo` (optional `#ref` or `@ref`). `sanring.config.json` also supports `registries` and `defaultRegistry` for permanent alias configuration. Run any command with `--help` for the full flag list, or see [ui.sanring.dev/cli](https://ui.sanring.dev/cli) for complete documentation. ## Notes diff --git a/packages/cli/scripts/check-registry-sync.mjs b/packages/cli/scripts/check-registry-sync.mjs index dc687b0c..e3cca282 100644 --- a/packages/cli/scripts/check-registry-sync.mjs +++ b/packages/cli/scripts/check-registry-sync.mjs @@ -25,6 +25,7 @@ const DOCS_ROUTES_PATH = join(REPO_ROOT, 'apps/docs/src/app/app.routes.ts'); const DOCS_PAGES_DIR = join(REPO_ROOT, 'apps/docs/src/app/pages/components'); const REGISTRY_JSON_PATH = join(REPO_ROOT, 'registry/registry.json'); const REGISTRY_COMPONENTS_DIR = join(REPO_ROOT, 'registry/components'); +const REGISTRY_BLOCKS_DIR = join(REPO_ROOT, 'registry/blocks'); const UI_COMPONENTS_DIR = join(REPO_ROOT, 'packages/ui/src/lib/components'); const PUBLIC_API_PATH = join(REPO_ROOT, 'packages/ui/src/public-api.ts'); @@ -65,6 +66,17 @@ function getRegistryDirNames() { .map((e) => e.name); } +function getRegistryBlockNames(registry) { + return (registry.blocks ?? []).map((block) => block.name); +} + +function getRegistryBlockDirNames() { + if (!existsSync(REGISTRY_BLOCKS_DIR)) return []; + return readdirSync(REGISTRY_BLOCKS_DIR, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name); +} + function getUiComponentDirNames() { return readdirSync(UI_COMPONENTS_DIR, { withFileTypes: true }) .filter((e) => e.isDirectory() && !UI_NON_COMPONENT_ENTRIES.has(e.name)) @@ -156,6 +168,8 @@ function diffSurfaces({ a, aLabel, b, bLabel, onlyInAFails, onlyInBFails, gapKey const registry = getRegistry(); const registryNames = getRegistryComponentNames(registry); const registryDirs = getRegistryDirNames(); +const registryBlockNames = getRegistryBlockNames(registry); +const registryBlockDirs = getRegistryBlockDirNames(); const uiDirs = getUiComponentDirNames(); const publicApiNames = getPublicApiExportedNames(); const docsIds = getDocsComponentIds(); @@ -176,6 +190,28 @@ for (const component of registry.components) { } } +for (const block of registry.blocks ?? []) { + const missingFiles = (block.files ?? []).filter( + (relativePath) => !existsSync(join(REGISTRY_BLOCKS_DIR, relativePath)), + ); + if (missingFiles.length > 0) { + fail( + `registry.json block "${block.name}" lists file(s) that don't exist under registry/blocks/`, + missingFiles, + ); + } +} + +diffSurfaces({ + a: registryBlockNames, + aLabel: 'registry.json blocks[]', + b: registryBlockDirs, + bLabel: 'registry/blocks/', + onlyInAFails: true, + onlyInBFails: false, + gapKey: 'registryJsonVsBlocksDir', +}); + // 2. registry.json name ↔ registry/components// directory. diffSurfaces({ a: registryNames, diff --git a/packages/cli/scripts/sync-registry.mjs b/packages/cli/scripts/sync-registry.mjs index 59361c92..6b183c34 100644 --- a/packages/cli/scripts/sync-registry.mjs +++ b/packages/cli/scripts/sync-registry.mjs @@ -61,6 +61,15 @@ function validate() { } } + for (const block of registry.blocks ?? []) { + for (const file of block.files ?? []) { + const filePath = join(DEST_DIR, 'blocks', file); + if (!existsSync(filePath)) { + errors.push(`block "${block.name}" references missing file: blocks/${file}`); + } + } + } + if (errors.length > 0) { console.error(`✖ registry.json is out of sync with registry files:\n`); for (const err of errors) console.error(` - ${err}`); @@ -68,8 +77,9 @@ function validate() { process.exit(1); } + const blockCount = registry.blocks?.length ?? 0; console.log( - `✔ registry.json verified (${registry.components.length} components, ${registry.shared.length} shared files)`, + `✔ registry.json verified (${registry.components.length} components, ${blockCount} blocks, ${registry.shared.length} shared files)`, ); } diff --git a/packages/cli/src/__tests__/registry-fixture.ts b/packages/cli/src/__tests__/registry-fixture.ts index a4a1b3ee..61962b59 100644 --- a/packages/cli/src/__tests__/registry-fixture.ts +++ b/packages/cli/src/__tests__/registry-fixture.ts @@ -11,6 +11,8 @@ export interface RegistryFixtureContent { widget?: string; /** Second file added to the widget component — simulates a registry adding a new file post-install. */ widgetExtra?: string; + /** Page-level block installed from `blocks/`, with `widget` as a componentDep when present. */ + block?: string; } // Writes a minimal, self-contained registry (registry.json + the files it @@ -58,6 +60,19 @@ export function writeRegistryFixture(dir: string, content: RegistryFixtureConten }); } + const blocks: Registry['blocks'] = []; + if (content.block !== undefined) { + mkdirSync(join(dir, 'blocks', 'login'), { recursive: true }); + writeFileSync(join(dir, 'blocks', 'login', 'index.ts'), content.block, 'utf-8'); + blocks.push({ + name: 'login', + description: 'fixture login block', + files: ['login/index.ts'], + componentDeps: content.widget !== undefined ? ['widget'] : [], + }); + } + const registry: Registry = { name: 'fixture', shared, components }; + if (blocks.length > 0) registry.blocks = blocks; writeFileSync(join(dir, 'registry.json'), JSON.stringify(registry, null, 2), 'utf-8'); } diff --git a/packages/cli/src/commands/add.test.ts b/packages/cli/src/commands/add.test.ts index 50ec5301..8535ddce 100644 --- a/packages/cli/src/commands/add.test.ts +++ b/packages/cli/src/commands/add.test.ts @@ -180,6 +180,13 @@ describe('addCommand (integration)', () => { }); process.chdir(projectDir); vi.spyOn(console, 'log').mockImplementation(() => {}); + addCommand.setOptionValue('dryRun', false); + addCommand.setOptionValue('check', false); + addCommand.setOptionValue('diff', false); + addCommand.setOptionValue('view', false); + addCommand.setOptionValue('force', false); + addCommand.setOptionValue('yes', false); + addCommand.setOptionValue('registry', undefined); }); afterEach(() => { @@ -340,6 +347,46 @@ describe('addCommand (integration)', () => { ).toBe(true); }); + it('installs a block via the block/ prefix and pulls componentDeps', async () => { + writeRegistryFixture(registryDir, { + utils: 'export function cn() {}\n', + widget: 'export const widget = 1;\n', + block: 'export const login = true;\n', + }); + + await addCommand.parseAsync(['block/login', '--registry', registryDir], { from: 'user' }); + + expect(readFileSync(join(projectDir, 'src/app/components/ui/login/index.ts'), 'utf-8')).toBe( + 'export const login = true;\n', + ); + expect(existsSync(join(projectDir, 'src/app/components/ui/widget/index.ts'))).toBe(true); + }); + + it('installs a block by bare name when no component shares that name', async () => { + writeRegistryFixture(registryDir, { + widget: 'export const widget = 1;\n', + block: 'export const login = true;\n', + }); + + await addCommand.parseAsync(['login', '--registry', registryDir], { from: 'user' }); + + expect(existsSync(join(projectDir, 'src/app/components/ui/login/index.ts'))).toBe(true); + }); + + it('refuses block/ prefix for a name that is only a component', async () => { + const errors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + errors.push(args.join(' ')); + }); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await addCommand.parseAsync(['block/widget', '--registry', registryDir], { from: 'user' }); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errors.some((line) => line.includes('block/widget'))).toBe(true); + expect(existsSync(join(projectDir, 'src/app/components/ui/widget/index.ts'))).toBe(false); + }); + it('does not update peer dependencies when the installed version spec already satisfies the minimum', async () => { const logs: string[] = []; vi.mocked(console.log).mockImplementation((...args: unknown[]) => { diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 60b06f46..126452fc 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -15,6 +15,9 @@ import { type RegistryComponent, type RegistryShared, createRegistryIndex, + BLOCK_NAME_PREFIX, + parseBlockRef, + registryItemRemotePath, } from '../registry.js'; import { confirmPrompt, @@ -191,8 +194,8 @@ interface ComponentFileJob { } export const addCommand = new Command('add') - .description('Add one or more components to your project') - .argument('', 'component name(s) (e.g. accordion button)') + .description('Add one or more components or blocks to your project') + .argument('', 'component or block name(s) (e.g. button, block/login)') .option('-p, --path ', 'destination path relative to cwd') .option('-s, --shared-path ', 'destination for shared utilities (default: /shared)') .option('-f, --force', 'overwrite existing files', false) @@ -242,7 +245,7 @@ export const addCommand = new Command('add') return; } const alias = explicitAliases.size === 1 ? [...explicitAliases][0] : undefined; - const strippedNames = parsedRefs.map((ref) => ref.name); + const parsedTargets = parsedRefs.map((ref) => parseBlockRef(ref.name)); // Resolve component path: CLI option > sanring.config.json > default const config = readConfig(cwd); @@ -269,10 +272,37 @@ export const addCommand = new Command('add') const registryIndex = createRegistryIndex(registry); registrySpinner.stop(); - const { toInstall, autoAdded, missing } = resolveInstallSet(strippedNames, registryIndex); + const missingBlocks: string[] = []; + const resolvedNames: string[] = []; + for (const target of parsedTargets) { + if (target.preferBlock) { + if (!target.name || !registryIndex.blocksByName.has(target.name)) { + missingBlocks.push(`${BLOCK_NAME_PREFIX}${target.name}`); + } else { + resolvedNames.push(target.name); + } + } else { + resolvedNames.push(target.name); + } + } + + if (missingBlocks.length > 0) { + const available = registryIndex.blockNames.map((name) => `${BLOCK_NAME_PREFIX}${name}`).join(', '); + console.error( + pc.red(`✖ Block${missingBlocks.length > 1 ? 's' : ''} not found: ${missingBlocks.join(', ')}`), + ); + console.error(pc.dim(` Available: ${available || '(none)'}`)); + process.exit(1); + return; + } + + const { toInstall, autoAdded, missing } = resolveInstallSet(resolvedNames, registryIndex); if (missing.length > 0) { - const available = registryIndex.componentNames.join(', '); + const available = [ + ...registryIndex.componentNames, + ...registryIndex.blockNames.map((name) => `${BLOCK_NAME_PREFIX}${name}`), + ].join(', '); console.error( pc.red(`✖ Component${missing.length > 1 ? 's' : ''} not found: ${missing.join(', ')}`), ); @@ -324,7 +354,7 @@ export const addCommand = new Command('add') for (const file of component.files) { const fileName = registryRelativePath(file, component.name); console.log(pc.bold(`── ${label}/${fileName} ──\n`)); - const content = await fetchFile(`components/${file}`, registrySource); + const content = await fetchFile(registryItemRemotePath(file, component.name, registryIndex), registrySource); console.log(content); } } @@ -354,7 +384,7 @@ export const addCommand = new Command('add') .map((shared) => ({ remotePath: shared.file, label: `shared/${shared.name}` })), ...toInstall.flatMap((component) => component.files.map((file) => ({ - remotePath: `components/${file}`, + remotePath: registryItemRemotePath(file, component.name, registryIndex), label: `${component.name}/${file}`, })), ), @@ -476,7 +506,7 @@ export const addCommand = new Command('add') const fileJobs: ComponentFileJob[] = component.files.map((file) => { const fileName = registryRelativePath(file, component.name); const dest = join(destDir, fileName); - return { file, fileName, dest, remotePath: `components/${file}` }; + return { file, fileName, dest, remotePath: registryItemRemotePath(file, component.name, registryIndex) }; }); for (const { fileName, dest } of fileJobs) { diff --git a/packages/cli/src/commands/diff.ts b/packages/cli/src/commands/diff.ts index dc88fa9c..fefe8b19 100644 --- a/packages/cli/src/commands/diff.ts +++ b/packages/cli/src/commands/diff.ts @@ -7,6 +7,7 @@ import { createRegistryIndex, fetchFile, fetchRegistry, + registryItemRemotePath, type Registry, type RegistryComponent, type RegistryIndex, @@ -34,11 +35,8 @@ export function listInstalledComponentNames( registry: Registry | RegistryIndex, ): string[] { if (!existsSync(componentBasePath)) return []; - const known = new Set( - 'componentNames' in registry - ? registry.componentNames - : createRegistryIndex(registry).componentNames, - ); + const index = 'componentNames' in registry ? registry : createRegistryIndex(registry); + const known = new Set([...index.componentNames, ...index.blockNames]); return readdirSync(componentBasePath, { withFileTypes: true }) .filter((entry) => entry.isDirectory() && known.has(entry.name)) .map((entry) => entry.name); @@ -115,7 +113,7 @@ export function buildDiffJobs( for (const file of component.files) { const fileName = registryRelativePath(file, component.name); const label = `${component.name}/${fileName}`; - jobs.push({ componentName: component.name, label, localPath: join(destDir, fileName), remotePath: `components/${file}`, recordedHash: options.installedHashes?.[label] }); + jobs.push({ componentName: component.name, label, localPath: join(destDir, fileName), remotePath: registryItemRemotePath(file, component.name, registryIndex), recordedHash: options.installedHashes?.[label] }); } } return jobs; diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts index fbacb740..33a2d51b 100644 --- a/packages/cli/src/commands/info.ts +++ b/packages/cli/src/commands/info.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import ora from 'ora'; import pc from 'picocolors'; import { collectPeerDeps, parseComponentRef, resolveInstallSet } from './add.js'; -import { createRegistryIndex, fetchRegistry } from '../registry.js'; +import { createRegistryIndex, fetchRegistry, parseBlockRef } from '../registry.js'; import { isAngularProject, getCliVersion, @@ -119,7 +119,7 @@ export const infoCommand = new Command('info') // ── Component info mode ─────────────────────────────────────────────── const config = readConfig(cwd); const parsedRef = parseComponentRef(componentName); - const bareComponentName = parsedRef.name; + const bareComponentName = parseBlockRef(parsedRef.name).name; const registrySpinner = ora('Loading registry...').start(); let registry; try { @@ -134,7 +134,10 @@ export const infoCommand = new Command('info') const { toInstall, autoAdded, missing } = resolveInstallSet([bareComponentName], registryIndex); if (missing.length > 0) { - const available = registryIndex.componentNames.join(', '); + const available = [ + ...registryIndex.componentNames, + ...registryIndex.blockNames.map((name) => `block/${name}`), + ].join(', '); console.error(pc.red(`✖ Component not found: ${componentName}`)); console.error(pc.dim(` Available: ${available}`)); process.exit(1); diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index 946bb07b..22f10950 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -8,6 +8,8 @@ import { createRegistryIndex, fetchFile, fetchRegistry, + registryInstallables, + registryItemRemotePath, type RegistryComponent, type RegistryIndex, } from '../registry.js'; @@ -90,7 +92,7 @@ export async function getComponentOutdatedSummaries( componentName: component.name, label, dest: join(destDir, fileName), - remotePath: `components/${file}`, + remotePath: registryItemRemotePath(file, component.name, registryIndex), recordedHash: options.installedHashes?.[label], }); } @@ -204,7 +206,7 @@ export const listCommand = new Command('list') const registryIndex = createRegistryIndex(registry); spinner.stop(); - let { components } = registry; + let components = registryInstallables(registry); if (options.installed || options.outdated) { requireAngularProject(process.cwd()); diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index 1788a822..1fb9d055 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -14,6 +14,9 @@ import { createRegistryGroups, fetchFile, fetchRegistry, + findRegistryItem, + registryInstallables, + registryItemRemotePath, type Registry, type RegistryComponent, } from '../registry.js'; @@ -295,7 +298,7 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { switch (name) { case 'refresh_registry': { const registry = await getRegistry(true); - return { content: [{ type: 'text' as const, text: `Registry refreshed: ${registry.components.length} components available.` }] }; + return { content: [{ type: 'text' as const, text: `Registry refreshed: ${registryInstallables(registry).length} installable items available.` }] }; } case 'diff_component': { @@ -306,10 +309,11 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { return { isError: true, content: [{ type: 'text' as const, text: '`cwd` must be an absolute Angular project root.' }] }; } const registry = await getRegistry(); - const component = registry.components.find((item) => item.name === componentName); + const component = findRegistryItem(registry, componentName); if (!component) return { isError: true, content: [{ type: 'text' as const, text: `Component "${componentName}" not found.` }] }; const config = readConfig(cwd); const base = resolveComponentBasePath(cwd, undefined, config); + const registryIndex = createRegistryIndex(registry); const lines: string[] = [`Diff: ${componentName}`, '']; let changed = 0; for (const file of component.files) { @@ -320,7 +324,7 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { changed++; continue; } - const remote = await fetchFile(`components/${file}`, registryUrl); + const remote = await fetchFile(registryItemRemotePath(file, component.name, registryIndex), registryUrl); const same = readFileSync(localPath, 'utf-8') === remote; lines.push(`${same ? '✓' : '●'} ${component.name}/${fileName}${same ? '' : ' (changed)'}`); if (!same) changed++; @@ -342,7 +346,7 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { ]; try { const registry = await getRegistry(); - lines.push(`Registry: reachable (${registry.components.length} components)`); + lines.push(`Registry: reachable (${registryInstallables(registry).length} installable items)`); const integrityIssues = findRegistryReferenceIssues(registry); if (integrityIssues.length > 0) { lines.push(`Registry integrity: ${integrityIssues.length} issue${integrityIssues.length > 1 ? 's' : ''}:`); @@ -366,7 +370,7 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { const lines: string[] = []; for (const [key, version] of Object.entries(config?.installedVersions ?? {})) { const name = key.includes(':') ? key.slice(key.indexOf(':') + 1) : key; - const component = registry.components.find((item) => item.name === name); + const component = findRegistryItem(registry, name); const needed = component?.migrations?.some((migration) => semverLte(version, migration.fromVersion)) ?? false; lines.push(`${needed ? '⚠' : '✓'} ${key}: ${version}${needed ? ' migration available' : ' up to date'}`); } @@ -375,12 +379,13 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { case 'list_components': { const registry = await getRegistry(); - const lines = formatGroupedComponents(registry, registry.components); + const items = registryInstallables(registry); + const lines = formatGroupedComponents(registry, items); return { content: [ { type: 'text' as const, - text: `Sanring UI — ${registry.components.length} installable items available:\n\n${lines}`, + text: `Sanring UI — ${items.length} installable items available:\n\n${lines}`, }, ], }; @@ -392,8 +397,9 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { const { query } = validated.values; const registry = await getRegistry(); const q = query.toLowerCase(); - const nameMatches = registry.components.filter((c) => c.name.toLowerCase().includes(q)); - const descMatches = registry.components.filter( + const items = registryInstallables(registry); + const nameMatches = items.filter((c) => c.name.toLowerCase().includes(q)); + const descMatches = items.filter( (c) => !c.name.toLowerCase().includes(q) && c.description.toLowerCase().includes(q), ); @@ -428,10 +434,10 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { if ('isError' in validated) return validated; const { name: componentName } = validated.values; const registry = await getRegistry(); - const component = registry.components.find((c) => c.name === componentName); + const component = findRegistryItem(registry, componentName); if (!component) { - const available = registry.components.map((c) => c.name).join(', '); + const available = registryInstallables(registry).map((c) => c.name).join(', '); return { isError: true, content: [ @@ -477,10 +483,10 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { } const registry = await getRegistry(); - const component = registry.components.find((c) => c.name === componentName); + const component = findRegistryItem(registry, componentName); if (!component) { - const available = registry.components.map((c) => c.name).join(', '); + const available = registryInstallables(registry).map((c) => c.name).join(', '); return { isError: true, content: [{ type: 'text' as const, text: `Component "${componentName}" not found.\n\nAvailable: ${available}` }], @@ -488,7 +494,7 @@ export function createMcpServer(options: CreateMcpServerOptions = {}): Server { } const registryIndex = createRegistryIndex(registry); - const { toInstall, autoAdded } = resolveInstallSet([componentName], registryIndex); + const { toInstall, autoAdded } = resolveInstallSet([component.name], registryIndex); const peerDeps = collectPeerDeps(toInstall, registryIndex); const lines: string[] = [`Plan for: sanring add ${componentName}`, '']; diff --git a/packages/cli/src/commands/search.ts b/packages/cli/src/commands/search.ts index 47180239..b9ddffb8 100644 --- a/packages/cli/src/commands/search.ts +++ b/packages/cli/src/commands/search.ts @@ -1,7 +1,7 @@ import { Command } from 'commander'; import ora from 'ora'; import pc from 'picocolors'; -import { fetchRegistry } from '../registry.js'; +import { fetchRegistry, registryInstallables } from '../registry.js'; import { isAngularProject, readConfig, @@ -47,7 +47,7 @@ export const searchCommand = new Command('search') const groupNames = options.group ? new Set(registry.groups?.find((group) => group.id === options.group || group.title.toLowerCase() === options.group?.toLowerCase())?.components ?? []) : null; - const scored = registry.components + const scored = registryInstallables(registry) .filter((component) => !groupNames || groupNames.has(component.name)) .filter((component) => !options.tag || component.tags?.includes(options.tag)) .map((component) => { diff --git a/packages/cli/src/registry-integrity.test.ts b/packages/cli/src/registry-integrity.test.ts index 8d7c45b7..c8eb0cd2 100644 --- a/packages/cli/src/registry-integrity.test.ts +++ b/packages/cli/src/registry-integrity.test.ts @@ -72,6 +72,27 @@ describe('findRegistryReferenceIssues', () => { ]); }); + it('accepts a block that depends on a known component', () => { + expect( + findRegistryReferenceIssues( + baseRegistry({ + blocks: [{ name: 'login', description: '', files: ['login/index.ts'], componentDeps: ['badge'] }], + }), + ), + ).toEqual([]); + }); + + it('flags a group referencing a block name as valid', () => { + expect( + findRegistryReferenceIssues( + baseRegistry({ + blocks: [{ name: 'login', description: '', files: ['login/index.ts'] }], + groups: [{ id: 'blocks', title: 'Blocks', components: ['login'] }], + }), + ), + ).toEqual([]); + }); + it('flags a group referencing an unknown component', () => { const registry = baseRegistry({ groups: [{ id: 'forms', title: 'Forms', components: ['badge', 'not-real'] }], diff --git a/packages/cli/src/registry-integrity.ts b/packages/cli/src/registry-integrity.ts index c132c6cd..25e4c4f3 100644 --- a/packages/cli/src/registry-integrity.ts +++ b/packages/cli/src/registry-integrity.ts @@ -46,10 +46,13 @@ export function isParseableVersionRange(spec: string): boolean { // file-fetchability check below. export function findRegistryReferenceIssues(registry: Registry): RegistryIntegrityIssue[] { const issues: RegistryIntegrityIssue[] = []; - const knownComponentNames = new Set(registry.components.map((c) => c.name)); + const knownComponentNames = new Set([ + ...registry.components.map((c) => c.name), + ...(registry.blocks ?? []).map((c) => c.name), + ]); const knownSharedNames = new Set(registry.shared.map((s) => s.name)); - for (const component of registry.components) { + for (const component of [...registry.components, ...(registry.blocks ?? [])]) { for (const dep of component.componentDeps ?? []) { if (!knownComponentNames.has(dep)) { issues.push({ @@ -108,9 +111,13 @@ export async function checkRegistryFilesFetchable( registry: Registry, source?: string, ): Promise { + const blockNames = new Set((registry.blocks ?? []).map((block) => block.name)); const targets = [ - ...registry.components.flatMap((component) => - component.files.map((file) => ({ label: `${component.name}/${file}`, remotePath: `components/${file}` })), + ...[...registry.components, ...(registry.blocks ?? [])].flatMap((component) => + component.files.map((file) => ({ + label: `${component.name}/${file}`, + remotePath: `${blockNames.has(component.name) ? 'blocks' : 'components'}/${file}`, + })), ), ...registry.shared.map((shared) => ({ label: `shared/${shared.name}`, remotePath: shared.file })), ]; diff --git a/packages/cli/src/registry.test.ts b/packages/cli/src/registry.test.ts index d092c1dc..1155ebb3 100644 --- a/packages/cli/src/registry.test.ts +++ b/packages/cli/src/registry.test.ts @@ -9,6 +9,8 @@ import { installCommand, installCommandParts, RegistryFetchError, + expandGithubRegistrySource, + parseBlockRef, validateRegistry, } from './registry.js'; @@ -82,6 +84,38 @@ describe('installCommandParts', () => { }); }); +describe('parseBlockRef', () => { + it('strips the block/ prefix', () => { + expect(parseBlockRef('block/login')).toEqual({ preferBlock: true, name: 'login' }); + }); + + it('leaves a bare component name unchanged', () => { + expect(parseBlockRef('button')).toEqual({ preferBlock: false, name: 'button' }); + }); +}); + +describe('expandGithubRegistrySource', () => { + it('maps github:owner/repo to the main-branch raw registry.json URL', () => { + expect(expandGithubRegistrySource('github:acme/ui')).toBe( + 'https://raw.githubusercontent.com/acme/ui/main/registry.json', + ); + }); + + it('accepts a #ref or @ref override', () => { + expect(expandGithubRegistrySource('github:acme/ui#v1')).toBe( + 'https://raw.githubusercontent.com/acme/ui/v1/registry.json', + ); + expect(expandGithubRegistrySource('github:acme/ui@release')).toBe( + 'https://raw.githubusercontent.com/acme/ui/release/registry.json', + ); + }); + + it('returns undefined for ordinary paths and URLs', () => { + expect(expandGithubRegistrySource('./registry')).toBeUndefined(); + expect(expandGithubRegistrySource('https://example.com/registry.json')).toBeUndefined(); + }); +}); + describe('validateRegistry', () => { it('accepts a minimal valid registry', () => { expect( @@ -122,6 +156,28 @@ describe('validateRegistry', () => { ]); }); + it('accepts an optional blocks array', () => { + expect( + validateRegistry({ + name: 'test', + shared: [], + components: [{ name: 'button', description: 'Button', files: ['button/index.ts'] }], + blocks: [{ name: 'login', description: 'Login page', files: ['login/index.ts'] }], + }).blocks, + ).toEqual([{ name: 'login', description: 'Login page', files: ['login/index.ts'] }]); + }); + + it('rejects a block whose name collides with a component', () => { + expect(() => + validateRegistry({ + name: 'test', + shared: [], + components: [{ name: 'login', description: 'Login input', files: ['login/index.ts'] }], + blocks: [{ name: 'login', description: 'Login page', files: ['login/index.ts'] }], + }), + ).toThrow('blocks[0].name "login" collides with a component'); + }); + it('reports invalid registry fields with paths', () => { expect(() => validateRegistry({ @@ -171,6 +227,16 @@ describe('fetchRegistry source resolution', () => { expect(fetchMock).toHaveBeenCalledWith('https://example.com/registry/registry.json'); }); + it('expands github:owner/repo before fetching', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ name: 'github-registry', shared: [], components: [] }), + }); + const registry = await fetchRegistry('github:acme/ui'); + expect(registry.name).toBe('github-registry'); + expect(fetchMock).toHaveBeenCalledWith('https://raw.githubusercontent.com/acme/ui/main/registry.json'); + }); + it('rejects with a RegistryFetchError when the explicit URL request fails', async () => { fetchMock.mockResolvedValueOnce({ ok: false, status: 404 }); await expect(fetchRegistry('https://example.com/registry/registry.json')).rejects.toThrow( diff --git a/packages/cli/src/registry.ts b/packages/cli/src/registry.ts index 22e34e91..a0ab2279 100644 --- a/packages/cli/src/registry.ts +++ b/packages/cli/src/registry.ts @@ -75,6 +75,18 @@ function isUrl(s: string): boolean { return s.startsWith('http://') || s.startsWith('https://'); } +/** `github:owner/repo` or `github:owner/repo#ref` → raw registry.json URL. */ +export function expandGithubRegistrySource(source: string): string | undefined { + const match = /^github:([^/]+)\/([^#@]+)(?:[#@](.+))?$/.exec(source.trim()); + if (!match) return undefined; + const [, owner, repo, ref = 'main'] = match; + return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/registry.json`; +} + +function normalizeRegistrySource(source: string): string { + return expandGithubRegistrySource(source) ?? source; +} + // Thrown instead of printing + `process.exit(1)` directly, so callers (CLI // commands, the MCP server) decide how to surface the failure — a one-shot // CLI command prints and exits, but the long-running MCP server must not be @@ -132,22 +144,56 @@ export interface Registry { groups?: RegistryGroup[]; shared: RegistryShared[]; components: RegistryComponent[]; + /** Page-level templates. Same shape as components; files live under `blocks/`. */ + blocks?: RegistryComponent[]; } export interface RegistryIndex { registry: Registry; componentsByName: Map; + blocksByName: Map; sharedByName: Map; componentNames: string[]; + blockNames: string[]; groups: RegistryGroup[]; } +export const BLOCK_NAME_PREFIX = 'block/'; + +export function parseBlockRef(name: string): { preferBlock: boolean; name: string } { + if (name.startsWith(BLOCK_NAME_PREFIX)) { + return { preferBlock: true, name: name.slice(BLOCK_NAME_PREFIX.length) }; + } + return { preferBlock: false, name }; +} + +export function registryInstallables(registry: Registry): RegistryComponent[] { + return registry.blocks?.length ? [...registry.components, ...registry.blocks] : registry.components; +} + +export function findRegistryItem(registry: Registry, rawName: string): RegistryComponent | undefined { + const { name } = parseBlockRef(rawName); + return ( + registry.components.find((item) => item.name === name) ?? + registry.blocks?.find((item) => item.name === name) + ); +} + +export function registryItemRemotePath(file: string, name: string, index: RegistryIndex): string { + return `${index.blocksByName.has(name) ? 'blocks' : 'components'}/${file}`; +} + export function createRegistryIndex(registry: Registry): RegistryIndex { + const blocks = registry.blocks ?? []; + const componentsByName = new Map(registry.components.map((component) => [component.name, component])); + for (const block of blocks) componentsByName.set(block.name, block); return { registry, - componentsByName: new Map(registry.components.map((component) => [component.name, component])), + componentsByName, + blocksByName: new Map(blocks.map((block) => [block.name, block])), sharedByName: new Map(registry.shared.map((shared) => [shared.name, shared])), componentNames: registry.components.map((component) => component.name), + blockNames: blocks.map((block) => block.name), groups: createRegistryGroups(registry), }; } @@ -197,6 +243,22 @@ function validateOptionalPeerDependencies( return false; } +function validateRegistryItem(errors: string[], item: unknown, path: string): void { + if (!isRecord(item)) { + errors.push(`${path} must be an object`); + return; + } + if (typeof item.name !== 'string') errors.push(`${path}.name must be a string`); + if (typeof item.description !== 'string') { + errors.push(`${path}.description must be a string`); + } + if (!isStringArray(item.files)) errors.push(`${path}.files must be an array of strings`); + validateOptionalStringArray(errors, item.tags, `${path}.tags`); + validateOptionalStringArray(errors, item.sharedDeps, `${path}.sharedDeps`); + validateOptionalStringArray(errors, item.componentDeps, `${path}.componentDeps`); + validateOptionalPeerDependencies(errors, item.peerDependencies, `${path}.peerDependencies`); +} + export function validateRegistry(value: unknown): Registry { const errors: string[] = []; @@ -248,20 +310,31 @@ export function validateRegistry(value: unknown): Registry { if (Array.isArray(value.components)) { value.components.forEach((item, index) => { - const path = `components[${index}]`; - if (!isRecord(item)) { - errors.push(`${path} must be an object`); - return; - } - if (typeof item.name !== 'string') errors.push(`${path}.name must be a string`); - if (typeof item.description !== 'string') { - errors.push(`${path}.description must be a string`); + validateRegistryItem(errors, item, `components[${index}]`); + }); + } + + if (value.blocks !== undefined) { + if (!Array.isArray(value.blocks)) { + errors.push('blocks must be an array'); + } else { + value.blocks.forEach((item, index) => { + validateRegistryItem(errors, item, `blocks[${index}]`); + }); + } + } + + if (Array.isArray(value.components) && Array.isArray(value.blocks)) { + const componentNames = new Set( + value.components + .filter(isRecord) + .map((item) => item.name) + .filter((name): name is string => typeof name === 'string'), + ); + value.blocks.forEach((item, index) => { + if (isRecord(item) && typeof item.name === 'string' && componentNames.has(item.name)) { + errors.push(`blocks[${index}].name "${item.name}" collides with a component`); } - if (!isStringArray(item.files)) errors.push(`${path}.files must be an array of strings`); - validateOptionalStringArray(errors, item.tags, `${path}.tags`); - validateOptionalStringArray(errors, item.sharedDeps, `${path}.sharedDeps`); - validateOptionalStringArray(errors, item.componentDeps, `${path}.componentDeps`); - validateOptionalPeerDependencies(errors, item.peerDependencies, `${path}.peerDependencies`); }); } @@ -275,17 +348,21 @@ export function validateRegistry(value: unknown): Registry { components: value.components as RegistryComponent[], }; if (value.groups !== undefined) registry.groups = value.groups as RegistryGroup[]; + if (value.blocks !== undefined) registry.blocks = value.blocks as RegistryComponent[]; return registry; } // --------------------------------------------------------------------------- // fetchRegistry -// source = undefined → local bundle → remote fallback -// source = '/path/to/dir' → read /registry.json from disk -// source = 'https://...' → HTTP fetch +// source = undefined → local bundle → remote fallback +// source = '/path/to/dir' → read /registry.json from disk +// source = 'https://...' → HTTP fetch +// source = 'github:owner/repo' → raw.githubusercontent.com .../registry.json // --------------------------------------------------------------------------- export async function fetchRegistry(source?: string): Promise { + if (source) source = normalizeRegistrySource(source); + // 1. Explicit local path if (source && !isUrl(source)) { const localJson = join(source, 'registry.json'); @@ -337,6 +414,8 @@ async function fetchRegistryFromUrl(url: string): Promise { // --------------------------------------------------------------------------- export async function fetchFile(relativePath: string, source?: string): Promise { + if (source) source = normalizeRegistrySource(source); + // Explicit local path if (source && !isUrl(source)) { return readFileSync(join(source, relativePath), 'utf-8'); diff --git a/packages/ui/src/lib/components/dialog/dialog-header.component.ts b/packages/ui/src/lib/components/dialog/dialog-header.component.ts index 71fd37c1..ea4c3e5f 100644 --- a/packages/ui/src/lib/components/dialog/dialog-header.component.ts +++ b/packages/ui/src/lib/components/dialog/dialog-header.component.ts @@ -13,10 +13,14 @@ import { cn } from '../../utils'; export class DialogHeaderComponent { readonly class = input(); + readonly align = input<'start' | 'center' | undefined>(undefined); + protected readonly dialogHeaderClass = computed(() => cn( - // 垂直排列、預設置中 (手機版常見)、桌機版靠左對齊 - 'flex flex-col space-y-1.5 text-center sm:text-left', + 'flex flex-col space-y-1.5', + this.align() === 'center' && 'items-center text-center', + this.align() === 'start' && 'items-start text-left', + !this.align() && 'text-center sm:text-left', this.class(), ), ); diff --git a/packages/ui/src/lib/components/dialog/dialog.component.spec.ts b/packages/ui/src/lib/components/dialog/dialog.component.spec.ts index 999aa536..cd13f589 100644 --- a/packages/ui/src/lib/components/dialog/dialog.component.spec.ts +++ b/packages/ui/src/lib/components/dialog/dialog.component.spec.ts @@ -296,3 +296,46 @@ describe('DialogComponent', () => { } }); }); + +@Component({ + imports: [DialogHeaderComponent], + template: `Title`, +}) +class DialogHeaderAlignHost { + align: 'start' | 'center' | undefined; +} + +describe('DialogHeaderComponent', () => { + it('keeps the responsive default alignment when align is unset', async () => { + await TestBed.configureTestingModule({ imports: [DialogHeaderAlignHost] }).compileComponents(); + const fixture = TestBed.createComponent(DialogHeaderAlignHost); + fixture.detectChanges(); + + const header = fixture.nativeElement.querySelector('sanring-dialog-header') as HTMLElement; + expect(header.classList.contains('text-center')).toBe(true); + expect(header.classList.contains('sm:text-left')).toBe(true); + }); + + it('centers header text when align is center', async () => { + await TestBed.configureTestingModule({ imports: [DialogHeaderAlignHost] }).compileComponents(); + const fixture = TestBed.createComponent(DialogHeaderAlignHost); + fixture.componentInstance.align = 'center'; + fixture.detectChanges(); + + const header = fixture.nativeElement.querySelector('sanring-dialog-header') as HTMLElement; + expect(header.classList.contains('text-center')).toBe(true); + expect(header.classList.contains('items-center')).toBe(true); + expect(header.classList.contains('sm:text-left')).toBe(false); + }); + + it('left-aligns header text when align is start', async () => { + await TestBed.configureTestingModule({ imports: [DialogHeaderAlignHost] }).compileComponents(); + const fixture = TestBed.createComponent(DialogHeaderAlignHost); + fixture.componentInstance.align = 'start'; + fixture.detectChanges(); + + const header = fixture.nativeElement.querySelector('sanring-dialog-header') as HTMLElement; + expect(header.classList.contains('text-left')).toBe(true); + expect(header.classList.contains('items-start')).toBe(true); + }); +}); diff --git a/packages/ui/src/lib/components/field/field.component.spec.ts b/packages/ui/src/lib/components/field/field.component.spec.ts index ebb0d12f..3aa56b38 100644 --- a/packages/ui/src/lib/components/field/field.component.spec.ts +++ b/packages/ui/src/lib/components/field/field.component.spec.ts @@ -4,6 +4,7 @@ import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; import { describe, expect, it } from 'vitest'; import { expectNoA11yViolations } from '../../../testing/axe-a11y'; import { InputDirective } from '../input/input.directive'; +import { TextareaDirective } from '../textarea/textarea.directive'; import { DescriptionDirective } from './description.directive'; import { ErrorMessageComponent } from './error-message.component'; import { SanringFieldComponent } from './field.component'; @@ -81,6 +82,36 @@ describe('SanringFieldComponent projection', () => { }); }); +@Component({ + standalone: true, + imports: [SanringFieldComponent, LabelDirective, TextareaDirective, DescriptionDirective], + template: ` + + + + +

0/500

+
+ `, +}) +class TextareaProjectionHost {} + +describe('SanringFieldComponent textarea projection', () => { + it('renders the textarea above the description', () => { + const fixture = TestBed.createComponent(TextareaProjectionHost); + fixture.detectChanges(); + + const field = fixture.nativeElement.querySelector('sanring-field') as HTMLElement; + const textarea = field.querySelector('textarea'); + const description = field.querySelector('[sanringDescription]'); + expect(textarea).toBeTruthy(); + expect(description).toBeTruthy(); + + const position = textarea!.compareDocumentPosition(description!); + expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); +}); + @Component({ standalone: true, imports: [ diff --git a/packages/ui/src/lib/components/field/field.component.ts b/packages/ui/src/lib/components/field/field.component.ts index 98c7d140..3f4f221e 100644 --- a/packages/ui/src/lib/components/field/field.component.ts +++ b/packages/ui/src/lib/components/field/field.component.ts @@ -51,7 +51,7 @@ function findAmbientBackground(el: HTMLElement): string | null { 所以 label 寫在前面即可同時滿足「非 floating 時 label 要先出現」跟「floating 時疊在 input 上」 -->
- +
diff --git a/registry/blocks/dashboard-shell/dashboard-shell.component.ts b/registry/blocks/dashboard-shell/dashboard-shell.component.ts new file mode 100644 index 00000000..9de76c35 --- /dev/null +++ b/registry/blocks/dashboard-shell/dashboard-shell.component.ts @@ -0,0 +1,173 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { + LucideLayoutDashboard, + LucideLogOut, + LucidePanelLeft, + LucideSettings, + LucideUsers, +} from '@lucide/angular'; +import { SANRING_AVATAR_IMPORTS } from '../avatar'; +import { BadgeDirective } from '../badge'; +import { SANRING_BREADCRUMB_IMPORTS } from '../breadcrumb'; +import { SANRING_DROPDOWN_MENU_IMPORTS } from '../dropdown-menu'; +import { SANRING_SIDEBAR_IMPORTS } from '../sidebar'; + +export interface DashboardShellNavItem { + label: string; + href?: string; + badge?: string; +} + +@Component({ + selector: 'sanring-dashboard-shell', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + LucideLayoutDashboard, + LucideLogOut, + LucidePanelLeft, + LucideSettings, + LucideUsers, + SANRING_AVATAR_IMPORTS, + BadgeDirective, + SANRING_BREADCRUMB_IMPORTS, + SANRING_DROPDOWN_MENU_IMPORTS, + SANRING_SIDEBAR_IMPORTS, + ], + template: ` +
+ + + +
+
+ +
+
+
{{ brand() }}
+
{{ brandHint() }}
+
+
+
+ + + + Workspace + + + @for (item of navItems(); track item.label) { + + + @switch (item.label) { + @case ('Customers') { + + } + @case ('Settings') { + + } + @default { + + } + } + {{ item.label }} + @if (item.badge) { + {{ item.badge }} + } + + + } + + + + + + + + + + {{ userEmail() }} + + + + + + +
+ +
+
+ + + + @for (crumb of breadcrumbs(); track crumb; let last = $last) { + + @if (last) { + {{ crumb }} + } @else { + {{ crumb }} + } + + @if (!last) { + + } + } + + + Live +
+
+ +
+
+
+
+ `, +}) +export class DashboardShellComponent { + readonly brand = input('Acme Inc'); + readonly brandHint = input('Workspace'); + readonly userName = input('Ada Lovelace'); + readonly userEmail = input('ada@acme.dev'); + readonly userInitials = input('AL'); + readonly activeNav = input('Overview'); + readonly breadcrumbs = input(['Workspace', 'Overview']); + readonly navItems = input([ + { label: 'Overview', href: '#' }, + { label: 'Customers', href: '#', badge: '12' }, + { label: 'Settings', href: '#' }, + ]); +} diff --git a/registry/blocks/dashboard-shell/index.ts b/registry/blocks/dashboard-shell/index.ts new file mode 100644 index 00000000..3a53bda4 --- /dev/null +++ b/registry/blocks/dashboard-shell/index.ts @@ -0,0 +1 @@ +export * from './dashboard-shell.component'; diff --git a/registry/blocks/login/index.ts b/registry/blocks/login/index.ts new file mode 100644 index 00000000..69c16441 --- /dev/null +++ b/registry/blocks/login/index.ts @@ -0,0 +1 @@ +export * from './login.component'; diff --git a/registry/blocks/login/login.component.ts b/registry/blocks/login/login.component.ts new file mode 100644 index 00000000..3ea4c435 --- /dev/null +++ b/registry/blocks/login/login.component.ts @@ -0,0 +1,116 @@ +import { ChangeDetectionStrategy, Component, input, output, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { SANRING_ALERT_IMPORTS } from '../alert'; +import { ButtonDirective } from '../button'; +import { SANRING_CARD_IMPORTS } from '../card'; +import { CheckboxComponent } from '../checkbox'; +import { DividerComponent } from '../divider'; +import { DescriptionDirective, FieldLabelDirective, SanringFieldComponent } from '../field'; +import { InputDirective } from '../input'; +import { LinkDirective } from '../link'; + +@Component({ + selector: 'sanring-login', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormsModule, + SANRING_ALERT_IMPORTS, + ButtonDirective, + SANRING_CARD_IMPORTS, + CheckboxComponent, + DividerComponent, + DescriptionDirective, + FieldLabelDirective, + SanringFieldComponent, + InputDirective, + LinkDirective, + ], + template: ` +
+ + +

{{ title() }}

+

+ {{ description() }} +

+
+ + @if (error()) { + +
Could not sign in
+

{{ error() }}

+
+ } +
+ + + + +

Use the email on your workspace invite.

+
+ + + + + +
+
+ + +
+ Forgot password? +
+ +
+ +

+ No account yet? + Create one +

+
+
+
+ `, +}) +export class LoginComponent { + readonly title = input('Sign in'); + readonly description = input('Enter your email and password to continue.'); + readonly error = input(null); + readonly submitted = output<{ email: string; password: string; rememberMe: boolean }>(); + + protected email = ''; + protected password = ''; + protected rememberMe = false; + protected readonly submitting = signal(false); + + protected onSubmit(): void { + this.submitting.set(true); + this.submitted.emit({ + email: this.email, + password: this.password, + rememberMe: this.rememberMe, + }); + this.submitting.set(false); + } +} diff --git a/registry/blocks/table-page/index.ts b/registry/blocks/table-page/index.ts new file mode 100644 index 00000000..a2922b0f --- /dev/null +++ b/registry/blocks/table-page/index.ts @@ -0,0 +1 @@ +export * from './table-page.component'; diff --git a/registry/blocks/table-page/table-page.component.ts b/registry/blocks/table-page/table-page.component.ts new file mode 100644 index 00000000..d218eab3 --- /dev/null +++ b/registry/blocks/table-page/table-page.component.ts @@ -0,0 +1,301 @@ +import { CdkTableModule } from '@angular/cdk/table'; +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { LucideEllipsis, LucidePlus, LucideTrash2 } from '@lucide/angular'; +import { BadgeDirective } from '../badge'; +import { ButtonDirective } from '../button'; +import { CheckboxComponent, type CheckedState } from '../checkbox'; +import { SANRING_DROPDOWN_MENU_IMPORTS } from '../dropdown-menu'; +import { FieldLabelDirective, SanringFieldComponent } from '../field'; +import { InputDirective } from '../input'; +import { PaginatorComponent, type PageEvent } from '../pagination'; +import { SANRING_SELECT_IMPORTS } from '../select'; +import { SANRING_SHEET_IMPORTS } from '../sheet'; +import { SkeletonDirective } from '../skeleton'; +import { SANRING_TABLE_IMPORTS } from '../table'; +import { SANRING_TOAST_IMPORTS, ToastService } from '../toast'; + +export interface TablePageRow { + id: string; + customer: string; + status: 'Paid' | 'Pending' | 'Overdue'; + amount: number; +} + +const SEED: TablePageRow[] = [ + { id: 'INV-001', customer: 'Northwind', status: 'Paid', amount: 2400 }, + { id: 'INV-002', customer: 'Contoso', status: 'Pending', amount: 1800 }, + { id: 'INV-003', customer: 'Adventure Works', status: 'Overdue', amount: 920 }, + { id: 'INV-004', customer: 'Fabrikam', status: 'Paid', amount: 3100 }, + { id: 'INV-005', customer: 'Wide World', status: 'Pending', amount: 640 }, +]; + +@Component({ + selector: 'sanring-table-page', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ToastService], + imports: [ + CdkTableModule, + FormsModule, + LucideEllipsis, + LucidePlus, + LucideTrash2, + BadgeDirective, + ButtonDirective, + CheckboxComponent, + SANRING_DROPDOWN_MENU_IMPORTS, + FieldLabelDirective, + SanringFieldComponent, + InputDirective, + PaginatorComponent, + SANRING_SELECT_IMPORTS, + SANRING_SHEET_IMPORTS, + SkeletonDirective, + SANRING_TABLE_IMPORTS, + SANRING_TOAST_IMPORTS, + ], + template: ` +
+ +
+
+

Invoices

+

Search, filter, and edit sample rows.

+
+ + + + + New invoice + Creates a local demo row. + +
+ + + + + + + + +
+
+
+
+ +
+ + + + + + + + + All + Paid + Pending + Overdue + + +
+ + @if (loading()) { +
+
+
+
+
+ } @else { + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + Invoice{{ row.id }}Customer{{ row.customer }}Status + {{ row.status }} + Amount + {{ formatAmount(row.amount) }} + + + + + + + +
+
+ + } +
+ `, +}) +export class TablePageComponent { + private readonly toast = inject(ToastService); + + protected readonly columns = ['select', 'invoice', 'customer', 'status', 'amount', 'actions']; + protected readonly pageSize = 4; + protected readonly loading = signal(false); + protected readonly query = signal(''); + protected readonly statusFilter = signal('all'); + protected readonly pageIndex = signal(0); + protected readonly selected = signal(new Set()); + protected readonly rows = signal(SEED); + protected sheetOpen = false; + protected draftCustomer = ''; + + protected readonly filteredRows = computed(() => { + const q = this.query().trim().toLowerCase(); + const status = this.statusFilter(); + return this.rows().filter((row) => { + const matchesQuery = !q || row.id.toLowerCase().includes(q) || row.customer.toLowerCase().includes(q); + const matchesStatus = status === 'all' || row.status === status; + return matchesQuery && matchesStatus; + }); + }); + + protected readonly pageRows = computed(() => { + const start = this.pageIndex() * this.pageSize; + return this.filteredRows().slice(start, start + this.pageSize); + }); + + protected readonly headerSelection = computed(() => { + const visible = this.pageRows(); + if (visible.length === 0) return false; + const selectedCount = visible.filter((row) => this.selected().has(row.id)).length; + if (selectedCount === 0) return false; + if (selectedCount === visible.length) return true; + return 'indeterminate'; + }); + + protected formatAmount(amount: number): string { + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount); + } + + protected statusVariant(status: TablePageRow['status']): 'default' | 'secondary' | 'destructive' { + if (status === 'Paid') return 'default'; + if (status === 'Pending') return 'secondary'; + return 'destructive'; + } + + protected toggleRow(id: string, checked: CheckedState): void { + this.selected.update((current) => { + const next = new Set(current); + if (checked === true) next.add(id); + else next.delete(id); + return next; + }); + } + + protected togglePage(checked: CheckedState): void { + this.selected.update((current) => { + const next = new Set(current); + for (const row of this.pageRows()) { + if (checked === true) next.add(row.id); + else next.delete(row.id); + } + return next; + }); + } + + protected onPage(event: PageEvent): void { + this.pageIndex.set(event.pageIndex); + } + + protected addRow(): void { + const customer = this.draftCustomer.trim(); + if (!customer) return; + const id = `INV-${String(this.rows().length + 1).padStart(3, '0')}`; + this.rows.update((rows) => [...rows, { id, customer, status: 'Pending', amount: 0 }]); + this.draftCustomer = ''; + this.sheetOpen = false; + this.toast.show({ type: 'success', title: 'Invoice created', description: id }); + } + + protected removeRow(id: string): void { + this.rows.update((rows) => rows.filter((row) => row.id !== id)); + this.selected.update((current) => { + const next = new Set(current); + next.delete(id); + return next; + }); + this.toast.show({ type: 'default', title: 'Invoice removed', description: id }); + } +} diff --git a/registry/components/dialog/dialog-header.component.ts b/registry/components/dialog/dialog-header.component.ts index ce1a7249..cdfcc098 100644 --- a/registry/components/dialog/dialog-header.component.ts +++ b/registry/components/dialog/dialog-header.component.ts @@ -13,10 +13,14 @@ import { cn } from '../shared/utils'; export class DialogHeaderComponent { readonly class = input(); + readonly align = input<'start' | 'center' | undefined>(undefined); + protected readonly dialogHeaderClass = computed(() => cn( - // 垂直排列、預設置中 (手機版常見)、桌機版靠左對齊 - 'flex flex-col space-y-1.5 text-center sm:text-left', + 'flex flex-col space-y-1.5', + this.align() === 'center' && 'items-center text-center', + this.align() === 'start' && 'items-start text-left', + !this.align() && 'text-center sm:text-left', this.class(), ), ); diff --git a/registry/components/field/field.component.ts b/registry/components/field/field.component.ts index dc51b396..eeba290a 100644 --- a/registry/components/field/field.component.ts +++ b/registry/components/field/field.component.ts @@ -51,7 +51,7 @@ function findAmbientBackground(el: HTMLElement): string | null { 所以 label 寫在前面即可同時滿足「非 floating 時 label 要先出現」跟「floating 時疊在 input 上」 -->
- +
diff --git a/registry/registry.json b/registry/registry.json index 18dda622..d017a198 100644 --- a/registry/registry.json +++ b/registry/registry.json @@ -75,6 +75,16 @@ "transfer", "tree" ] + }, + { + "id": "blocks", + "title": "Blocks", + "description": "Installable page-level templates assembled from existing components.", + "components": [ + "dashboard-shell", + "login", + "table-page" + ] } ], "shared": [ @@ -1202,5 +1212,66 @@ "navigation-menu/index.ts" ] } + ], + "blocks": [ + { + "name": "dashboard-shell", + "description": "App chrome with sidebar navigation, breadcrumbs, and a user menu. Wrap page content in ng-content.", + "tags": ["layout", "shell"], + "componentDeps": [ + "sidebar", + "dropdown-menu", + "avatar", + "breadcrumb", + "badge" + ], + "files": [ + "dashboard-shell/dashboard-shell.component.ts", + "dashboard-shell/index.ts" + ] + }, + { + "name": "login", + "description": "Sign-in page with email, password, remember-me, and error alert.", + "tags": ["auth", "page"], + "componentDeps": [ + "card", + "field", + "input", + "label", + "button", + "checkbox", + "link", + "divider", + "alert" + ], + "files": [ + "login/login.component.ts", + "login/index.ts" + ] + }, + { + "name": "table-page", + "description": "Data table page with search, status filter, row selection, sheet editor, skeleton, and toast.", + "tags": ["data", "page"], + "componentDeps": [ + "table", + "pagination", + "input", + "select", + "dropdown-menu", + "checkbox", + "badge", + "sheet", + "skeleton", + "toast", + "field", + "button" + ], + "files": [ + "table-page/table-page.component.ts", + "table-page/index.ts" + ] + } ] }